feat: Allow file and directory creation modes to be configured

The defaults remain the same as before.
For now, the config options are global instead of per-user.
Note also that the BoltDB creation maintains the old default mode of 0640
since it's not really a user-facing filesystem manipulation.
Fixes #5316, #5200
This commit is contained in:
Vincent Lee 2025-07-20 18:21:52 -07:00 committed by Henrique Dias
parent 5b7ea9f95a
commit 21ad653b7e
12 changed files with 73 additions and 41 deletions

View File

@ -49,6 +49,10 @@ func addConfigFlags(flags *pflag.FlagSet) {
flags.String("branding.files", "", "path to directory with images and custom styles") flags.String("branding.files", "", "path to directory with images and custom styles")
flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links") flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links")
flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph") flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph")
// NB: these are string so they can be presented as octal in the help text
// as that's the conventional representation for modes in Unix.
flags.String("file-mode", fmt.Sprintf("%O", settings.DefaultFileMode), "Mode bits that new files are created with")
flags.String("dir-mode", fmt.Sprintf("%O", settings.DefaultDirMode), "Mode bits that new directories are created with")
} }
//nolint:gocyclo //nolint:gocyclo
@ -170,6 +174,8 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale) fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale)
fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode) fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode)
fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick) fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick)
fmt.Fprintf(w, "\tFile Creation Mode:\t%O\n", set.FileMode)
fmt.Fprintf(w, "\tDirectory Creation Mode:\t%O\n", set.DirMode)
fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " ")) fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " "))
fmt.Fprintf(w, "\tSorting:\n") fmt.Fprintf(w, "\tSorting:\n")
fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By) fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By)

View File

@ -43,6 +43,8 @@ override the options.`,
Theme: mustGetString(flags, "branding.theme"), Theme: mustGetString(flags, "branding.theme"),
Files: mustGetString(flags, "branding.files"), Files: mustGetString(flags, "branding.files"),
}, },
FileMode: mustGetMode(flags, "file-mode"),
DirMode: mustGetMode(flags, "dir-mode"),
} }
ser := &settings.Server{ ser := &settings.Server{

View File

@ -65,6 +65,10 @@ you want to change. Other options will remain unchanged.`,
set.Branding.DisableUsedPercentage = mustGetBool(flags, flag.Name) set.Branding.DisableUsedPercentage = mustGetBool(flags, flag.Name)
case "branding.files": case "branding.files":
set.Branding.Files = mustGetString(flags, flag.Name) set.Branding.Files = mustGetString(flags, flag.Name)
case "file-mode":
set.FileMode = mustGetMode(flags, flag.Name)
case "dir-mode":
set.DirMode = mustGetMode(flags, flag.Name)
} }
}) })

View File

