Compare commits

...
94 changed files with 1448 additions and 506 deletions

No files matched your search

+23
View File
@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.8.2-beta
**New Features**:
- added `source.config.denyByDefault` configuration to enable a deny-by-default access rule. A source enabled with this will deny access unless an "allow" rule was specifically created. (Similar to creating a root-level denyAll rule)
- allow oidc user source access and permission based on username and groups is fulfilled by denyByDefault source with access rules https://github.com/gtsteffaniak/filebrowser/issues/824
- "open parent folder" in context menu and search results https://github.com/gtsteffaniak/filebrowser/issues/1121
- added friendly "share not found" page.
**Notes**:
- 8.0 ffmpeg version bundled with docker
- go 1.25 upgrade with green tea GC enabled
- totp secrets accept non-secure strings, only throwing warning
- adjusted download limit so it also counts viewing text "content" of files (like in editor). You can also "disable file viewing" to stop the editor from showing. lower quality file image previews are not counted as downloads.
- updated invalid share message to be more clear https://github.com/gtsteffaniak/filebrowser/issues/1120
**BugFixes**:
- fixed /public/static routes issue
- shares redirect to login - https://github.com/gtsteffaniak/filebrowser/issues/1109
- some static assets not available to anonymous user - https://github.com/gtsteffaniak/filebrowser/issues/1102
- more safari style issues https://github.com/gtsteffaniak/filebrowser/issues/1110
- fix public share download issues https://github.com/gtsteffaniak/filebrowser/issues/1118 https://github.com/gtsteffaniak/filebrowser/issues/1089
- fixed disable file viewer setting and enforced on backend
## v0.8.1-beta
**New Features**:
+2 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:7.1.1-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -6,6 +6,7 @@ WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
+1
View File
@@ -9,5 +9,6 @@ RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1
View File
@@ -4,6 +4,7 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
+1
View File
@@ -6,6 +6,7 @@ services:
- "80:80"
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser:
hostname: filebrowser
volumes:
+1
View File
@@ -0,0 +1 @@
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
+2 -2
View File
@@ -1,6 +1,6 @@
server:
port: 8080
baseURL: "/"
baseURL: "/subpath"
logging:
- levels: "info|error|debug"
sources:
@@ -32,5 +32,5 @@ userDefaults:
permissions:
admin: false
modify: true
share: false
share: true
api: false
+14 -2
View File
@@ -7,13 +7,25 @@ server {
listen 80;
server_name localhost 127.0.0.1;
location / {
location /subpath/public/ {
proxy_pass http://filebrowser:8080/subpath/public/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# requires basic auth demo-127.0.0.1:SecurePass123!
location /subpath {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://filebrowser:8080;
proxy_pass http://filebrowser:8080/subpath;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -10,7 +10,7 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
//globalSetup: "./global-setup",
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 3000,
testDir: "./tests/playwright/proxy",
/* Run tests in files in parallel */
@@ -25,8 +25,8 @@ export default defineConfig({
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 3000,
//storageState: "loginAuth.json",
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
+17 -4
View File
@@ -2,10 +2,13 @@ package auth
import (
"encoding/base64"
"log"
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/crypto/pbkdf2"
)
// StorageBackend is a storage backend for auth storage.
@@ -35,9 +38,19 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
if err != nil {
return nil, err
}
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
if err != nil {
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
if settings.Config.Auth.TotpSecret != "" {
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
keyLen := len(key)
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 25) {
// Use the user-provided key if it's valid
encryptionKey = key
} else {
// Otherwise, derive the key from the provided secret string
log.Println("[WARN] totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
}
}
return store, nil
}
+8 -2
View File
@@ -44,10 +44,10 @@ func Initialize(configFile string) {
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend()
setupFrontend(false)
}
func setupFrontend() {
func setupFrontend(generate bool) {
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
@@ -73,6 +73,12 @@ func setupFrontend() {
// check if file exists
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
if generate {
Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{
Description: "Reduce rounded corners",
CSS: "reduce-rounded-corners.css",
}
}
}
}
_, ok := Config.Frontend.Styling.CustomThemes["default"]
+1 -1
View File
@@ -185,7 +185,7 @@ func GenerateYaml() {
setupAuth(true)
setupSources(true)
setupUrls()
setupFrontend()
setupFrontend(true)
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
output := "generated.yaml" // "output YAML file"
+1
View File
@@ -77,6 +77,7 @@ type Source struct {
}
type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault"` // 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.
Disabled bool `json:"disabled"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes"` // optional manual overide interval in minutes to re-index the source
+111 -24
View File
@@ -11,6 +11,7 @@ import (
"github.com/asdine/storm/v3"
"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/users"
"github.com/gtsteffaniak/go-cache/cache"
@@ -52,9 +53,10 @@ type FrontendRuleSet struct {
}
type FrontendAccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
DenyAll bool `json:"denyAll,omitempty"`
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
SourceDenyDefault bool `json:"sourceDenyDefault"`
}
// GroupMap maps group names to a set of usernames.
@@ -134,6 +136,12 @@ func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
return s
}
// ClearCache clears the access cache (useful for testing)
func ClearCache() {
// Recreate the cache to clear it
accessCache = cache.NewCache(1 * time.Minute)
}
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
// The caller must hold the lock.
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
@@ -239,6 +247,7 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
version := 0
@@ -269,6 +278,19 @@ func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool
break
}
}
// No rules found anywhere in the path hierarchy
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if !ok {
logger.Errorf("source %s not found in config during access check", sourcePath)
return false
}
if sourceInfo.Config.DenyByDefault {
return false
}
return true
}
@@ -285,28 +307,27 @@ func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (
if !ok {
return true, false
}
return s.permittedRule(rule, username), true
return s.permittedRule(rule, username, sourcePath), true
}
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
func (s *Storage) permittedRule(rule *AccessRule, username string) bool {
// Check user deny
func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath string) bool {
// Check user deny first - this always takes precedence
if _, found := rule.Deny.Users[username]; found {
return false
}
if rule.DenyAll {
return false
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
// If any allow is present, user must be in at least one
// Check allow rules - these can override DenyAll
hasUserAllow := len(rule.Allow.Users) > 0
hasGroupAllow := len(rule.Allow.Groups) > 0
if hasUserAllow || hasGroupAllow {
// If user is in any allow list, permit them (overrides DenyAll)
if hasUserAllow {
if _, found := rule.Allow.Users[username]; found {
return true
@@ -319,18 +340,30 @@ func (s *Storage) permittedRule(rule *AccessRule, username string) bool {
}
}
}
} else {
return true
}
// Fallback to deny rules
if _, found := rule.Deny.Users[username]; found {
return false
}
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
// User is not in any allow list
// Check DenyByDefault to determine behavior
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
// DenyByDefault=true: allow lists are exclusive, deny users not on the list
return false
} else {
// DenyByDefault=false: allow lists are additive, allow users not on the list
return true
}
}
// Check DenyAll - only applies if no allow rules exist
if rule.DenyAll {
return false
}
// No specific rules apply - check source DenyByDefault setting
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
return false
}
return true
}
@@ -350,7 +383,16 @@ func (s *Storage) isUserInGroup(username, group string) bool {
func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
frontendRules := FrontendAccessRule{
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
@@ -388,6 +430,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
// Return a copy to avoid external mutation
frontendRules := make(map[string]FrontendAccessRule, len(s.AllRules))
rules, ok := s.AllRules[sourcePath]
@@ -397,7 +447,8 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
for indexPath, rule := range rules {
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
@@ -739,6 +790,14 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
userRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
@@ -756,7 +815,8 @@ func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]Fronte
}
if userHasRule {
userRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
@@ -775,6 +835,14 @@ func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]Fronte
func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
groupRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
@@ -792,7 +860,8 @@ func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]Fron
}
if groupHasRule {
groupRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
@@ -811,6 +880,14 @@ func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]Fron
func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allUserRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
@@ -823,7 +900,8 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
@@ -853,6 +931,14 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
// Get source configuration
sourceDenyDefault := false
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
if sourceExists {
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
allGroupRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
@@ -865,7 +951,8 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
+203 -7
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
@@ -29,7 +30,28 @@ func createTestUser(t *testing.T, userStore *users.Storage, username string) {
}
}
func setupTestSources() {
// Setup default test sources with allow-by-default behavior
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
}
func TestPermitted_UserBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -49,6 +71,7 @@ func TestPermitted_UserBlacklist(t *testing.T) {
}
func TestPermitted_UserWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -63,11 +86,12 @@ func TestPermitted_UserWhitelist(t *testing.T) {
t.Error("bob should be permitted (allowed)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (not denied)")
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_GroupBlacklist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -88,6 +112,7 @@ func TestPermitted_GroupBlacklist(t *testing.T) {
}
func TestPermitted_GroupWhitelist(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -103,11 +128,12 @@ func TestPermitted_GroupWhitelist(t *testing.T) {
t.Error("bob should be permitted (in allowed group)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (not in denied group)")
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
}
}
func TestPermitted_NoRule(t *testing.T) {
setupTestSources()
s, _ := createTestStorage(t)
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
@@ -119,6 +145,7 @@ func TestPermitted_NoRule(t *testing.T) {
}
func TestPermitted_CombinedRules(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -163,6 +190,7 @@ func TestPermitted_CombinedRules(t *testing.T) {
}
func TestPermitted_DenyAll(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
@@ -182,12 +210,12 @@ func TestPermitted_DenyAll(t *testing.T) {
t.Error("bob should not be permitted (deny all)")
}
// Test that Allow rule is overridden by DenyAll
// Test that Allow rule overrides DenyAll
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all overrides allow)")
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted (allow overrides deny all)")
}
// Test removing DenyAll
@@ -204,8 +232,176 @@ func TestPermitted_DenyAll(t *testing.T) {
t.Error("alice should be permitted after removing deny all")
}
// Bob should be permitted as he is not on any list, and there is an allow list which he is not on, but he is not on deny either.
// Bob should be permitted because DenyByDefault=false makes allow lists additive (not exclusive)
if !s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should be permitted after removing deny all (not on allow list but not denied)")
t.Error("bob should be permitted after removing deny all (DenyByDefault=false makes allow lists additive)")
}
}
func TestPermitted_DenyByDefault(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled for one source
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Test DenyByDefault: When no rules exist, users should be denied
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (DenyByDefault is true and no rules exist)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no rules exist)")
}
// Test that explicit allow rules override DenyByDefault
if err := s.AllowUser("mnt/storage", "/allowed", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/allowed", "alice") {
t.Error("alice should be permitted (explicit allow rule overrides DenyByDefault)")
}
if s.Permitted("mnt/storage", "/allowed", "bob") {
t.Error("bob should not be permitted (not in allow list and DenyByDefault is true)")
}
// Test that explicit deny rules work with DenyByDefault
if err := s.DenyUser("mnt/storage", "/denied", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/denied", "alice") {
t.Error("alice should not be permitted (explicit deny rule)")
}
if s.Permitted("mnt/storage", "/denied", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true)")
}
// Test that DenyByDefault doesn't affect sources where it's disabled
if !s.Permitted("mnt/open", "/public", "alice") {
t.Error("alice should be permitted in open source (DenyByDefault is false)")
}
if !s.Permitted("mnt/open", "/public", "bob") {
t.Error("bob should be permitted in open source (DenyByDefault is false)")
}
// Test recursive path checking with DenyByDefault
// Allow alice at parent path, should work for child paths too
if err := s.AllowUser("mnt/storage", "/parent", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/parent/child", "alice") {
t.Error("alice should be permitted for child path (inherits from parent allow rule)")
}
if s.Permitted("mnt/storage", "/parent/child", "bob") {
t.Error("bob should not be permitted for child path (DenyByDefault and not in allow list)")
}
// Test that group rules work with DenyByDefault
_ = s.AddUserToGroup("vip", "alice")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (in allowed group)")
}
if s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should not be permitted (not in allowed group and DenyByDefault is true)")
}
}
func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
// Clear access cache to prevent test pollution
access.ClearCache()
// Create isolated storage for this test
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test configuration with DenyByDefault enabled
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// Test that explicit DenyAll rule also works when DenyByDefault is enabled
if err = s.DenyAll("mnt/storage", "/restricted"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should not be permitted (DenyAll rule)")
}
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyAll rule)")
}
// Test that Allow rule overrides DenyAll even with DenyByDefault
if err = s.AllowUser("mnt/storage", "/restricted", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted (allow overrides DenyAll)")
}
// Remove DenyAll and test that DenyByDefault takes effect
removed, err := s.RemoveDenyAll("mnt/storage", "/restricted")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to Allow rule
if !s.Permitted("mnt/storage", "/restricted", "alice") {
t.Error("alice should be permitted after removing DenyAll (has allow rule)")
}
// Bob should not be permitted due to DenyByDefault
if s.Permitted("mnt/storage", "/restricted", "bob") {
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
}
}
+1 -2
View File
@@ -5,7 +5,7 @@ import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisablingFileViewer bool `json:"disableFileViewer,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
@@ -26,7 +26,6 @@ type CommonShare struct {
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
Username string `json:"username,omitempty"`
}
type CreateBody struct {
CommonShare
+4 -27
View File
@@ -71,17 +71,6 @@ func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
}
}
// setUsername populates the Link.Username from the users store using UserID, if available.
func (s *Storage) setUsername(l *Link) {
if l == nil || s.users == nil {
return
}
user, err := s.users.Get(l.UserID)
if err == nil && user != nil {
l.Username = user.Username
}
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
@@ -92,10 +81,7 @@ func (s *Storage) All() ([]*Link, error) {
if err != nil {
return nil, err
}
// warm cache: avoid DB calls under lock
for _, l := range filtered {
s.setUsername(l)
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
@@ -121,10 +107,7 @@ func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
if err != nil {
return nil, err
}
// inject username outside lock
for _, l := range filtered {
s.setUsername(l)
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
@@ -166,7 +149,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
return nil, errors.ErrNotExist
}
s.mu.Lock()
s.setUsername(link)
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
@@ -177,7 +160,7 @@ func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.setUsername(l)
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
@@ -194,9 +177,6 @@ func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
if err != nil {
return nil, err
}
for _, l := range filtered {
s.setUsername(l)
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
@@ -222,9 +202,6 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
if err != nil {
return nil, err
}
for _, l := range filtered {
s.setUsername(l)
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
+4 -4
View File
@@ -1,6 +1,6 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.24.2
go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
@@ -24,7 +24,7 @@ require (
golang.org/x/mod v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.35.0
golang.org/x/time v0.11.0
golang.org/x/time v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -109,7 +109,7 @@ require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695 // indirect
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
@@ -230,7 +230,7 @@ require (
go-simpler.org/musttag v0.13.1 // indirect
go-simpler.org/sloglint v0.11.0 // indirect
go.augendre.info/fatcontext v0.8.0 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
+6 -6
View File
@@ -285,8 +285,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695 h1:W3KSdht88NFfM71hd6Y4j2uiW81/aoqjPrMVoV/ph1E=
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac h1:gBtvd74wZT02fkOyoG651Sx2imv5lKdVvDwpUjKPFnw=
github.com/golang/geo v0.0.0-20250821133510-ecfc33a939ac/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -717,8 +717,8 @@ go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkye
go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk=
go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -947,8 +947,8 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+1 -1
View File
@@ -23,7 +23,7 @@ type GroupListResponse struct {
// @Param path query string false "Index path (e.g. /secret)"
// @Param user query string false "Username to filter rules for"
// @Param group query string false "Group name to filter rules for"
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [get]
+4 -3
View File
@@ -76,11 +76,12 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
err = storage.CreateUser(users.User{
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
Permissions: settings.Config.UserDefaults.Permissions,
})
}
settings.ApplyUserDefaults(&user)
err = storage.CreateUser(user)
if err != nil {
return nil, err
}
+6 -5
View File
@@ -118,7 +118,8 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// Public API routes (hash-based authentication)
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
publicAPI.HandleFunc("GET /preview", withHashFile(publicPreviewHandler))
publicAPI.HandleFunc("GET /shared", withHashFile(publicShareHandler))
publicAPI.HandleFunc("GET /resources", withHashFile(publicShareHandler))
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
@@ -203,11 +204,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// New frontend share route handler - handle share page and any subpaths
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Public static assets (needed for shared pages to load CSS/JS/images)
publicRoutes.HandleFunc("GET /static/", staticFilesHandler)
// Static and index file handlers
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
staticPrefix := config.Server.BaseURL + "static/"
router.Handle(staticPrefix, http.StripPrefix(staticPrefix, http.HandlerFunc(staticFilesHandler)))
publicRoutes.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(staticFilesHandler)))
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
+35 -7
View File
@@ -27,6 +27,7 @@ type requestContext struct {
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
}
@@ -50,6 +51,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
data.share = &share.Link{}
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
if link.DisableAnonymous && data.user.Username == "anonymous" {
@@ -77,18 +79,27 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return http.StatusForbidden, fmt.Errorf("the target source is private")
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
if link.DisableFileViewer || link.Downloads >= link.DownloadsLimit {
getContent = false
}
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Modify: false,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
Content: getContent,
})
file.Token = link.Token
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
if getContent && file.Content != "" {
link.Mu.Lock()
link.Downloads++
link.Mu.Unlock()
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
// Set the file info in the `data` object
data.fileInfo = file
@@ -114,9 +125,14 @@ func withAdminHelper(fn handleFunc) handleFunc {
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/"
@@ -128,6 +144,8 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
remaining = remaining[:idx]
}
if remaining != "" {
isShareRequest = true
shareHash = remaining
var err error
link, err = store.Share.GetByHash(remaining)
if err != nil {
@@ -138,13 +156,24 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
}
}
// 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
}
}
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if link != nil {
data.share = link
if data.share != nil && data.shareValid {
if data.user != nil {
data.user.CustomTheme = link.ShareTheme
data.user.CustomTheme = data.share.ShareTheme
}
}
return fn(w, r, data)
@@ -155,9 +184,8 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
// If user authentication failed, call the handler without user context
// Clear any user data that might have been partially set
data.token = ""
if link != nil {
data.share = link
data.user.CustomTheme = link.ShareTheme
if data.share != nil && data.shareValid {
data.user.CustomTheme = data.share.ShareTheme
}
// Call the handler function without user context
return fn(w, r, data)
+4 -4
View File
@@ -292,11 +292,11 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodOidc,
}
settings.ApplyUserDefaults(user)
user.Username = username
user.LoginMethod = users.LoginMethodOidc
user.Permissions = settings.Config.UserDefaults.Permissions
if isAdmin {
user.Permissions.Admin = true
}
+19 -2
View File
@@ -45,8 +45,25 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
fileList := []string{}
for _, file := range strings.Split(f, "||") {
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
// Check if file already contains source prefix (source::path format)
if strings.Contains(file, "::") {
splitFile := strings.SplitN(file, "::", 2)
if len(splitFile) == 2 {
source := splitFile[0]
path := splitFile[1]
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, path)
fileList = append(fileList, source+"::"+filePath)
} else {
// Fallback: treat as plain path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
}
} else {
// Plain path without source prefix
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
}
}
var status int
+16 -3
View File
@@ -104,6 +104,7 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
path = utils.JoinPathAsUnix(userScope, path)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
@@ -173,15 +174,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
} else {
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
// If we get "is a directory" error, this is likely a symlink to a directory
// that wasn't properly detected. Skip it gracefully.
if strings.Contains(err.Error(), "is a directory") {
return nil
}
return err
}
defer file.Close()
@@ -191,6 +197,11 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
return err
}
// Double-check if this is actually a directory (in case of symlinks)
if info.IsDir() {
return nil
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if err != nil {
@@ -240,6 +251,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
@@ -395,6 +407,7 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
}
path = utils.JoinPathAsUnix(userScope, path)
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return http.StatusInternalServerError, err
+5 -1
View File
@@ -85,6 +85,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, path)
getContent := r.URL.Query().Get("content") == "true"
if d.share != nil && d.share.DisableFileViewer {
getContent = false
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: d.user.Username,
@@ -92,7 +96,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
Content: getContent,
})
if err != nil {
return errToStatus(err), err
+82 -13
View File
@@ -17,9 +17,35 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// ShareResponse represents a share with computed username field and download URL
type ShareResponse struct {
*share.Link
Username string `json:"username,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
}
// convertToShareResponse converts shares to response format with usernames
func convertToShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
responses := make([]*ShareResponse, len(shares))
for i, s := range shares {
user, err := store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
responses[i] = &ShareResponse{
Link: s,
Username: username,
DownloadURL: getDownloadURL(r, s.Hash),
}
}
return responses, nil
}
// shareListHandler returns a list of all share links.
// @Summary List share links
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
@@ -41,7 +67,11 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
return renderJSON(w, r, shares)
sharesWithUsernames, err := convertToShareResponse(r, shares)
if err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, sharesWithUsernames)
}
// shareGetsHandler retrieves share links for a specific resource path.
@@ -74,16 +104,17 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
scopePath := utils.JoinPathAsUnix(userscope, path)
s, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
if err == errors.ErrNotExist || len(s) == 0 {
return renderJSON(w, r, []*share.Link{})
}
// Overwrite the Source field with the source name from the query for each link
for _, link := range s {
link.DownloadURL = getDownloadURL(r, link.Hash)
return renderJSON(w, r, []*ShareResponse{})
}
// DownloadURL will be set in convertToShareResponse
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
return renderJSON(w, r, s)
sharesWithUsernames, err := convertToShareResponse(r, s)
if err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, sharesWithUsernames)
}
// shareDeleteHandler deletes a specific share link by its hash.
@@ -190,11 +221,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
body.Path = s.Path
body.Source = s.Source
s.CommonShare = body.CommonShare
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
// Convert to ShareResponse format with username
var user *users.User
user, err = store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
response := &ShareResponse{
Link: s,
Username: username,
}
return renderJSON(w, r, response)
}
source, ok := config.Server.NameToSource[body.Source]
@@ -243,10 +284,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
CommonShare: body.CommonShare,
}
if err := store.Share.Save(s); err != nil {
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
// Convert to ShareResponse format with username
var user *users.User
user, err = store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
response := &ShareResponse{
Link: s,
Username: username,
}
return renderJSON(w, r, response)
}
// DirectDownloadResponse represents the response for direct download endpoint
@@ -410,9 +462,26 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
func getDownloadURL(r *http.Request, hash string) string {
var downloadURL string
if config.Server.ExternalUrl != "" {
downloadURL = fmt.Sprintf("%spublic/api/raw?hash=%s", config.Server.ExternalUrl, hash)
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
} else {
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", getScheme(r), r.Host, config.Server.BaseURL, hash)
// Prefer X-Forwarded-Host for proxy support
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
}
return downloadURL
}
+59 -42
View File
@@ -45,7 +45,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
}
}
defaultThemeColor := "#455a64"
staticURL := config.Server.BaseURL + "static"
publicStaticURL := config.Server.BaseURL + "public/static"
data := map[string]interface{}{
"title": config.Frontend.Name,
"customCSS": config.Frontend.Styling.CustomCSS,
@@ -55,51 +57,67 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": staticURL + "/img/icons/favicon-256x256.png",
"color": "var(--primaryColor)",
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server",
}
shareOverrides := map[string]interface{}{
"isShare": false,
"banner": "",
"title": "",
"quickDownload": false,
"description": "",
"themeColor": "",
"disableThumbnails": false,
"viewMode": "list",
"disableFileViewer": false,
"disableShareCard": false,
"disableSidebar": false,
shareProps := map[string]interface{}{
"isShare": false,
"isValid": false,
"banner": "",
"title": "",
"quickDownload": false,
"description": "",
"themeColor": "",
"disableThumbnails": false,
"viewMode": "list",
"disableFileViewer": false,
"disableShareCard": false,
"disableSidebar": false,
"isPasswordProtected": false,
"hash": "",
}
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil {
disableNavButtons = disableNavButtons || d.share.HideNavButtons
shareOverrides["viewMode"] = d.share.ViewMode
shareOverrides["banner"] = d.share.Banner
shareOverrides["title"] = d.share.Title
shareOverrides["description"] = d.share.Description
shareOverrides["themeColor"] = d.share.ThemeColor
shareOverrides["quickDownload"] = d.share.QuickDownload
shareOverrides["disableThumbnails"] = d.share.DisableThumbnails
shareOverrides["disableFileViewer"] = d.share.DisablingFileViewer
shareOverrides["disableShareCard"] = d.share.DisableShareCard
shareOverrides["disableSidebar"] = d.share.DisableSidebar
shareOverrides["isShare"] = true
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
} else {
data["favicon"] = staticURL + "/" + d.share.Favicon
shareProps["isShare"] = true
shareProps["isValid"] = d.shareValid
shareProps["hash"] = d.share.Hash
if d.shareValid {
disableNavButtons = disableNavButtons || d.share.HideNavButtons
shareProps["viewMode"] = d.share.ViewMode
shareProps["banner"] = d.share.Banner
shareProps["title"] = d.share.Title
shareProps["description"] = d.share.Description
shareProps["themeColor"] = d.share.ThemeColor
shareProps["quickDownload"] = d.share.QuickDownload
shareProps["disableThumbnails"] = d.share.DisableThumbnails
shareProps["disableFileViewer"] = d.share.DisableFileViewer
shareProps["disableShareCard"] = d.share.DisableShareCard
shareProps["disableSidebar"] = d.share.DisableSidebar
shareProps["isPasswordProtected"] = d.share.PasswordHash != ""
shareProps["downloadURL"] = getDownloadURL(r, d.share.Hash)
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
} else {
data["favicon"] = publicStaticURL + "/" + d.share.Favicon
}
}
if d.share.Description != "" {
data["description"] = d.share.Description
}
if d.share.Title != "" {
data["title"] = d.share.Title
}
}
if d.share.Description != "" {
data["description"] = d.share.Description
}
if d.share.Title != "" {
data["title"] = d.share.Title
}
// base url could be different for routes behind proxy
data["staticURL"] = publicStaticURL
data["favicon"] = publicStaticURL + "/img/icons/favicon-256x256.png"
data["winIcon"] = publicStaticURL + "/img/icons/mstile-144x144.png"
data["appIcon"] = publicStaticURL + "/img/icons/android-chrome-256x256.png"
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -124,7 +142,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"share": shareOverrides,
"share": shareProps,
}
jsonVars, err := json.Marshal(data["globalVars"])
if err != nil {
@@ -143,10 +161,9 @@ func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
// Remove "/static/" from the request path
adjustedPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%vstatic/", config.Server.BaseURL))
adjustedCompressed := adjustedPath + ".gz"
if strings.HasSuffix(adjustedPath, ".js") {
adjustedCompressed := r.URL.Path + ".gz"
if strings.HasSuffix(r.URL.Path, ".js") {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
}
// Check if the gzipped version of the file exists
@@ -159,7 +176,7 @@ func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
}
} else {
// Otherwise, serve the regular file
http.StripPrefix(fmt.Sprintf("%vstatic/", config.Server.BaseURL), http.FileServer(http.FS(assetFs))).ServeHTTP(w, r)
http.FileServer(http.FS(assetFs)).ServeHTTP(w, r)
}
}
+5 -7
View File
@@ -56,7 +56,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"description": "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"schema": {
"type": "object"
}
@@ -2985,6 +2985,10 @@ const docTemplate = `{
"description": "default \"/\" should match folders under path",
"type": "string"
},
"denyByDefault": {
"description": "deny access unless an \"allow\" access rule was specifically created.",
"type": "boolean"
},
"disableIndexing": {
"description": "disable the indexing of this source",
"type": "boolean"
@@ -3256,9 +3260,6 @@ const docTemplate = `{
"unit": {
"type": "string"
},
"username": {
"type": "string"
},
"viewMode": {
"type": "string"
}
@@ -3354,9 +3355,6 @@ const docTemplate = `{
"userID": {
"type": "integer"
},
"username": {
"type": "string"
},
"viewMode": {
"type": "string"
}
+5 -7
View File
@@ -45,7 +45,7 @@
],
"responses": {
"200": {
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"description": "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"schema": {
"type": "object"
}
@@ -2974,6 +2974,10 @@
"description": "default \"/\" should match folders under path",
"type": "string"
},
"denyByDefault": {
"description": "deny access unless an \"allow\" access rule was specifically created.",
"type": "boolean"
},
"disableIndexing": {
"description": "disable the indexing of this source",
"type": "boolean"
@@ -3245,9 +3249,6 @@
"unit": {
"type": "string"
},
"username": {
"type": "string"
},
"viewMode": {
"type": "string"
}
@@ -3343,9 +3344,6 @@
"userID": {
"type": "integer"
},
"username": {
"type": "string"
},
"viewMode": {
"type": "string"
}
+6 -6
View File
@@ -515,6 +515,9 @@ definitions:
defaultUserScope:
description: default "/" should match folders under path
type: string
denyByDefault:
description: deny access unless an "allow" access rule was specifically created.
type: boolean
disableIndexing:
description: disable the indexing of this source
type: boolean
@@ -716,8 +719,6 @@ definitions:
type: string
unit:
type: string
username:
type: string
viewMode:
type: string
type: object
@@ -785,8 +786,6 @@ definitions:
type: string
userID:
type: integer
username:
type: string
viewMode:
type: string
type: object
@@ -1083,8 +1082,9 @@ paths:
- application/json
responses:
"200":
description: Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule,
map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule
description: Varies based on query. Can be access.FrontendAccessRule (when
source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule,
or map[string]access.FrontendAccessRule
schema:
type: object
"400":
+2 -52
View File
@@ -21,6 +21,7 @@ server:
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
name: "backend" # display name
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.
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
indexingIntervalMinutes: 0 # optional manual overide interval in minutes to re-index the source
@@ -99,58 +100,7 @@ frontend:
customThemes: # A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
alternative:
description: Reduce rounded corners
css: |-
.card,
#listingView.gallery .item,
#listingView.normal .item,
.button,
button,
.button-action,
.input,
input,
#listingView.gallery .item img,
#listingView .item img,
.sizeInputWrapper,
.sizeInput,
#search-input,
.floating-tooltip,
.settings-items>.item,
.button-group {
border-radius: 0.25em !important;
}
#listingView.compact .header {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
#listingView.list .item:last-child,
#results {
border-bottom-left-radius: 0.25em !important;
border-bottom-right-radius: 0.25em !important;
}
#listingView.list .item:first-child {
border-top-left-radius: 0.25em !important;
border-top-right-radius: 0.25em !important;
}
#breadcrumbs ul li:last-child a {
border-top-right-radius: 0.25em !important;
border-bottom-right-radius: 0.25em !important;
}
#breadcrumbs ul li:first-child a {
border-top-left-radius: 0.25em !important;
border-bottom-left-radius: 0.25em !important;
}
.button-group button {
border-radius: 0 !important;
}
.icon {
border-radius: 0.1em !important;
}
css: reduce-rounded-corners.css
default:
description: The default theme
css: ""
+4 -4
View File
@@ -1,6 +1,6 @@
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
import { state } from "@/store";
@@ -23,7 +23,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
...(content && { content: 'true' }),
...(state.share.token && { token: state.share.token })
}
const apiPath = getApiPath("public/api/shared", params);
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
@@ -63,7 +63,7 @@ export function getDownloadURL(share, files, inline=false) {
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getApiPath("public/api/raw", params);
const apiPath = getPublicApiPath("raw", params);
return window.origin + apiPath
}
@@ -81,7 +81,7 @@ export function getPreviewURL(path,size="small") {
inline: 'true',
...(state.share.token && { token: state.share.token })
}
const apiPath = getApiPath('public/api/preview', params)
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
} catch (/** @type {any} */ err) {
notify.showError(err.message || 'Error getting preview URL')
+11 -7
View File
@@ -1,9 +1,10 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath } from '@/utils/url.js'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
import { setNewToken } from '@/utils/auth.js' // Import setNewToken for token management
import { shareInfo } from '@/utils/constants'
export async function getAllUsers () {
export async function getAllUsers() {
try {
const apiPath = getApiPath('api/users')
return await fetchJSON(apiPath)
@@ -13,7 +14,7 @@ export async function getAllUsers () {
}
}
export async function generateOTP (username, password) {
export async function generateOTP(username, password) {
const params = { username }
try {
let apiPath = getApiPath('api/auth/otp/generate', params)
@@ -30,7 +31,7 @@ export async function generateOTP (username, password) {
}
}
export async function verifyOtp (username, password, otp) {
export async function verifyOtp(username, password, otp) {
const params = { username }
try {
let apiPath = getApiPath('api/auth/otp/verify', params)
@@ -91,7 +92,10 @@ export async function login(username, password, recaptcha, otp) {
}
export async function get(id) {
try {
const apiPath = getApiPath('api/users', { id: id })
let apiPath = getApiPath('api/users', { id: id })
if (shareInfo.isShare) {
apiPath = getPublicApiPath('users', { id: id })
}
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
@@ -148,7 +152,7 @@ export function deleteApiKey (params) {
}
}
export async function create (user) {
export async function create(user) {
try {
const apiPath = getApiPath('api/users')
const res = await fetchURL(apiPath, {
@@ -190,7 +194,7 @@ export async function update(user, which = ['all']) {
})
}
export async function remove (id) {
export async function remove(id) {
try {
const apiPath = getApiPath('api/users', { id: id })
await fetchURL(apiPath, {
+2 -1
View File
@@ -1,6 +1,7 @@
import { state } from "@/store";
import { renew, logout } from "@/utils/auth";
import { notify } from "@/notify";
import { shareInfo } from "@/utils/constants";
export async function fetchURL(url, opts, auth = true) {
opts = opts || {};
@@ -34,7 +35,7 @@ export async function fetchURL(url, opts, auth = true) {
let error = new Error(await res.text());
error.status = res.status;
if (auth && res.status == 401) {
if (auth && res.status == 401 && !shareInfo.isShare) {
logout();
}
+3 -3
View File
@@ -18,7 +18,7 @@
</router-link>
</li>
</ul>
<div v-if="isCardView" class="gallery-size card">
<div v-if="showGallerySize" class="gallery-size card">
{{ $t("files.size") }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input
v-model="gallerySize"
@@ -69,8 +69,8 @@ export default {
addPadding() {
return getters.isStickySidebar() || getters.isShare();
},
isCardView() {
return getters.isCardView();
showGallerySize() {
return getters.isCardView() && state.req?.items?.length > 0;
},
items() {
const req = state.req;
+32 -1
View File
@@ -73,6 +73,12 @@
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="!showCreate && selectedCount == 1 && isSearchActive"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
icon="forward"
@@ -117,6 +123,7 @@
:class="{ 'dark-mode': isDarkMode }"
>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('buttons.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview" icon="file_download" :label="$t('buttons.download')" @action="startDownload" />
<action v-if="showEdit" icon="edit" :label="$t('buttons.edit')" @action="edit()" />
<action v-if="showSave" icon="save" :label="$t('buttons.save')" @action="save()" />
@@ -134,6 +141,7 @@ import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
import { filesApi, publicApi } from "@/api";
import { url } from "@/utils";
export default {
name: "ContextMenu",
components: {
@@ -258,8 +266,19 @@ export default {
modify: state.user?.permissions?.modify,
};
},
currentPrompt() {
return getters.currentPrompt();
},
},
watch: {
currentPrompt: {
handler(prompt) {
if (prompt && prompt.name === "ContextMenu") {
this.setPositions();
}
},
deep: true,
},
hasOverflowItems: {
handler(hasItems) {
mutations.setContextMenuHasItems(hasItems);
@@ -287,6 +306,9 @@ export default {
}
},
methods: {
shouldShowParentFolder() {
return this.isPreview && state.req.path != "/";
},
showAccessHover() {
mutations.showHover({
name: "access",
@@ -405,7 +427,8 @@ export default {
},
startDownload() {
mutations.closeHovers();
downloadFiles(state.selected);
const items = state.selected.length > 0 ? state.selected : [state.req];
downloadFiles(items);
},
goToRaw() {
if (getters.isShare()) {
@@ -447,6 +470,14 @@ export default {
},
});
},
openParentFolder() {
const item = state.selected.length > 0 ? state.selected[0] : state.req;
let parentPath = url.removeLastDir(item.path);
if (parentPath == "") {
parentPath = "/";
}
url.goToItem(state.req.source, parentPath);
},
},
};
</script>
+27 -19
View File
@@ -38,7 +38,7 @@
<div class="inputWrapper" style="display: flex">
<select
v-if="multipleSources"
class="searchContext input"
class="searchContext button input"
aria-label="search-path"
v-model="selectedSource"
:value="selectedSource"
@@ -306,13 +306,15 @@ export default {
return this.showHelp;
},
activeStates() {
// Create a Set of combined `name` and `type` keys for efficient lookup
const selectedSet = new Set(
state.selected.map((item) => `${item.name}:${item.type}`)
);
const result = this.results.map((s) => selectedSet.has(`${s.name}:${s.type}`));
// Build a map of active states for the `results` array
return result;
const selectedItems = state.selected ? [].concat(state.selected) : [];
if (selectedItems.length === 0) {
// Return an array of all false if nothing is selected
return new Array(this.results.length).fill(false);
}
const selectedPaths = new Set(selectedItems.map((item) => item.path));
return this.results.map((result) => selectedPaths.has(result.path));
},
sourceInfo() {
return state.sources.info;
@@ -519,17 +521,13 @@ export default {
},
addSelected(event, s) {
const pathParts = url.removeTrailingSlash(s.path).split("/");
let path = this.getContext + s.path;
if (this.getContext === "/") {
path = s.path;
}
let path = this.getContext + url.removeTrailingSlash(s.path);
const modifiedItem = {
name: pathParts.pop(),
path: path,
size: s.size,
type: s.type,
source: this.selectedSource || state.sources.current,
fullPath: path,
};
mutations.resetSelected();
mutations.addSelected(modifiedItem);
@@ -538,7 +536,10 @@ export default {
};
</script>
<style>
<style scoped>
.sizeInputWrapper {
border: 1px solid #ccc;
}
.main-input {
width: 100%;
}
@@ -551,7 +552,6 @@ export default {
border-left: 1px solid gray;
border-right: 1px solid gray;
word-wrap: break-word;
height: -webkit-fill-available;
}
.searchContext.input {
@@ -562,7 +562,16 @@ export default {
width: 25%;
min-width: 7em;
max-width: 15em;
height: -webkit-fill-available;
}
.searchContext.input option {
background: grey;
color: white;
}
.searchContext.input option:hover {
background: var(--primaryColor);
color: white;
}
#results > #result-list {
@@ -670,9 +679,8 @@ export default {
/* IE and Edge */
}
.search-entry {
cursor: pointer;
border-radius: 0.25em;
.search-entry:hover {
background-color: var(--alt-background);
}
.search-entry.active {
+2 -2
View File
@@ -32,7 +32,7 @@ import {
} from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state } from "@/store";
import { shareOverrides } from "@/utils/constants";
import { shareInfo } from "@/utils/constants";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = baseURL + "static/img/placeholder.png"; // A generic loading placeholder
@@ -76,7 +76,7 @@ export default {
},
computed: {
hasPreviewImage() {
if (shareOverrides.disableThumbnails) {
if (shareInfo.disableThumbnails) {
return false;
}
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
@@ -75,7 +75,7 @@ import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import Icon from "@/components/files/Icon.vue";
import { baseURL, serverHasMultipleSources, shareOverrides } from "@/utils/constants";
import { baseURL, serverHasMultipleSources, shareInfo } from "@/utils/constants";
export default {
name: "item",
@@ -115,7 +115,7 @@ export default {
// @ts-ignore
if (getters.isShare()) {
// @ts-ignore
return shareOverrides.quickDownload && !this.isDir;
return shareInfo.quickDownload && !this.isDir;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView && !this.isDir;
@@ -1,16 +1,16 @@
<template>
<div class="share card">
<div class="share__box">
<div v-if="shareOverrides.banner" class="banner">
<div v-if="shareInfo.banner" class="banner">
<img :src="getShareBanner" />
</div>
<div v-if="shareOverrides.title" class="share__box__element">
<h3>{{ shareOverrides.title }}</h3>
<div v-if="shareInfo.title" class="share__box__element">
<h3>{{ shareInfo.title }}</h3>
</div>
<div v-if="shareOverrides.description" class="share__box__element">
<p>{{ shareOverrides.description }}</p>
<div v-if="shareInfo.description" class="share__box__element">
<p>{{ shareInfo.description }}</p>
</div>
<hr v-if="shareOverrides.banner || shareOverrides.title || shareOverrides.description" />
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<div class="share__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
@@ -38,7 +38,7 @@ import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getTypeInfo } from "@/utils/mimetype";
import QrcodeVue from "qrcode.vue";
import { shareOverrides } from "@/utils/constants";
import { shareInfo } from "@/utils/constants";
export default {
name: "ShareInfo",
@@ -61,13 +61,13 @@ export default {
},
computed: {
getShareBanner() {
if (this.shareOverrides.banner.startsWith("http")) {
return this.shareOverrides.banner;
if (this.shareInfo.banner.startsWith("http")) {
return this.shareInfo.banner;
}
return publicApi.getDownloadURL(state.share, [this.shareOverrides.banner]);
return publicApi.getDownloadURL(state.share, [this.shareInfo.banner]);
},
shareOverrides() {
return shareOverrides;
shareInfo() {
return shareInfo;
},
req() {
return state.req;
+52 -10
View File
@@ -16,11 +16,24 @@
</div>
<div v-else>
<p>{{ $t("prompts.source", { suffix: ":" }) }} {{ currentSource }}</p>
<div aria-label="access-path" class="searchContext clickable" @click="startPathEdit">
<div aria-label="access-path" class="searchContext clickable button" @click="startPathEdit">
{{ $t("search.path") }} {{ currentPath }}
</div>
<!-- Default behavior banner -->
<div class="card item">
<div class="card-content banner-content">
<i class="material-icons">{{ sourceDenyDefault ? 'block' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
}}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('access.defaultBehaviorDescription'))" @mouseleave="hideTooltip">
help
</i>
</div>
</div>
<!-- Add Form -->
<div class="form-flex-group" >
<div class="form-flex-group">
<select class="input flat-right form-compact" v-model="addType">
<option value="user">{{ $t("general.user") }}</option>
<option value="group">{{ $t("general.group") }}</option>
@@ -30,7 +43,8 @@
<option value="deny">{{ $t("access.deny") }}</option>
<option value="allow">{{ $t("access.allow") }}</option>
</select>
<input v-if="addType !== 'all'" class="input flat-right flat-left form-grow form-compact" v-model="addName" :placeholder="$t('access.enterName')" list="group-suggestions" />
<input v-if="addType !== 'all'" class="input flat-right flat-left form-grow form-compact" v-model="addName"
:placeholder="$t('access.enterName')" list="group-suggestions" />
<datalist id="group-suggestions">
<option v-for="group in groups" :key="group" :value="group"></option>
</datalist>
@@ -48,7 +62,8 @@
</tr>
<tr v-for="entry in entries" :key="entry.type + '-' + entry.name">
<td>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</td>
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") : $t('access.all')) }}</td>
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") :
$t('access.all')) }}</td>
<td>{{ entry.name }}</td>
<td>
<button @click="deleteAccess(entry)" class="action" :aria-label="$t('buttons.delete')"
@@ -62,11 +77,11 @@
</div>
</div>
<div class="card-action">
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.close')"
:title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
</div>
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey"
:aria-label="$t('buttons.close')" :title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
</div>
</template>
<script>
@@ -90,6 +105,7 @@ export default {
currentSource: this.sourceName,
tempSource: this.sourceName,
rule: { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } },
sourceDenyDefault: false,
addType: "user",
addListType: "deny",
addName: "",
@@ -171,10 +187,14 @@ export default {
},
async fetchRule() {
try {
this.rule = await accessApi.get(this.currentSource, this.currentPath);
const response = await accessApi.get(this.currentSource, this.currentPath);
// Handle new API response structure - now sourceDenyDefault is part of the rule
this.rule = response;
this.sourceDenyDefault = response.sourceDenyDefault || false;
} catch (e) {
notify.showError(e);
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
this.sourceDenyDefault = false;
}
},
/**
@@ -225,6 +245,16 @@ export default {
} else {
this.$emit('close');
}
},
showTooltip(event, text) {
mutations.showTooltip({
content: text,
x: event.clientX,
y: event.clientY,
});
},
hideTooltip() {
mutations.hideTooltip();
}
}
};
@@ -234,4 +264,16 @@ export default {
.form-flex-group {
margin-top: 1em;
}
.banner-content {
display: flex;
justify-content: center;
align-items: center;
padding: 0.25em !important;
gap: 0.5em;
}
.searchContext {
margin-bottom: 1em;
}
</style>
@@ -11,7 +11,7 @@
<!-- Duration Input -->
<p>{{ $t('api.tokenDuration') }}</p>
<div class="inputWrapper">
<div class="sizeInputWrapper">
<input class="sizeInput roundedInputLeft input" v-model.number="duration" type="number" min="1"
:placeholder="$t('api.durationNumberPlaceholder')" />
<select v-model="unit" class="roundedInputRight input">
@@ -97,3 +97,8 @@ export default {
},
};
</script>
<style scoped>
.sizeInputWrapper {
display: flex !important;
}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@
<p v-else>
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
</p>
<div style="display: grid" aria-label="delete-path" class="searchContext">
<div style="display: grid" aria-label="delete-path" class="searchContext button">
<span v-for="(item, index) in nav" :key="index"> {{ item }} </span>
</div>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
</div>
<div>Source: {{ sourcePath.source }} </div> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div aria-label="filelist-path" class="searchContext clickable">{{$t('search.path')}} {{ sourcePath.path }}</div>
<div aria-label="filelist-path" class="searchContext button clickable">{{$t('search.path')}} {{ sourcePath.path }}</div>
<ul class="file-list">
<li
@click="itemClick"
@@ -0,0 +1,77 @@
<template>
<div class="card-title">
<h2>{{ $t("general.password") }}</h2>
</div>
<div class="card-content">
<div v-if="showWrongCredentials" class="share__wrong__password">
{{ $t("login.wrongCredentials") }}
</div>
<div class="form-flex-group">
<input
class="input share-password"
v-focus
type="password"
:placeholder="$t('general.password')"
v-model="password"
@keyup.enter="submit"
/>
</div>
</div>
<div class="card-action">
<button
class="button button--flat"
@click="submit"
:aria-label="$t('buttons.submit')"
:title="$t('buttons.submit')"
>
{{ $t("buttons.submit") }}
</button>
</div>
</template>
<script>
import { mutations } from "@/store";
export default {
name: "password",
props: {
submitCallback: {
type: Function,
required: true,
},
showWrongCredentials: {
type: Boolean,
default: false,
},
initialPassword: {
type: String,
default: "",
},
},
data() {
return {
password: this.initialPassword,
};
},
methods: {
submit() {
mutations.closeHovers();
this.submitCallback(this.password);
},
},
};
</script>
<style scoped>
.share__wrong__password {
color: #ff4757;
text-align: center;
padding: 1em 0;
}
.share-password {
width: 100%;
}
</style>
@@ -30,6 +30,7 @@ import UserEdit from "./UserEdit.vue";
import buttons from "@/utils/buttons";
import Totp from "./Totp.vue";
import Access from "./Access.vue";
import Password from "./Password.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
export default {
@@ -56,6 +57,7 @@ export default {
CreateApi,
ActionApi,
Access,
Password,
},
data() {
return {
+18 -10
View File
@@ -3,7 +3,7 @@
<h2>{{ $t("buttons.share") }}</h2>
</div>
<div class="card-content">
<div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ item.path }}</div>
<div aria-label="share-path" class="searchContext button"> {{ $t('search.path') }} {{ item.path }}</div>
<p> {{ $t('share.notice') }} </p>
<div v-if="listing">
@@ -80,7 +80,7 @@
<ToggleSwitch class="item" v-model="allowEdit" :name="'Allow modifications'" />
<ToggleSwitch class="item" v-model="allowUpload" :name="'Allow uploading'" />
-->
<ToggleSwitch class="item" v-model="disablingFileViewer" :name="'Disable File Viewer'" />
<ToggleSwitch class="item" v-model="disableFileViewer" :name="'Disable File Viewer'" />
<ToggleSwitch
class="item"
v-model="quickDownload"
@@ -253,9 +253,10 @@ import { state, getters, mutations } from "@/store";
import { publicApi } from "@/api";
import Clipboard from "clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import { buildItemUrl, fixDownloadURL } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { userSelectableThemes } from "@/utils/constants";
import { userSelectableThemes, externalUrl } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
//import ViewMode from "@/components/settings/ViewMode.vue";
export default {
@@ -299,7 +300,7 @@ export default {
disableAnonymous: false,
allowUpload: false,
maxBandwidth: "",
disablingFileViewer: false,
disableFileViewer: false,
disableThumbnails: false,
enableAllowedUsernames: false,
allowedUsernames: "",
@@ -376,6 +377,7 @@ export default {
this.shareTheme = this.link.shareTheme || "default";
this.disableAnonymous = this.link.disableAnonymous || false;
this.disableThumbnails = this.link.disableThumbnails || false;
this.disableFileViewer = this.link.disableFileViewer || false;
this.enableAllowedUsernames = Array.isArray(this.link.allowedUsernames) && this.link.allowedUsernames.length > 0;
this.allowedUsernames = this.enableAllowedUsernames ? this.link.allowedUsernames.join(", ") : "";
this.keepAfterExpiration = this.link.keepAfterExpiration || false;
@@ -491,7 +493,7 @@ export default {
maxBandwidth: this.maxBandwidth ? parseInt(this.maxBandwidth) : 0,
downloadsLimit: this.downloadsLimit ? parseInt(this.downloadsLimit) : 0,
shareTheme: this.shareTheme,
disablingFileViewer: this.disablingFileViewer,
disableFileViewer: this.disableFileViewer,
disableThumbnails: this.disableThumbnails,
allowedUsernames: this.enableAllowedUsernames ? this.allowedUsernames.split(',').map(u => u.trim()) : [],
keepAfterExpiration: this.keepAfterExpiration,
@@ -516,8 +518,9 @@ export default {
this.links.push(res);
this.sort();
} else {
// reload page to see changes
window.location.reload();
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
mutations.closeHovers();
}
this.time = "";
@@ -538,6 +541,8 @@ export default {
try {
await publicApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
if (this.links.length === 0) {
this.listing = false;
}
@@ -565,8 +570,8 @@ export default {
* @param {Share} share
*/
buildDownloadLink(share) {
if (share.downloadURL) {
return share.downloadURL;
if (share.downloadURL && externalUrl == "") {
return this.fixDownloadURL(share.downloadURL);
}
return publicApi.getDownloadURL(share, [this.item.name]);
},
@@ -585,6 +590,9 @@ export default {
this.listing = !this.listing;
},
fixDownloadURL(downloadUrl) {
return fixDownloadURL(downloadUrl);
},
},
};
</script>
+4 -2
View File
@@ -48,6 +48,7 @@
@click="toggleClick"
@mouseenter="showTooltip($event, $t('index.toggleClick'))"
@mouseleave="hideTooltip"
v-if="!isInvalidShare"
>
<i class="material-icons">ads_click</i>
</div>
@@ -139,7 +140,7 @@
<script>
import * as auth from "@/utils/auth";
import { signup, disableExternal, noAuth, loginPage } from "@/utils/constants";
import { signup, disableExternal, noAuth, loginPage, shareInfo } from "@/utils/constants";
import ProgressBar from "@/components/ProgressBar.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
@@ -156,11 +157,12 @@ export default {
computed: {
disableQuickToggles: () => state.user.disableQuickToggles,
hasSourceInfo: () => state.sources.hasSourceInfo,
hideSidebarFileActions: () => state.user.hideSidebarFileActions,
hideSidebarFileActions: () => state.user.hideSidebarFileActions || getters.isInvalidShare(),
settingsAllowed: () => !state.user.disableSettings,
isSettings: () => getters.isSettings(),
isStickySidebar: () => getters.isStickySidebar(),
isMobile: () => getters.isMobile(),
isInvalidShare: () => getters.isInvalidShare(),
isListingView: () => getters.currentView() == "listingView",
user: () => (state.user),
isDarkMode: () => getters.isDarkMode(),
+5 -5
View File
@@ -1,7 +1,7 @@
<template>
<div class="share-sidebar">
<div class="share-sidebar__content">
<ShareInfo
<ShareInfoCard
v-if="!disableShareCard"
:hash="hash"
:token="token"
@@ -12,13 +12,13 @@
</template>
<script>
import ShareInfo from "@/components/files/ShareInfo.vue";
import { shareOverrides } from "@/utils/constants";
import ShareInfoCard from "@/components/files/ShareInfoCard.vue";
import { shareInfo } from "@/utils/constants";
export default {
name: "SidebarShare",
components: {
ShareInfo,
ShareInfoCard,
},
props: {
hash: {
@@ -36,7 +36,7 @@ export default {
},
computed: {
disableShareCard() {
return shareOverrides.disableShareCard;
return shareInfo.disableShareCard;
},
},
};
+2 -2
View File
@@ -9,7 +9,7 @@
</div>
<SidebarSettings v-if="isSettings"></SidebarSettings>
<SidebarGeneral v-if="!isSettings"></SidebarGeneral>
<SidebarShare v-if="isShare"></SidebarShare>
<SidebarShare v-if="isValidShare"></SidebarShare>
<div class="buffer"></div>
<div v-if="!isSettings" class="credits">
@@ -48,7 +48,7 @@ export default {
mutations.setSeenUpdate(localStorage.getItem("seenUpdate"));
},
computed: {
isShare: () => getters.isShare(),
isValidShare: () => getters.isValidShare(),
releaseUrl: () => updateAvailable,
isDarkMode: () => getters.isDarkMode(),
isLoggedIn: () => getters.isLoggedIn(),
+4 -3
View File
@@ -179,13 +179,13 @@ input.sizeInput:disabled {
border-radius: 1em;
margin: .5em;
display: -ms-flexbox;
display: flex;
display: flex !important;
align-items: center;
background-color: #f5f5f5;
padding: .25em;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid #ccc;
justify-content: center;
}
@@ -199,6 +199,7 @@ input.sizeInput:disabled {
backdrop-filter: invert(.1);
height: 2em;
width: 5em;
}
.roundedInputLeft {
@@ -206,10 +207,10 @@ input.sizeInput:disabled {
border-top-right-radius: 0;
width: auto;
background-color: var(--surfaceSecondary);
min-width: 50%;
}
.roundedInputRight {
padding: 1em;
border-radius: 1em;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
+1 -1
View File
@@ -224,7 +224,7 @@ body.rtl .card-title>*:first-child {
text-align: right;
}
.card .card-content.full {
.card-content.full {
padding-bottom: 0;
overflow: auto;
}
+3 -2
View File
@@ -102,9 +102,10 @@ body.rtl #listingView {
}
#listingView.compact .item:hover,
#listingView.list .item:hover {
#listingView.list .item:hover,
.search-entry:hover {
width: 98% !important;
margin-left: 0.5em !important;
margin-left: 1% !important;
padding-left: 0.5em !important;
}
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "استئناف الكل",
"clearCompleted": "اكتمل المسح",
"showMore": "اعرض المزيد",
"showLess": "اعرض أقل"
"showLess": "اعرض أقل",
"openParentFolder": "فتح المجلد الأصل"
},
"download": {
"downloadFile": "Download File",
@@ -59,7 +60,8 @@
"forbidden": "You don't have permissions to access this.",
"internal": "لقد حدث خطأ ما.",
"notFound": "لا يمكن الوصول لهذا المحتوى.",
"connection": "لا يمكن الوصول إلى الخادم"
"connection": "لا يمكن الوصول إلى الخادم",
"shareNotFound": "حصة غير صالحة أو منتهية الصلاحية."
},
"files": {
"body": "الصفحة",
@@ -444,7 +446,9 @@
"accessManagement": "إدارة الوصول",
"totalDenied": "مرفوض",
"totalAllowed": "المسموح به",
"all": "رفض الكل"
"all": "رفض الكل",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "يوضح هذا ما يحدث عندما يحاول المستخدم الوصول إلى مسار لا توجد فيه قواعد وصول محددة. تعني \"السماح\" أنه يمكن للمستخدمين الوصول بشكل افتراضي، وتعني \"الرفض\" أنه يتم حظر المستخدمين بشكل افتراضي ما لم يُسمح لهم صراحةً."
},
"fileLoading": {
"title": "التحميلات والتنزيلات",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Obnovit vše",
"clearCompleted": "Vymazání dokončeno",
"showMore": "Zobrazit více",
"showLess": "Zobrazit méně"
"showLess": "Zobrazit méně",
"openParentFolder": "Otevření nadřazené složky"
},
"download": {
"downloadFile": "Stáhnout soubor",
@@ -62,7 +63,8 @@
"forbidden": "Nemáte oprávnění k přístupu.",
"internal": "Něco se opravdu pokazilo.",
"notFound": "Tuto položku nelze najít.",
"connection": "Nelze se připojit k serveru."
"connection": "Nelze se připojit k serveru.",
"shareNotFound": "Neplatná nebo prošlá akcie."
},
"files": {
"body": "Tělo",
@@ -510,7 +512,9 @@
"added": "Přidáno pravidlo přístupu!",
"name": "Název",
"accessManagement": "Správa přístupu",
"all": "Odmítnout vše"
"all": "Odmítnout vše",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "To ukazuje, co se stane, když se uživatel pokusí získat přístup k cestě, kde neexistují žádná specifická pravidla přístupu. \"Povolit\" znamená, že uživatelé mají ve výchozím nastavení přístup, \"Odmítnout\" znamená, že uživatelé jsou ve výchozím nastavení blokováni, pokud není výslovně povoleno."
},
"editor": {
"uninitialized": "Nepodařilo se inicializovat editor. Prosím, načtěte jej znovu.",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Alle zusammenfassen",
"clearCompleted": "Abgeschlossene löschen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
"showLess": "Weniger anzeigen",
"openParentFolder": "Übergeordneten Ordner öffnen"
},
"download": {
"downloadFile": "Datei herunterladen",
@@ -59,7 +60,8 @@
"forbidden": "Sie haben keine Berechtigung dies abzurufen.",
"internal": "Etwas ist schief gelaufen.",
"notFound": "Dieser Ort kann nicht angezeigt werden.",
"connection": "Der Server ist nicht erreichbar."
"connection": "Der Server ist nicht erreichbar.",
"shareNotFound": "Ungültige oder abgelaufene Aktie."
},
"files": {
"body": "Body",
@@ -444,7 +446,9 @@
"accessManagement": "Zugangsverwaltung",
"totalDenied": "Verweigert",
"totalAllowed": "Erlaubt",
"all": "Alle ablehnen"
"all": "Alle ablehnen",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Dies zeigt, was passiert, wenn ein Benutzer versucht, auf einen Pfad zuzugreifen, für den keine spezifischen Zugriffsregeln bestehen. Zulassen\" bedeutet, dass Benutzer standardmäßig Zugang haben, \"Verweigern\" bedeutet, dass Benutzer standardmäßig blockiert werden, wenn sie nicht ausdrücklich zugelassen sind."
},
"fileLoading": {
"title": "Uploads & Downloads",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Συνέχισε όλα",
"clearCompleted": "Εκκαθάριση ολοκληρώθηκε",
"showMore": "Εμφάνιση περισσότερων",
"showLess": "Εμφάνιση λιγότερων"
"showLess": "Εμφάνιση λιγότερων",
"openParentFolder": "Άνοιγμα γονικού φακέλου"
},
"download": {
"downloadFile": "Λήψη αρχείου",
@@ -62,7 +63,8 @@
"forbidden": "Δεν έχετε άδεια πρόσβασης σε αυτό.",
"internal": "Προέκυψε εσωτερικό σφάλμα.",
"notFound": "Αυτή η τοποθεσία δεν μπορεί να βρεθεί.",
"connection": "Ο διακομιστής δεν είναι διαθέσιμος."
"connection": "Ο διακομιστής δεν είναι διαθέσιμος.",
"shareNotFound": "Άκυρη ή ληγμένη μετοχή."
},
"files": {
"body": "Περιεχόμενο",
@@ -444,7 +446,9 @@
"accessManagement": "Διαχείριση πρόσβασης",
"totalDenied": "Απορρίπτεται",
"totalAllowed": "Επιτρεπόμενο",
"all": "Άρνηση όλων"
"all": "Άρνηση όλων",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Αυτό δείχνει τι συμβαίνει όταν ένας χρήστης προσπαθεί να αποκτήσει πρόσβαση σε μια διαδρομή όπου δεν υπάρχουν συγκεκριμένοι κανόνες πρόσβασης. 'Allow' σημαίνει ότι οι χρήστες μπορούν να έχουν πρόσβαση από προεπιλογή, 'Deny' σημαίνει ότι οι χρήστες αποκλείονται από προεπιλογή, εκτός αν επιτρέπεται ρητά."
},
"fileLoading": {
"title": "Ανεβάσματα & λήψεις",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Resume all",
"clearCompleted": "Clear completed",
"showMore": "Show more",
"showLess": "Show less"
"showLess": "Show less",
"openParentFolder": "Open parent folder"
},
"download": {
"downloadFile": "Download File",
@@ -62,7 +63,8 @@
"forbidden": "You don't have permissions to access this.",
"internal": "Something really went wrong.",
"notFound": "This location can't be reached.",
"connection": "The server can't be reached."
"connection": "The server can't be reached.",
"shareNotFound": "Invalid or expired share."
},
"files": {
"body": "Body",
@@ -495,7 +497,9 @@
"deleted": "Access rule deleted!",
"added": "Access rule added!",
"name": "Name",
"accessManagement": "Access Management"
"accessManagement": "Access Management",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "This shows what happens when a user tries to access a path where no specific access rules exist. 'Allow' means users can access by default, 'Deny' means users are blocked by default unless explicitly allowed."
},
"fileLoading": {
"title": "Uploads & Downloads",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Reanudar todo",
"clearCompleted": "Limpiar lista",
"showMore": "Ver más",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"openParentFolder": "Abrir carpeta principal"
},
"download": {
"downloadFile": "Descargar archivo",
@@ -59,7 +60,8 @@
"forbidden": "No tienes permisos para acceder.",
"internal": "Error interno :(",
"notFound": "No se encontró el directorio",
"connection": "No se puede conectar al servidor."
"connection": "No se puede conectar al servidor.",
"shareNotFound": "Acción no válida o caducada."
},
"files": {
"body": "Contenido",
@@ -444,7 +446,9 @@
"accessManagement": "Gestión de accesos",
"totalDenied": "Denegado",
"totalAllowed": "Permitido",
"all": "Denegar todo"
"all": "Denegar todo",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Muestra lo que ocurre cuando un usuario intenta acceder a una ruta en la que no existen reglas de acceso específicas. Permitir\" significa que los usuarios pueden acceder por defecto, \"Denegar\" significa que los usuarios están bloqueados por defecto a menos que se les permita explícitamente."
},
"fileLoading": {
"title": "Carga de archivos",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Curriculum vitae",
"clearCompleted": "Effacement terminé",
"showMore": "Afficher plus",
"showLess": "Montrer moins"
"showLess": "Montrer moins",
"openParentFolder": "Ouvrir le dossier parent"
},
"download": {
"downloadFile": "Télécharger le fichier",
@@ -59,7 +60,8 @@
"forbidden": "Vous n'avez pas la permission d'accéder à cela.",
"internal": "Aïe ! Quelque chose s'est mal passé.",
"notFound": "Impossible d'accéder à cet emplacement.",
"connection": "Le serveur n'est pas accessible."
"connection": "Le serveur n'est pas accessible.",
"shareNotFound": "Action non valide ou expirée."
},
"files": {
"body": "Corps",
@@ -444,7 +446,9 @@
"accessManagement": "Gestion de l'accès",
"totalDenied": "Refusé",
"totalAllowed": "Autorisé",
"all": "Refuser tout"
"all": "Refuser tout",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Ceci montre ce qui se passe lorsqu'un utilisateur tente d'accéder à un chemin pour lequel il n'existe pas de règles d'accès spécifiques. La mention \"Autoriser\" signifie que les utilisateurs peuvent accéder par défaut, la mention \"Refuser\" signifie que les utilisateurs sont bloqués par défaut, à moins qu'ils n'y soient explicitement autorisés."
},
"fileLoading": {
"title": "Chargements et téléchargements",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "המשך הכל",
"clearCompleted": "השלם",
"showMore": "הצג עוד",
"showLess": "הצג פחות"
"showLess": "הצג פחות",
"openParentFolder": "פתח את התיקייה הראשית"
},
"download": {
"downloadFile": "הורד קובץ",
@@ -59,7 +60,8 @@
"forbidden": "אין לך הרשאות גישה",
"internal": "משהו השתבש",
"notFound": "לא ניתן להגיע למיקום זה",
"connection": "לא ניתן להגיע לשרת"
"connection": "לא ניתן להגיע לשרת",
"shareNotFound": "שיתוף לא חוקי או פג תוקף."
},
"files": {
"body": "גוף",
@@ -444,7 +446,9 @@
"accessManagement": "ניהול גישה",
"totalDenied": "נדחה",
"totalAllowed": "מותר",
"all": "דחה הכל"
"all": "דחה הכל",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "זה מראה מה קורה כאשר משתמש מנסה לגשת לנתיב שבו אין כללי גישה ספציפיים. 'אפשר' פירושו שמשתמשים יכולים לגשת כברירת מחדל, 'דחה' פירושו שמשתמשים נחסמים כברירת מחדל, אלא אם כן הותר להם במפורש."
},
"fileLoading": {
"title": "העלאות והורדות",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Folytassa az összes",
"clearCompleted": "Tiszta befejezve",
"showMore": "Többet mutasson",
"showLess": "Kevesebb mutatása"
"showLess": "Kevesebb mutatása",
"openParentFolder": "Szülői mappa megnyitása"
},
"download": {
"downloadFile": "Fájl letöltése",
@@ -59,7 +60,8 @@
"forbidden": "Nincs jogosultsága a hozzáféréshez.",
"internal": "Valami nagyon elromlott.",
"notFound": "Ez a hely nem érhető el.",
"connection": "A kiszolgáló nem érhető el."
"connection": "A kiszolgáló nem érhető el.",
"shareNotFound": "Érvénytelen vagy lejárt részvény."
},
"files": {
"body": "Törzs",
@@ -444,7 +446,9 @@
"accessManagement": "Hozzáférés-kezelés",
"totalDenied": "Elutasítva",
"totalAllowed": "Engedélyezett",
"all": "Mindent megtagadni"
"all": "Mindent megtagadni",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Ez azt mutatja, hogy mi történik, ha egy felhasználó megpróbál hozzáférni egy olyan elérési útvonalhoz, ahol nincsenek speciális hozzáférési szabályok. Az 'Allow' azt jelenti, hogy a felhasználók alapértelmezés szerint hozzáférhetnek, a 'Deny' azt jelenti, hogy a felhasználók alapértelmezés szerint blokkolva vannak, kivéve, ha kifejezetten engedélyezik."
},
"fileLoading": {
"title": "Feltöltések és letöltések",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Riprendete tutti",
"clearCompleted": "Completato il lavoro di pulizia",
"showMore": "Mostra di più",
"showLess": "Mostra meno"
"showLess": "Mostra meno",
"openParentFolder": "Aprire la cartella madre"
},
"download": {
"downloadFile": "Scarica file",
@@ -59,7 +60,8 @@
"forbidden": "Non hai i permessi per accedere a questo file.",
"internal": "Qualcosa è andato veramente male.",
"notFound": "Questo percorso non può essere raggiunto.",
"connection": "Il server non è raggiungibile."
"connection": "Il server non è raggiungibile.",
"shareNotFound": "Azione non valida o scaduta."
},
"files": {
"body": "Contenuto",
@@ -444,7 +446,9 @@
"accessManagement": "Gestione degli accessi",
"totalDenied": "Negato",
"totalAllowed": "Consentito",
"all": "Rifiuta tutto"
"all": "Rifiuta tutto",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Questo mostra cosa succede quando un utente cerca di accedere a un percorso per il quale non esistono regole di accesso specifiche. 'Consenti' significa che gli utenti possono accedere per impostazione predefinita, 'Rifiuta' significa che gli utenti sono bloccati per impostazione predefinita, a meno che non siano esplicitamente autorizzati."
},
"fileLoading": {
"title": "Caricamenti e download",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "すべての履歴書",
"clearCompleted": "クリア完了",
"showMore": "もっと見る",
"showLess": "もっと見る"
"showLess": "もっと見る",
"openParentFolder": "親フォルダを開く"
},
"download": {
"downloadFile": "Download File",
@@ -59,7 +60,8 @@
"forbidden": "You don't have permissions to access this.",
"internal": "内部エラーが発生しました。",
"notFound": "リソースが見つからなりませんでした。",
"connection": "サーバーにアクセスできません。"
"connection": "サーバーにアクセスできません。",
"shareNotFound": "無効または期限切れの共有。"
},
"files": {
"body": "本文",
@@ -444,7 +446,9 @@
"accessManagement": "アクセス管理",
"totalDenied": "却下",
"totalAllowed": "可",
"all": "すべて拒否"
"all": "すべて拒否",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "これは、特定のアクセスルールが存在しないパスにユーザがアクセスしようとしたときに何が起こるかを示している。Allow」はユーザーがデフォルトでアクセスできることを意味し、「Deny」は明示的に許可されない限り、ユーザーがデフォルトでブロックされることを意味する。"
},
"fileLoading": {
"title": "アップロードとダウンロード",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "모두 다시 시작",
"clearCompleted": "완료 지우기",
"showMore": "자세히 보기",
"showLess": "덜 보기"
"showLess": "덜 보기",
"openParentFolder": "상위 폴더 열기"
},
"download": {
"downloadFile": "파일 다운로드",
@@ -59,7 +60,8 @@
"forbidden": "접근 권한이 없습니다.",
"internal": "오류가 발생하였습니다.",
"notFound": "해당 경로를 찾을 수 없습니다.",
"connection": "서버에 연결할 수 없습니다."
"connection": "서버에 연결할 수 없습니다.",
"shareNotFound": "유효하지 않거나 만료된 공유입니다."
},
"files": {
"body": "본문",
@@ -444,7 +446,9 @@
"accessManagement": "액세스 관리",
"totalDenied": "거부됨",
"totalAllowed": "허용됨",
"all": "모두 거부"
"all": "모두 거부",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "사용자가 특정 액세스 규칙이 없는 경로에 액세스하려고 할 때 어떤 일이 발생하는지 보여줍니다. '허용'은 사용자가 기본적으로 액세스할 수 있음을 의미하며, '거부'는 명시적으로 허용되지 않는 한 기본적으로 차단됨을 의미합니다."
},
"fileLoading": {
"title": "업로드 및 다운로드",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Alles hervatten",
"clearCompleted": "Gereed",
"showMore": "Meer tonen",
"showLess": "Minder tonen"
"showLess": "Minder tonen",
"openParentFolder": "Bovenliggende map openen"
},
"download": {
"downloadFile": "Bestand downloaden",
@@ -59,7 +60,8 @@
"forbidden": "U hebt geen rechten om hier toegang toe te krijgen.",
"internal": "Er ging iets mis.",
"notFound": "Deze locatie kan niet worden bereikt.",
"connection": "De server kan niet worden bereikt."
"connection": "De server kan niet worden bereikt.",
"shareNotFound": "Ongeldig of verlopen aandeel."
},
"files": {
"body": "Body",
@@ -510,7 +512,9 @@
"added": "Toegangsregel toegevoegd!",
"name": "Naam",
"accessManagement": "Toegangsbeheer",
"all": "Alles weigeren"
"all": "Alles weigeren",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen."
},
"editor": {
"uninitialized": "Het is niet gelukt de editor te initialiseren. Gelieve opnieuw te laden.",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Resume all",
"clearCompleted": "Czyszczenie zakończone",
"showMore": "Pokaż więcej",
"showLess": "Pokaż mniej"
"showLess": "Pokaż mniej",
"openParentFolder": "Otwórz folder nadrzędny"
},
"download": {
"downloadFile": "Pobierz plik",
@@ -59,7 +60,8 @@
"forbidden": "Nie posiadasz uprawnień potrzebnych, by uzyskać do tego dostęp.",
"internal": "Pojawił się poważny problem.",
"notFound": "Ten adres nie jest poprawny.",
"connection": "Nie można uzyskać dostępu do serwera."
"connection": "Nie można uzyskać dostępu do serwera.",
"shareNotFound": "Nieprawidłowy lub wygasły udział."
},
"files": {
"body": "Body",
@@ -444,7 +446,9 @@
"accessManagement": "Zarządzanie dostępem",
"totalDenied": "Odmowa",
"totalAllowed": "Dozwolone",
"all": "Odmów wszystkim"
"all": "Odmów wszystkim",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "To pokazuje, co się dzieje, gdy użytkownik próbuje uzyskać dostęp do ścieżki, dla której nie istnieją określone reguły dostępu. \"Zezwól\" oznacza, że użytkownicy mogą uzyskać dostęp domyślnie, \"Odmów\" oznacza, że użytkownicy są domyślnie blokowani, chyba że wyraźnie na to zezwolono."
},
"fileLoading": {
"title": "Przesyłanie i pobieranie",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Retomar tudo",
"clearCompleted": "Limpar concluído",
"showMore": "Mostrar mais",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"openParentFolder": "Abrir a pasta principal"
},
"download": {
"downloadFile": "Baixar arquivo",
@@ -59,7 +60,8 @@
"forbidden": "Você não tem permissões para acessar isto.",
"internal": "Ops! Algum erro ocorreu.",
"notFound": "Ops! Nada foi encontrado.",
"connection": "O servidor não pode ser alcançado."
"connection": "O servidor não pode ser alcançado.",
"shareNotFound": "Compartilhamento inválido ou expirado."
},
"files": {
"body": "Corpo",
@@ -444,7 +446,9 @@
"accessManagement": "Gerenciamento de acesso",
"totalDenied": "Negado",
"totalAllowed": "Permitido",
"all": "Negar tudo"
"all": "Negar tudo",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Isso mostra o que acontece quando um usuário tenta acessar um caminho em que não existem regras de acesso específicas. \"Permitir\" significa que os usuários podem acessar por padrão, \"Negar\" significa que os usuários são bloqueados por padrão, a menos que sejam explicitamente permitidos."
},
"fileLoading": {
"title": "Uploads e downloads",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Retomar tudo",
"clearCompleted": "Limpar concluído",
"showMore": "Mostrar mais",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"openParentFolder": "Abrir a pasta principal"
},
"download": {
"downloadFile": "Descarregar ficheiro",
@@ -59,7 +60,8 @@
"forbidden": "Não tem permissões para aceder a isto",
"internal": "Algo correu bastante mal.",
"notFound": "Esta localização não é alcançável.",
"connection": "O servidor não pode ser contactado."
"connection": "O servidor não pode ser contactado.",
"shareNotFound": "Ação inválida ou expirada."
},
"files": {
"body": "Corpo",
@@ -510,7 +512,9 @@
"added": "Regra de acesso adicionada!",
"name": "Nome",
"accessManagement": "Gestão do acesso",
"all": "Recusar tudo"
"all": "Recusar tudo",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Isto mostra o que acontece quando um utilizador tenta aceder a um caminho onde não existem regras de acesso específicas. \"Permitir\" significa que os utilizadores podem aceder por predefinição, \"Negar\" significa que os utilizadores são bloqueados por predefinição, a menos que sejam explicitamente autorizados."
},
"editor": {
"uninitialized": "Falha ao inicializar o editor. Por favor, recarregue.",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "CV toate",
"clearCompleted": "Golire finalizată",
"showMore": "Arată mai multe",
"showLess": "Arată mai puțin"
"showLess": "Arată mai puțin",
"openParentFolder": "Deschideți folderul părinte"
},
"download": {
"downloadFile": "Descarcă fișier",
@@ -59,7 +60,8 @@
"forbidden": "Nu ai permisiuni sa accesezi asta.",
"internal": "Ceva nu a funcționat corect.",
"notFound": "Aceasta locație nu poate fi accesată.",
"connection": "Serverul nu poate fi accesat."
"connection": "Serverul nu poate fi accesat.",
"shareNotFound": "Acțiune invalidă sau expirată."
},
"files": {
"body": "Corp",
@@ -444,7 +446,9 @@
"accessManagement": "Gestionarea accesului",
"totalDenied": "Refuzat",
"totalAllowed": "Permise",
"all": "Refuzați toate"
"all": "Refuzați toate",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Aceasta arată ce se întâmplă atunci când un utilizator încearcă să acceseze o cale unde nu există reguli specifice de acces. \"Allow\" înseamnă că utilizatorii pot accesa în mod implicit, \"Deny\" înseamnă că utilizatorii sunt blocați în mod implicit dacă nu li se permite în mod explicit."
},
"fileLoading": {
"title": "Încărcări și descărcări",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Резюме все",
"clearCompleted": "Очистка завершена",
"showMore": "Показать еще",
"showLess": "Показать меньше"
"showLess": "Показать меньше",
"openParentFolder": "Откройте родительскую папку"
},
"download": {
"downloadFile": "Скачать файл",
@@ -59,7 +60,8 @@
"forbidden": "У вас нет прав доступа к этому.",
"internal": "Что-то пошло не так.",
"notFound": "Неправильная ссылка.",
"connection": "Нет подключения к серверу."
"connection": "Нет подключения к серверу.",
"shareNotFound": "Недействительный или истекший ресурс."
},
"files": {
"body": "Тело",
@@ -444,7 +446,9 @@
"accessManagement": "Управление доступом",
"totalDenied": "Отказано",
"totalAllowed": "Разрешено",
"all": "Запретить все"
"all": "Запретить все",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Здесь показано, что происходит, когда пользователь пытается получить доступ к пути, для которого не существует специальных правил доступа. 'Allow' означает, что пользователи могут получить доступ по умолчанию, 'Deny' означает, что пользователи блокируются по умолчанию, если только они не разрешены явным образом."
},
"fileLoading": {
"title": "Загрузка и скачивание",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Obnoviť všetko",
"clearCompleted": "Vyčistiť dokončené",
"showMore": "Zobraziť viac",
"showLess": "Zobraziť menej"
"showLess": "Zobraziť menej",
"openParentFolder": "Otvorenie nadradeného priečinka"
},
"download": {
"downloadFile": "Stiahnuť súbor",
@@ -59,7 +60,8 @@
"forbidden": "You don't have permissions to access this.",
"internal": "Something really went wrong.",
"notFound": "This location can't be reached.",
"connection": "The server can't be reached."
"connection": "The server can't be reached.",
"shareNotFound": "Neplatná alebo neplatná akcia."
},
"files": {
"body": "Telo",
@@ -444,7 +446,9 @@
"accessManagement": "Správa prístupu",
"totalDenied": "Odmietnuté",
"totalAllowed": "Povolené",
"all": "Odmietnuť všetko"
"all": "Odmietnuť všetko",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Toto ukazuje, čo sa stane, keď sa používateľ pokúsi získať prístup k ceste, kde neexistujú žiadne špecifické pravidlá prístupu. \"Allow\" (Povoliť) znamená, že používatelia majú predvolený prístup, \"Deny\" (Odmietnuť) znamená, že používatelia sú predvolene blokovaní, pokiaľ to nie je výslovne povolené."
},
"fileLoading": {
"title": "Nahrávanie a sťahovanie",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Återuppta alla",
"clearCompleted": "Klart avslutat",
"showMore": "Visa mer",
"showLess": "Visa mindre"
"showLess": "Visa mindre",
"openParentFolder": "Öppna överordnad mapp"
},
"download": {
"downloadFile": "Ladda ner fil",
@@ -59,7 +60,8 @@
"forbidden": "Du saknar rättigheter till detta",
"internal": "Något gick fel",
"notFound": "Det går inte att nå den här platsen.",
"connection": "Det går inte att nå servern."
"connection": "Det går inte att nå servern.",
"shareNotFound": "Ogiltig eller utgången andel."
},
"files": {
"body": "Huvud",
@@ -510,7 +512,9 @@
"added": "Tillträdesregel tillagd!",
"name": "Namn",
"accessManagement": "Hantering av åtkomst",
"all": "Avvisa alla"
"all": "Avvisa alla",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Detta visar vad som händer när en användare försöker komma åt en sökväg där det inte finns några specifika åtkomstregler. \"Allow\" innebär att användare kan komma åt som standard, \"Deny\" innebär att användare blockeras som standard om de inte uttryckligen tillåts."
},
"editor": {
"uninitialized": "Redigeraren kunde inte initieras. Vänligen ladda om.",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Tümünü devam ettir",
"clearCompleted": "Temizleme tamamlandı",
"showMore": "Daha fazla göster",
"showLess": "Daha az göster"
"showLess": "Daha az göster",
"openParentFolder": "Üst klasörü açın"
},
"download": {
"downloadFile": "Dosyayı indir",
@@ -59,7 +60,8 @@
"forbidden": "Buna erişim izniniz yok.",
"internal": "Bir şeyler ters gitti.",
"notFound": "Bu konuma ulaşılamıyor.",
"connection": "Sunucuya ulaşılamıyor."
"connection": "Sunucuya ulaşılamıyor.",
"shareNotFound": "Geçersiz veya süresi dolmuş paylaşım."
},
"files": {
"body": "Sayfa",
@@ -444,7 +446,9 @@
"accessManagement": "Erişim Yönetimi",
"totalDenied": "Reddedildi",
"totalAllowed": "İzin verildi",
"all": "Tümünü Reddet"
"all": "Tümünü Reddet",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Bu, bir kullanıcı belirli erişim kurallarının bulunmadığı bir yola erişmeye çalıştığında ne olacağını gösterir. 'Allow' kullanıcıların varsayılan olarak erişebileceği anlamına gelir, 'Deny' ise açıkça izin verilmediği sürece kullanıcıların varsayılan olarak engellendiği anlamına gelir."
},
"fileLoading": {
"title": "Yüklemeler & İndirmeler",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "Відновити все",
"clearCompleted": "Очищення завершено",
"showMore": "Показати більше",
"showLess": "Показати менше"
"showLess": "Показати менше",
"openParentFolder": "Відкрийте батьківську папку"
},
"download": {
"downloadFile": "Завантажити файл",
@@ -59,7 +60,8 @@
"forbidden": "У вас немає прав доступу до цього.",
"internal": "Щось пішло не так.",
"notFound": "Неправильне посилання.",
"connection": "Немає підключення до сервера."
"connection": "Немає підключення до сервера.",
"shareNotFound": "Недійсна або прострочена акція."
},
"files": {
"body": "Тіло",
@@ -510,7 +512,9 @@
"added": "Додано правило доступу!",
"name": "Ім'я",
"accessManagement": "Керування доступом",
"all": "Заперечувати все."
"all": "Заперечувати все.",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "Тут показано, що відбувається, коли користувач намагається отримати доступ до шляху, для якого не існує певних правил доступу. \"Дозволити\" означає, що користувачі можуть отримати доступ за замовчуванням, \"Заборонити\" означає, що користувачі за замовчуванням заблоковані, якщо тільки це не дозволено явно."
},
"editor": {
"uninitialized": "Не вдалося ініціалізувати редактор. Будь ласка, перезавантажте.",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "全部简历",
"clearCompleted": "清除完毕",
"showMore": "显示更多",
"showLess": "显示更少"
"showLess": "显示更少",
"openParentFolder": "打开父文件夹"
},
"download": {
"downloadFile": "下载文件",
@@ -59,7 +60,8 @@
"forbidden": "你无权限访问",
"internal": "服务器出了点问题。",
"notFound": "找不到文件。",
"connection": "无法连接到服务器。"
"connection": "无法连接到服务器。",
"shareNotFound": "无效或过期共享。"
},
"files": {
"body": "内容",
@@ -510,7 +512,9 @@
"added": "已添加访问规则!",
"name": "名称",
"accessManagement": "访问管理",
"all": "全部拒绝"
"all": "全部拒绝",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "这显示了当用户试图访问没有特定访问规则的路径时会发生什么。允许 \"表示默认情况下用户可以访问,\"拒绝 \"表示除非明确允许,否则默认情况下用户将被阻止。"
},
"editor": {
"uninitialized": "编辑器初始化失败。请重新加载。",
+7 -3
View File
@@ -48,7 +48,8 @@
"resumeAll": "所有履歷",
"clearCompleted": "清除完成",
"showMore": "顯示更多",
"showLess": "顯示更少"
"showLess": "顯示更少",
"openParentFolder": "開啟父資料夾"
},
"download": {
"downloadFile": "下載檔案",
@@ -59,7 +60,8 @@
"forbidden": "您無權訪問。",
"internal": "伺服器出了點問題。",
"notFound": "找不到檔案。",
"connection": "伺服器無法連線。"
"connection": "伺服器無法連線。",
"shareNotFound": "無效或過期的共享。"
},
"files": {
"body": "内容",
@@ -510,7 +512,9 @@
"added": "已新增存取規則!",
"name": "名稱",
"accessManagement": "存取管理",
"all": "全部拒絕"
"all": "全部拒絕",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "這顯示使用者嘗試存取沒有特定存取規則的路徑時會發生的情況。允許」表示使用者預設可以存取,「拒絕」表示使用者預設會被阻擋,除非明確允許。"
},
"editor": {
"uninitialized": "編輯器初始化失敗。請重新載入。",
+3 -2
View File
@@ -24,6 +24,7 @@ const titles = {
User: "settings.user",
Forbidden: "errors.forbidden",
NotFound: "errors.notFound",
ShareNotFound: "errors.shareNotFound",
InternalServerError: "errors.internal",
};
@@ -45,7 +46,7 @@ const routes = [
],
},
{
path: "/public/share",
path: "/public",
component: Layout,
meta: {
optionalAuth: true,
@@ -137,7 +138,7 @@ const router = createRouter({
// Helper function to check if a route resolves to itself
function isSameRoute(to: RouteLocation, from: RouteLocation) {
return to.path === from.path && to.hash === from.hash;
return to.path === from.path;
}
router.beforeResolve(async (to, from, next) => {
+14 -10
View File
@@ -1,7 +1,7 @@
import { removePrefix, buildItemUrl, removeLeadingSlash } from '@/utils/url.js'
import { getFileExtension } from '@/utils/files.js'
import { state, mutations } from '@/store'
import { noAuth, shareOverrides } from '@/utils/constants.js'
import { noAuth, shareInfo } from '@/utils/constants.js'
import { getTypeInfo } from '@/utils/mimetype'
import { fromNow } from '@/utils/moment'
import * as i18n from '@/i18n'
@@ -36,12 +36,12 @@ export const getters = {
isCardView: () =>
(state.user.viewMode == 'gallery' || state.user.viewMode == 'normal') &&
getters.currentView() == 'listingView',
currentHash: () => state.route.hash.replace('#', ''),
currentHash: () => shareInfo.hash,
isMobile: () => state.isMobile,
isLoading: () => Object.keys(state.loading).length > 0,
isSettings: () => getters.currentView() === 'settings',
isShare: () => {
return shareOverrides.isShare
return shareInfo.isShare
},
isDarkMode: () => {
if (state.user == null) {
@@ -137,7 +137,7 @@ export const getters = {
return { dirs, files }
},
isSidebarVisible: () => {
if (shareOverrides.disableSidebar) {
if (shareInfo.disableSidebar) {
return false
}
const cv = getters.currentView()
@@ -190,16 +190,14 @@ export const getters = {
return removePrefix(state.route.path, trimModifier)
},
shareHash: () => {
let urlPath = getters.routePath('public/share')
let parts = urlPath.split('/')
return parts[1]
return shareInfo.hash
},
sharePathBase: () => {
return '/public/share/' + getters.shareHash() + '/'
return '/public/share/' + shareInfo.hash + '/'
},
getSharePath: (subPath = "") => {
let urlPath = getters.routePath('public/share')
let path = "/" + removeLeadingSlash(urlPath.split(state.share.hash)[1])
let path = "/" + removeLeadingSlash(urlPath.split(shareInfo.hash)[1])
if (subPath != "") {
path += "/" + removeLeadingSlash(subPath)
}
@@ -344,7 +342,7 @@ export const getters = {
return false
},
officeViewingDisabled: filename => {
if (shareOverrides.isShare) {
if (shareInfo.isShare) {
return true
}
const ext = ' ' + getFileExtension(filename)
@@ -429,4 +427,10 @@ export const getters = {
}
return "close";
},
isInvalidShare: () => {
return shareInfo.isShare && !shareInfo.isValid;
},
isValidShare: () => {
return shareInfo.isShare && shareInfo.isValid;
},
};
+6
View File
@@ -427,6 +427,12 @@ export const mutations = {
state.isSearchActive = value;
emitStateChanged();
},
resetAll: () => {
state.isSearchActive = false;
state.selected = [];
state.req = {};
emitStateChanged();
},
showTooltip(value) {
state.tooltip.content = value.content;
state.tooltip.x = value.x;
+2 -2
View File
@@ -23,7 +23,7 @@ const muPdfAvailable = window.filebrowser.muPdfAvailable;
const updateAvailable = window.filebrowser.updateAvailable;
const disableNavButtons = window.filebrowser.disableNavButtons;
const userSelectableThemes = window.filebrowser.userSelectableThemes;
const shareOverrides = window.filebrowser.share;
const shareInfo = window.filebrowser.share;
const origin = window.location.origin;
const settings = [
@@ -37,7 +37,7 @@ const settings = [
];
export {
shareOverrides,
shareInfo,
userSelectableThemes,
disableNavButtons,
updateAvailable,
+37 -4
View File
@@ -1,6 +1,7 @@
import { baseURL } from "@/utils/constants.js";
import { state, mutations } from "@/store";
import { router } from "@/router";
import { shareInfo } from "@/utils/constants.js";
export function removeLastDir(url) {
var arr = url.split("/");
@@ -48,6 +49,7 @@ export default {
removePrefix,
getApiPath,
extractSourceFromPath,
fixDownloadURL,
};
export function removePrefix(path, prefix = "") {
@@ -93,6 +95,12 @@ export function getApiPath(path, params = {}) {
return path;
}
// get path with parameters
// relative path so it can be used behind proxy
export function getPublicApiPath(path, params = {}) {
return getApiPath(`/public/api/${path}`, params);
}
export function removeTrailingSlash(str) {
if (str.endsWith('/')) {
return str.slice(0, -1);
@@ -149,15 +157,15 @@ export function encodedPath(path) {
}
// assume non-encoded input path and source
export function goToItem(source, path, previousHash, shareHash) {
mutations.replaceRequest({})
export function goToItem(source, path, previousHash) {
mutations.resetAll()
if (previousHash) {
location.hash = previousHash;
}
let newPath = encodedPath(path);
let fullPath;
if (shareHash) {
fullPath = `/public/share/${shareHash}${newPath}`;
if (shareInfo.isShare) {
fullPath = `/public/share/${shareInfo.hash}${newPath}`;
router.push({ path: fullPath });
return;
}
@@ -172,4 +180,29 @@ export function goToItem(source, path, previousHash, shareHash) {
export function doubleEncode(str) {
return encodeURIComponent(encodeURIComponent(str));
}
/**
* Fixes download URLs by replacing everything before /public/api
* with the current window.location.origin + baseURL
* @param {string} downloadUrl - The original download URL from backend
* @returns {string} - The corrected URL using current client origin
*/
export function fixDownloadURL(downloadUrl) {
if (!downloadUrl) {
return downloadUrl;
}
// Find the position of /public/api in the URL
const publicApiIndex = downloadUrl.indexOf('/public/api');
if (publicApiIndex === -1) {
// If /public/api is not found, return the original URL
return downloadUrl;
}
// Extract the part from /public/api onwards
const publicApiPath = downloadUrl.substring(publicApiIndex);
// Build the corrected URL using current client origin and baseURL
const correctedBaseURL = removeTrailingSlash(baseURL);
return `${window.location.origin}${correctedBaseURL}${publicApiPath}`;
}
+4
View File
@@ -21,6 +21,10 @@ const errors = {
icon: "gps_off",
message: "errors.notFound",
},
"share404": {
icon: "gps_off",
message: "errors.shareNotFound",
},
500: {
icon: "error_outline",
message: "errors.internal",
+28 -50
View File
@@ -1,7 +1,7 @@
<template>
<div>
<!-- Share Info Component -->
<ShareInfo
<ShareInfoCard
v-if="showShareInfo"
class="share-info-component"
:hash="share?.hash"
@@ -10,36 +10,7 @@
/>
<breadcrumbs v-if="showBreadCrumbs" :base="isShare ? `/share/${shareHash}` : undefined" />
<!-- Share password prompt -->
<div v-if="isShare && error && error.status === 401" class="card floating" id="password">
<div v-if="attemptedPasswordLogin" class="share__wrong__password">
{{ $t("login.wrongCredentials") }}
</div>
<div class="card-title">
<h2>{{ $t("general.password") }}</h2>
</div>
<div class="card-content form-flex-group">
<input
class="input share-password"
v-focus
type="password"
:placeholder="$t('general.password')"
v-model="sharePassword"
@keyup.enter="fetchData"
/>
</div>
<div class="card-action">
<button
class="button button--flat"
@click="fetchData"
:aria-label="$t('buttons.submit')"
:title="$t('buttons.submit')"
>
{{ $t("buttons.submit") }}
</button>
</div>
</div>
<errors v-else-if="error" :errorCode="error.status" />
<errors v-if="error && !(isShare && error.status === 401)" :errorCode="error.status" />
<component v-else-if="currentViewLoaded" :is="currentView"></component>
<div v-else>
<h2 class="message delayed">
@@ -70,10 +41,10 @@ import MarkdownViewer from "./files/MarkdownViewer.vue";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import router from "@/router";
import { baseURL, shareOverrides } from "@/utils/constants";
import { baseURL, shareInfo } from "@/utils/constants";
import PopupPreview from "@/components/files/PopupPreview.vue";
import { extractSourceFromPath } from "@/utils/url";
import ShareInfo from "@/components/files/ShareInfo.vue";
import ShareInfoCard from "@/components/files/ShareInfoCard.vue";
export default {
name: "files",
@@ -88,7 +59,7 @@ export default {
OnlyOfficeEditor,
MarkdownViewer,
PopupPreview,
ShareInfo,
ShareInfoCard,
},
data() {
return {
@@ -110,7 +81,7 @@ export default {
return state.share;
},
showShareInfo() {
return this.isShare && state.share.hash && state.isMobile && state.req.path == "/" && !shareOverrides.disableShareCard;
return this.isShare && state.share.hash && state.isMobile && state.req.path == "/" && !shareInfo.disableShareCard;
},
isShare() {
return getters.isShare();
@@ -145,6 +116,13 @@ export default {
mounted() {
window.addEventListener("hashchange", this.scrollToHash);
window.addEventListener("keydown", this.keyEvent);
if (getters.isInvalidShare()) {
// show message that share is invalid and don't do anything else
this.error = {
status: "share404",
message: "errors.shareNotFound",
};
}
},
beforeUnmount() {
window.removeEventListener("keydown", this.keyEvent);
@@ -182,7 +160,7 @@ export default {
}
},
async fetchData() {
if (state.deletedItem) {
if (state.deletedItem || getters.isInvalidShare()) {
return
}
@@ -214,6 +192,7 @@ export default {
} else if (e.status === 401 && this.isShare) {
// Handle share password requirement
this.attemptedPasswordLogin = this.sharePassword !== "";
this.showPasswordPrompt();
} else {
router.push({ name: "error" });
}
@@ -329,6 +308,19 @@ export default {
mutations.setLoading("files", false);
}
},
showPasswordPrompt() {
mutations.showHover({
name: "password",
props: {
submitCallback: (password) => {
this.sharePassword = password;
this.fetchData();
},
showWrongCredentials: this.attemptedPasswordLogin,
initialPassword: this.sharePassword,
},
});
},
keyEvent(event) {
// F1!
if (event.keyCode === 112) {
@@ -348,17 +340,6 @@ export default {
</script>
<style>
.share__wrong__password {
color: #ff4757;
text-align: center;
padding: 1em 0;
}
#password {
max-width: 400px;
margin: 2em auto;
}
.scroll-glow {
animation: scrollGlowAnimation 1s ease-out;
}
@@ -375,9 +356,6 @@ export default {
}
}
.share-password {
width: 100%;
}
.share-info-component {
margin-top: 0.5em;
}
+3 -3
View File
@@ -42,7 +42,7 @@ import { filesApi } from "@/api";
import { state, getters, mutations } from "@/store";
import { events } from "@/notify";
import { generateRandomCode } from "@/utils/auth";
import { shareOverrides } from "@/utils/constants";
import { shareInfo } from "@/utils/constants";
export default {
name: "layout",
@@ -65,8 +65,8 @@ export default {
},
mounted() {
window.addEventListener("resize", this.updateIsMobile);
if (shareOverrides.themeColor != "") {
document.documentElement.style.setProperty("--primaryColor", shareOverrides.themeColor);
if (shareInfo.themeColor != "") {
document.documentElement.style.setProperty("--primaryColor", shareInfo.themeColor);
} else if (state.user.themeColor) {
document.documentElement.style.setProperty("--primaryColor", state.user.themeColor);
}
+55 -1
View File
@@ -81,14 +81,68 @@ export default {
return active;
},
},
watch: {
// Watch for route hash changes
"$route.hash"() {
this.initializeActiveSettingFromHash();
}
},
mounted() {
mutations.closeHovers();
mutations.setSearch(false);
this.initializeActiveSettingFromHash();
// Listen for hash changes (browser navigation)
window.addEventListener('hashchange', this.handleHashChange);
},
beforeUnmount() {
// Clean up event listener
window.removeEventListener('hashchange', this.handleHashChange);
},
methods: {
/**
* @param {any} setting
*/
shouldShow(setting) {
const perm = setting?.permissions || {};
return Object.keys(perm).every((key) => state.user.permissions[key]);
const userPermissions = /** @type {Record<string, boolean>} */ (state.user.permissions || {});
return Object.keys(perm).every((key) => userPermissions[key]);
},
handleHashChange() {
// Handle browser back/forward navigation
this.initializeActiveSettingFromHash();
},
initializeActiveSettingFromHash() {
// Get the current hash from the URL
const hash = window.location.hash.replace('#', '');
if (hash) {
// Check if the hash corresponds to a valid setting
const validSetting = this.settings.find(
(setting) => `${setting.id}-main` === hash && this.shouldShow(setting)
);
if (validSetting) {
// Set the active settings view to the hash value
mutations.setActiveSettingsView(hash);
return;
}
}
// Default to profile-main if no hash or invalid hash
const defaultSetting = this.settings.find(
(setting) => setting.id === 'profile' && this.shouldShow(setting)
);
if (defaultSetting) {
mutations.setActiveSettingsView('profile-main');
} else {
// Fallback to first allowed setting if profile is not available
const firstAllowed = this.settings.find((setting) => this.shouldShow(setting));
if (firstAllowed) {
mutations.setActiveSettingsView(`${firstAllowed.id}-main`);
}
}
},
},
};
+4 -4
View File
@@ -44,7 +44,7 @@ import { eventBus } from "@/store/eventBus";
import { getters, state, mutations } from "@/store";
import Action from "@/components/Action.vue";
import Search from "@/components/Search.vue";
import { disableNavButtons, shareOverrides } from "@/utils/constants";
import { disableNavButtons, shareInfo } from "@/utils/constants";
export default {
name: "UnifiedHeader",
@@ -59,8 +59,8 @@ export default {
},
computed: {
getTopTitle() {
if (getters.isShare() && shareOverrides.title) {
return shareOverrides.title;
if (getters.isShare() && shareInfo.title) {
return shareInfo.title;
}
return state.req.name;
},
@@ -115,7 +115,7 @@ export default {
return state.isSearchActive || getters.currentPromptName() != "";
},
isDisabledMultiAction() {
const shareDisabled = shareOverrides.disableSidebar && getters.multibuttonState() === "menu";
const shareDisabled = shareInfo.disableSidebar && getters.multibuttonState() === "menu";
return this.isDisabled || (getters.isStickySidebar() && getters.multibuttonState() === "menu") || shareDisabled;
},
showSwitchView() {
+13 -8
View File
@@ -1,19 +1,19 @@
<template>
<div id="previewer" @mousemove="toggleNavigation" @touchstart="toggleNavigation">
<div class="preview" v-if="!isDeleted">
<ExtendedImage v-if="previewType == 'image' || pdfConvertable" :src="raw"
<ExtendedImage v-if="previewType == 'image' || pdfConvertable && !disableFileViewer" :src="raw"
@navigate-previous="prev"
@navigate-next="next">
</ExtendedImage>
<audio v-else-if="previewType == 'audio'" ref="player" :src="raw" controls :autoplay="autoPlay"
<audio v-else-if="previewType == 'audio' && !disableFileViewer" ref="player" :src="raw" controls :autoplay="autoPlay"
@play="autoPlay = true"></audio>
<video v-else-if="previewType == 'video'" ref="player" :src="raw" controls :autoplay="autoPlay"
<video v-else-if="previewType == 'video' && !disableFileViewer" ref="player" :src="raw" controls :autoplay="autoPlay"
@play="autoPlay = true">
<track kind="captions" v-for="(sub, index) in subtitlesList" :key="index" :src="sub.src"
:label="'Subtitle ' + sub.name" :default="index === 0" />
</video>
<div v-else-if="previewType == 'pdf'" class="pdf-wrapper">
<div v-else-if="previewType == 'pdf' && !disableFileViewer" class="pdf-wrapper">
<iframe class="pdf" :src="raw"></iframe>
<a v-if="isMobileSafari" :href="raw" target="_blank" class="button button--flat floating-btn">
<div>
@@ -65,6 +65,7 @@ import { getFileExtension } from "@/utils/files";
import { convertToVTT } from "@/utils/subtitles";
import { getTypeInfo } from "@/utils/mimetype";
import { muPdfAvailable } from "@/utils/constants";
import { shareInfo } from "@/utils/constants";
export default {
name: "preview",
components: {
@@ -140,8 +141,7 @@ export default {
path: state.share.subPath,
hash: state.share.hash,
token: state.share.token,
inline: true,
}, [state.req.path]);
}, [state.req.path], true);
}
return filesApi.getDownloadURL(state.req.source, state.req.path, true);
},
@@ -173,6 +173,9 @@ export default {
deletedItem() {
return state.deletedItem;
},
disableFileViewer() {
return shareInfo.disableFileViewer;
},
},
watch: {
deletedItem() {
@@ -315,7 +318,7 @@ export default {
});
}
const directoryPath = url.removeLastDir(state.req.path);
if (!this.listing) {
if (!this.listing || this.listing == "undefined") {
let res;
if (getters.isShare()) {
// Use public API for shared files
@@ -324,7 +327,9 @@ export default {
// Use regular files API for authenticated users
res = await filesApi.fetchFiles(state.req.source, directoryPath);
}
this.listing = res.items;
}
if (!this.listing) {
this.listing = [state.req]
}
this.name = state.req.name;
this.previousLink = "";
+55 -16
View File
@@ -16,6 +16,7 @@
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody class="settings-items">
@@ -51,6 +52,12 @@
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="fixDownloadURL(link.downloadURL)" v-if="link.downloadURL"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')" :title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-icons">content_paste_go</i>
</button>
</td>
</tr>
</tbody>
</table>
@@ -67,7 +74,9 @@ import { publicApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import Errors from "@/views/Errors.vue";
import { fromNow } from '@/utils/moment'
import { fromNow } from '@/utils/moment';
import { eventBus } from "@/store/eventBus";
import { fixDownloadURL } from "@/utils/url";
export default {
name: "shares",
@@ -76,35 +85,31 @@ export default {
},
data: function () {
return {
/** @type {any} */
error: null,
/** @type {any[]} */
links: [],
/** @type {any} */
clip: null,
};
},
async created() {
mutations.setLoading("shares", true);
try {
let links = await publicApi.list();
if (links.length === 0) {
this.links = [];
return;
}
this.links = links;
} catch (e) {
this.error = e;
notify.showError(e);
} finally {
mutations.setLoading("shares", false);
}
await this.reloadShares();
},
mounted() {
this.clip = new Clipboard(".copy-clipboard");
this.clip.on("success", () => {
notify.showSuccess(this.$t("success.linkCopied"));
});
// Listen for share changes
eventBus.on('sharesChanged', this.reloadShares);
},
beforeUnmount() {
this.clip.destroy();
// Clean up event listener
eventBus.removeEventListener('sharesChanged', this.reloadShares);
},
computed: {
settings() {
@@ -121,6 +126,26 @@ export default {
},
},
methods: {
async reloadShares() {
mutations.setLoading("shares", true);
try {
let links = await publicApi.list();
if (links.length === 0) {
this.links = [];
return;
}
this.links = links;
this.error = null; // Clear any previous errors
} catch (e) {
this.error = e;
notify.showError(e);
} finally {
mutations.setLoading("shares", false);
}
},
/**
* @param {any} link
*/
editLink(link) {
mutations.showHover({
name: "share",
@@ -130,6 +155,10 @@ export default {
},
});
},
/**
* @param {any} event
* @param {any} link
*/
deleteLink: async function (event, link) {
mutations.showHover({
name: "share-delete",
@@ -147,18 +176,28 @@ export default {
},
});
},
/**
* @param {any} time
*/
humanTime(time) {
return fromNow(time);
},
/**
* @param {any} share
*/
buildLink(share) {
return publicApi.getShareURL(share);
},
fixDownloadURL(downloadUrl) {
return fixDownloadURL(downloadUrl);
},
},
};
</script>
<style scoped>
tr > td {
tr > td,
tr > th {
text-align: center;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
import { Browser, firefox, expect, Page, } from "@playwright/test";
// Perform authentication and store auth state
async function globalSetup() {
const browser: Browser = await firefox.launch();
const context = await browser.newContext();
const page: Page = await context.newPage();
// Set basic auth credentials for protected /subpath route
await page.setExtraHTTPHeaders({
'Authorization': `Basic ZGVtby0xMjcuMC4wLjE6U2VjdXJlUGFzczEyMyE=`
});
await page.goto("http://127.0.0.1/subpath/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - demo-127.0.0.1");
// Create a share of folder
await page.locator('button[aria-label="File-Actions"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="File-Actions"]').click();
await page.locator('button[aria-label="Share"]').click();
await page.locator('button[aria-label="Share-Confirm"]').click();
await expect(page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th))")).toHaveCount(1);
const shareHash = await page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th)) td").first().textContent();
if (!shareHash) {
throw new Error("Failed to retrieve shareHash");
}
// Store shareHash in localStorage
await page.evaluate((hash) => {
localStorage.setItem('shareHash', hash);
}, shareHash);
await context.storageState({ path: "./loginAuth.json" });
await browser.close();
}
export default globalSetup;
@@ -1,7 +1,13 @@
import { test, expect } from "../test-setup";
test("Create first new file", async ({ page, checkForErrors, context }) => {
await page.goto("/");
test("Create first new file with basic auth", async ({ page, checkForErrors, context }) => {
// Set basic auth credentials for protected /subpath route
await page.setExtraHTTPHeaders({
'Authorization': `Basic ZGVtby0xMjcuMC4wLjE6U2VjdXJlUGFzczEyMyE=`
});
await page.goto("/subpath/");
await expect(page.locator('#listingView .message > span')).toHaveText('It feels lonely here...');
await page.locator('#listingView').click({ button: "right" });
await page.locator('button[aria-label="New file"]').click();
@@ -10,6 +16,30 @@ test("Create first new file", async ({ page, checkForErrors, context }) => {
await expect(page).toHaveTitle("Graham's Filebrowser - Files - test.txt");
await page.locator('button[aria-label="Close"]').click();
await expect(page.locator('#listingView .file-items')).toHaveCount(1);
checkForErrors();
// clear basic auth credentials from browser headers for public routes
await page.setExtraHTTPHeaders({});
// check share hash
const shareHash = await page.evaluate(() => localStorage.getItem('shareHash'));
if (shareHash == "") {
throw new Error("Share hash not found in localStorage");
}
// test public share route (no auth required)
await page.goto("/subpath/public/share/" + shareHash);
await expect(page.locator('#listingView .file-items')).toHaveCount(1);
await page.locator('a[aria-label="test.txt"]').dblclick();
checkForErrors(0,1);
});
test("Verify basic auth is required for protected route", async ({ page }) => {
// try to access protected route without credentials - should get 401
const response = await page.goto("/subpath/", { waitUntil: 'networkidle' });
expect(response?.status()).toBe(401);
// verify public routes still work without auth
const publicResponse = await page.goto("/subpath/public/static/index.html", { waitUntil: 'networkidle' });
expect(publicResponse?.status()).toBe(200);
});
+63 -4
View File
@@ -22,9 +22,58 @@ export function setupErrorTracking(page: Page) {
const failedResponses: { url: string; status: number }[] = [];
// Track console errors
page.on("console", (message) => {
page.on("console", async (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
const errorText = message.text();
const args = message.args();
// Try to extract more detailed error information
let detailedError = errorText;
if (args.length > 0) {
try {
// Get the first argument which usually contains the error object
const firstArg = await args[0].jsonValue().catch(() => null);
if (firstArg && typeof firstArg === 'object') {
if (firstArg.stack) {
// If we have a stack trace, use it
detailedError = firstArg.stack;
} else if (firstArg.message) {
// Otherwise use the message if available
detailedError = `${firstArg.name || 'Error'}: ${firstArg.message}`;
}
}
} catch (e) {
// If we can't extract detailed info, try to get string representation of args
try {
const argsText = await Promise.all(
args.map(async (arg) => {
try {
return await arg.evaluate((obj) => {
if (obj && typeof obj === 'object' && obj.stack) {
return obj.stack;
}
return String(obj);
});
} catch {
return '[Unable to serialize]';
}
})
);
const combinedArgs = argsText.join(' ');
if (combinedArgs.trim() && combinedArgs !== errorText) {
detailedError = combinedArgs;
}
} catch {
// Fallback to original text
detailedError = errorText;
}
}
}
consoleErrors.push(detailedError);
}
});
@@ -41,11 +90,21 @@ export function setupErrorTracking(page: Page) {
return {
checkForErrors: (expectedConsoleErrors = 0, expectedApiErrors = 0) => {
if (consoleErrors.length !== expectedConsoleErrors) {
console.error("Unexpected Console Errors:", consoleErrors);
console.error(`\n=== Unexpected Console Errors (Expected: ${expectedConsoleErrors}, Got: ${consoleErrors.length}) ===`);
consoleErrors.forEach((error, index) => {
console.error(`\nError ${index + 1}:`);
console.error(error);
console.error('---');
});
console.error('=== End Console Errors ===\n');
}
if (failedResponses.length !== expectedApiErrors) {
console.error("Unexpected Failed API Calls:", failedResponses);
console.error(`\n=== Unexpected Failed API Calls (Expected: ${expectedApiErrors}, Got: ${failedResponses.length}) ===`);
failedResponses.forEach((response, index) => {
console.error(`\nFailed Request ${index + 1}: ${response.status} - ${response.url}`);
});
console.error('=== End Failed API Calls ===\n');
}
expect(consoleErrors).toHaveLength(expectedConsoleErrors);