updated styling and tests (#1116)

This commit is contained in:
Graham Steffaniak authored and GitHub committed 2025-08-24 08:38:00 -05:00
1 parent 8a597e3202
commit 70933aea36
21 files changed
+508 -106

No files matched your search

+6 -3
View File
@@ -4,15 +4,18 @@ All notable changes to this project will be documented in this file. For commit
## v0.8.2-beta
Includes new 8.0 ffmpeg version bundled with docker and go 1.25 upgrade with new GC flag enabled. I found this new garbage collector reduced memory usage by an astounding 38% on my personal deployment. Indexing performance also appears 25% faster. I have opened [this poll](https://github.com/gtsteffaniak/filebrowser/discussions/1113) to get your feedback on the change.
**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)
**Notes**:
- updated description for indexingIntervalMinutes https://github.com/gtsteffaniak/filebrowser/issues/1067
- 8.0 ffmpeg version bundled with docker
- go 1.25 upgrade with green tea GC enabled
**BugFixes**:
- fixed routes issue
- 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
## v0.8.1-beta
+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 -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]
+5 -1
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"
+5 -1
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"
+6 -2
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
@@ -1083,8 +1086,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":
+1
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
+5 -4
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"
@@ -538,7 +538,10 @@ export default {
};
</script>
<style>
<style scoped>
.sizeInputWrapper {
border: 1px solid #ccc;
}
.main-input {
width: 100%;
}
@@ -551,7 +554,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 +564,6 @@ export default {
width: 25%;
min-width: 7em;
max-width: 15em;
height: -webkit-fill-available;
}
#results > #result-list {
+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 {
+1 -1
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">
+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;
+3 -1
View File
@@ -495,7 +495,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",
+15 -44
View File
@@ -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">
@@ -214,6 +185,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 +301,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 +333,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 +349,6 @@ export default {
}
}
.share-password {
width: 100%;
}
.share-info-component {
margin-top: 0.5em;
}
+2 -1
View File
@@ -158,7 +158,8 @@ export default {
</script>
<style scoped>
tr > td {
tr > td,
tr > th {
text-align: center;
}
</style>