@ -4,9 +4,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
@ -14,12 +16,13 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/storage/bolt"
) )
const dbPerms = 0640
func checkErr(err error) { func checkErr(err error) {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -32,6 +35,13 @@ func mustGetString(flags *pflag.FlagSet, flag string) string {
return s return s
} }
func mustGetMode(flags *pflag.FlagSet, flag string) fs.FileMode {
s := mustGetString(flags, flag)
b, err := strconv.ParseUint(s, 0, 32)
checkErr(err)
return fs.FileMode(b)
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool { func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag) b, err := flags.GetBool(flag)
checkErr(err) checkErr(err)
@ -106,7 +116,7 @@ func python(fn pythonFunc, cfg pythonConfig) cobraFunc {
log.Println("Using database: " + absPath) log.Println("Using database: " + absPath)
data.hadDB = exists data.hadDB = exists
db, err := storm.Open(path, storm.BoltOptions(files.PermFile, nil)) db, err := storm.Open(path, storm.BoltOptions(dbPerms, nil))
checkErr(err) checkErr(err)
defer db.Close() defer db.Close()
data.store, err = bolt.NewStorage(db) data.store, err = bolt.NewStorage(db)

View File

@ -27,9 +27,6 @@ import (
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
) )
const PermFile = 0640
const PermDir = 0750
var ( var (
reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$") reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$")
reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$") reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$")

View File

@ -1,6 +1,7 @@
package fileutils package fileutils
import ( import (
"io/fs"
"os" "os"
"path" "path"
@ -8,7 +9,7 @@ import (
) )
// Copy copies a file or folder from one place to another. // Copy copies a file or folder from one place to another.
func Copy(fs afero.Fs, src, dst string) error { func Copy(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) error {
if src = path.Clean("/" + src); src == "" { if src = path.Clean("/" + src); src == "" {
return os.ErrNotExist return os.ErrNotExist
} }
@ -26,14 +27,14 @@ func Copy(fs afero.Fs, src, dst string) error {
return os.ErrInvalid return os.ErrInvalid
} }
info, err := fs.Stat(src) info, err := afs.Stat(src)
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() { if info.IsDir() {
return CopyDir(fs, src, dst) return CopyDir(afs, src, dst, fileMode, dirMode)
} }
return CopyFile(fs, src, dst) return CopyFile(afs, src, dst, fileMode, dirMode)
} }

View File

@ -2,6 +2,7 @@ package fileutils
import ( import (
"errors" "errors"
"io/fs"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -9,20 +10,20 @@ import (
// CopyDir copies a directory from source to dest and all // CopyDir copies a directory from source to dest and all
// of its sub-directories. It doesn't stop if it finds an error // of its sub-directories. It doesn't stop if it finds an error
// during the copy. Returns an error if any. // during the copy. Returns an error if any.
func CopyDir(fs afero.Fs, source, dest string) error { func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error {
// Get properties of source. // Get properties of source.
srcinfo, err := fs.Stat(source) srcinfo, err := afs.Stat(source)
if err != nil { if err != nil {
return err return err
} }
// Create the destination directory. // Create the destination directory.
err = fs.MkdirAll(dest, srcinfo.Mode()) err = afs.MkdirAll(dest, srcinfo.Mode())
if err != nil { if err != nil {
return err return err
} }
dir, _ := fs.Open(source) dir, _ := afs.Open(source)
obs, err := dir.Readdir(-1) obs, err := dir.Readdir(-1)
if err != nil { if err != nil {
return err return err
@ -36,13 +37,13 @@ func CopyDir(fs afero.Fs, source, dest string) error {
if obj.IsDir() { if obj.IsDir() {
// Create sub-directories, recursively. // Create sub-directories, recursively.
err = CopyDir(fs, fsource, fdest) err = CopyDir(afs, fsource, fdest, fileMode, dirMode)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
} else { } else {
// Perform the file copy. // Perform the file copy.
err = CopyFile(fs, fsource, fdest) err = CopyFile(afs, fsource, fdest, fileMode, dirMode)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
} }

View File

@ -2,29 +2,28 @@ package fileutils
import ( import (
"io" "io"
"io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
) )
// MoveFile moves file from src to dst. // MoveFile moves file from src to dst.
// By default the rename filesystem system call is used. If src and dst point to different volumes // By default the rename filesystem system call is used. If src and dst point to different volumes
// the file copy is used as a fallback // the file copy is used as a fallback
func MoveFile(fs afero.Fs, src, dst string) error { func MoveFile(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) error {
if fs.Rename(src, dst) == nil { if afs.Rename(src, dst) == nil {
return nil return nil
} }
// fallback // fallback
err := Copy(fs, src, dst) err := Copy(afs, src, dst, fileMode, dirMode)
if err != nil { if err != nil {
_ = fs.Remove(dst) _ = afs.Remove(dst)
return err return err
} }
if err := fs.RemoveAll(src); err != nil { if err := afs.RemoveAll(src); err != nil {
return err return err
} }
return nil return nil
@ -32,9 +31,9 @@ func MoveFile(fs afero.Fs, src, dst string) error {
// CopyFile copies a file from source to dest and returns // CopyFile copies a file from source to dest and returns
// an error if any. // an error if any.
func CopyFile(fs afero.Fs, source, dest string) error { func CopyFile(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error {
// Open the source file. // Open the source file.
src, err := fs.Open(source) src, err := afs.Open(source)
if err != nil { if err != nil {
return err return err
} }
@ -42,13 +41,13 @@ func CopyFile(fs afero.Fs, source, dest string) error {
// Makes the directory needed to create the dst // Makes the directory needed to create the dst
// file. // file.
err = fs.MkdirAll(filepath.Dir(dest), files.PermDir) err = afs.MkdirAll(filepath.Dir(dest), dirMode)
if err != nil { if err != nil {
return err return err
} }
// Create the destination file. // Create the destination file.
dst, err := fs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, files.PermFile) dst, err := afs.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil { if err != nil {
return err return err
} }
@ -61,11 +60,11 @@ func CopyFile(fs afero.Fs, source, dest string) error {
} }
// Copy the mode // Copy the mode
info, err := fs.Stat(source) info, err := afs.Stat(source)
if err != nil { if err != nil {
return err return err
} }
err = fs.Chmod(dest, info.Mode()) err = afs.Chmod(dest, info.Mode())
if err != nil { if err != nil {
return err return err
} }

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@ -105,7 +106,7 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
// Directories creation on POST. // Directories creation on POST.
if strings.HasSuffix(r.URL.Path, "/") { if strings.HasSuffix(r.URL.Path, "/") {
err := d.user.Fs.MkdirAll(r.URL.Path, files.PermDir) err := d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
return errToStatus(err), err return errToStatus(err), err
} }
@ -134,7 +135,7 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
} }
err = d.RunHook(func() error { err = d.RunHook(func() error {
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body) info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body, d.settings.FileMode, d.settings.DirMode)
if writeErr != nil { if writeErr != nil {
return writeErr return writeErr
} }
@ -171,7 +172,7 @@ var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
} }
err = d.RunHook(func() error { err = d.RunHook(func() error {
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body) info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body, d.settings.FileMode, d.settings.DirMode)
if writeErr != nil { if writeErr != nil {
return writeErr return writeErr
} }
@ -243,14 +244,14 @@ func checkParent(src, dst string) error {
return nil return nil
} }
func addVersionSuffix(source string, fs afero.Fs) string { func addVersionSuffix(source string, afs afero.Fs) string {
counter := 1 counter := 1
dir, name := path.Split(source) dir, name := path.Split(source)
ext := filepath.Ext(name) ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext) base := strings.TrimSuffix(name, ext)
for { for {
if _, err := fs.Stat(source); err != nil { if _, err := afs.Stat(source); err != nil {
break break
} }
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext) renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
@ -261,14 +262,14 @@ func addVersionSuffix(source string, fs afero.Fs) string {
return source return source
} }
func writeFile(fs afero.Fs, dst string, in io.Reader) (os.FileInfo, error) { func writeFile(afs afero.Fs, dst string, in io.Reader, fileMode, dirMode fs.FileMode) (os.FileInfo, error) {
dir, _ := path.Split(dst) dir, _ := path.Split(dst)
err := fs.MkdirAll(dir, files.PermDir) err := afs.MkdirAll(dir, dirMode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
file, err := fs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, files.PermFile) file, err := afs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -306,7 +307,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
return fbErrors.ErrPermissionDenied return fbErrors.ErrPermissionDenied
} }
return fileutils.Copy(d.user.Fs, src, dst) return fileutils.Copy(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
case "rename": case "rename":
if !d.user.Perm.Rename { if !d.user.Perm.Rename {
return fbErrors.ErrPermissionDenied return fbErrors.ErrPermissionDenied
@ -332,7 +333,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
return err return err
} }
return fileutils.MoveFile(d.user.Fs, src, dst) return fileutils.MoveFile(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
default: default:
return fmt.Errorf("unsupported action %s: %w", action, fbErrors.ErrInvalidRequestParams) return fmt.Errorf("unsupported action %s: %w", action, fbErrors.ErrInvalidRequestParams)
} }

View File

@ -92,7 +92,7 @@ func tusPostHandler() handleFunc {
case errors.Is(err, afero.ErrFileNotFound): case errors.Is(err, afero.ErrFileNotFound):
dirPath := filepath.Dir(r.URL.Path) dirPath := filepath.Dir(r.URL.Path)
if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) { if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) {
if mkdirErr := d.user.Fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil { if mkdirErr := d.user.Fs.MkdirAll(dirPath, d.settings.DirMode); mkdirErr != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
} }
} }
@ -121,7 +121,7 @@ func tusPostHandler() handleFunc {
fileFlags |= os.O_TRUNC fileFlags |= os.O_TRUNC
} }
openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, files.PermFile) openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, d.settings.FileMode)
if err != nil { if err != nil {
return errToStatus(err), err return errToStatus(err), err
} }
@ -239,7 +239,7 @@ func tusPatchHandler() handleFunc {
) )
} }
openFile, err := d.user.Fs.OpenFile(r.URL.Path, os.O_WRONLY|os.O_APPEND, files.PermFile) openFile, err := d.user.Fs.OpenFile(r.URL.Path, os.O_WRONLY|os.O_APPEND, d.settings.FileMode)
if err != nil { if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not open file: %w", err) return http.StatusInternalServerError, fmt.Errorf("could not open file: %w", err)
} }

View File

@ -2,6 +2,7 @@ package settings
import ( import (
"crypto/rand" "crypto/rand"
"io/fs"
"log" "log"
"strings" "strings"
"time" "time"
@ -11,6 +12,8 @@ import (
const DefaultUsersHomeBasePath = "/users" const DefaultUsersHomeBasePath = "/users"
const DefaultMinimumPasswordLength = 12 const DefaultMinimumPasswordLength = 12
const DefaultFileMode = 0640
const DefaultDirMode = 0750
// AuthMethod describes an authentication method. // AuthMethod describes an authentication method.
type AuthMethod string type AuthMethod string
@ -29,6 +32,8 @@ type Settings struct {
Shell []string `json:"shell"` Shell []string `json:"shell"`
Rules []rules.Rule `json:"rules"` Rules []rules.Rule `json:"rules"`
MinimumPasswordLength uint `json:"minimumPasswordLength"` MinimumPasswordLength uint `json:"minimumPasswordLength"`
FileMode fs.FileMode `json:"fileMode"`
DirMode fs.FileMode `json:"dirMode"`
} }
// GetRules implements rules.Provider. // GetRules implements rules.Provider.

View File

@ -42,6 +42,12 @@ func (s *Storage) Get() (*Settings, error) {
RetryCount: DefaultTusRetryCount, RetryCount: DefaultTusRetryCount,
} }
} }
if set.FileMode == 0 {
set.FileMode = DefaultFileMode
}
if set.DirMode == 0 {
set.DirMode = DefaultDirMode
}
return set, nil return set, nil
} }