diff --git a/cmd/root.go b/cmd/root.go index 981eec4f..13139a7e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -46,6 +46,7 @@ var ( "disable-type-detection-by-header": "disableTypeDetectionByHeader", "img-processors": "imageProcessors", "cache-dir": "cacheDir", + "redis-cache-url": "redisCacheUrl", "token-expiration-time": "tokenExpirationTime", "baseurl": "baseURL", } @@ -88,6 +89,7 @@ func init() { flags.String("password", "", "hashed password for the first user when using quick setup") flags.Uint32("socketPerm", 0666, "unix socket file permissions") flags.String("cacheDir", "", "file cache directory (disabled if empty)") + flags.String("redisCacheUrl", "", "redis cache URL (for multi-instance deployments), e.g. redis://user:pass@host:port") flags.Int("imageProcessors", 4, "image processors count") addServerFlags(flags) } @@ -176,6 +178,12 @@ user created with the credentials from options "username" and "password".`, fileCache = diskcache.New(afero.NewOsFs(), cacheDir) } + redisCacheURL := v.GetString("redisCacheUrl") + uploadCache, err := fbhttp.NewUploadCache(redisCacheURL) + if err != nil { + return fmt.Errorf("failed to initialize upload cache: %w", err) + } + server, err := getServerSettings(v, st.Storage) if err != nil { return err @@ -227,7 +235,7 @@ user created with the credentials from options "username" and "password".`, panic(err) } - handler, err := fbhttp.NewHandler(imageService, fileCache, st.Storage, server, assetsFs) + handler, err := fbhttp.NewHandler(imageService, fileCache, uploadCache, st.Storage, server, assetsFs) if err != nil { return err } diff --git a/compose.redis.yaml b/compose.redis.yaml new file mode 100644 index 00000000..878a96b3 --- /dev/null +++ b/compose.redis.yaml @@ -0,0 +1,17 @@ +# Run using: +# docker compose -f compose.yaml -f compose.redis.yaml up --build + +services: + redis: + container_name: redis + image: redis:latest + networks: + - filebrowser + command: + - sh + - -c + - | + cat > /tmp/users.acl <filebrowser ~* +@all + EOF + redis-server --aclfile /tmp/users.acl diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..be19b56a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,37 @@ +# Run using: +# docker compose up --build + +services: + filebrowser: + container_name: filebrowser + build: + dockerfile: Dockerfile + context: . + networks: + - filebrowser + ports: + - 8000:80 + volumes: + - filebrowser:/flux/vault + environment: + - REDIS_CACHE_URL=redis://default:filebrowser@redis:6379 # Use rediss:// for ssl + + redis: + container_name: redis + image: redis:latest + networks: + - filebrowser + command: + - sh + - -c + - | + cat > /tmp/users.acl <filebrowser ~* +@all + EOF + redis-server --aclfile /tmp/users.acl + +networks: + filebrowser: + +volumes: + filebrowser: diff --git a/go.mod b/go.mod index 6a4c0008..8d2afd51 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/marusama/semaphore/v2 v2.5.0 github.com/mholt/archives v0.1.5 github.com/mitchellh/go-homedir v1.1.0 + github.com/redis/go-redis/v9 v9.17.2 github.com/samber/lo v1.52.0 github.com/shirou/gopsutil/v4 v4.26.1 github.com/spf13/afero v1.15.0 @@ -39,8 +40,10 @@ require ( github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect diff --git a/go.sum b/go.sum index b6522ab2..ceb6c426 100644 --- a/go.sum +++ b/go.sum @@ -42,7 +42,13 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4 github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -53,6 +59,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -194,6 +202,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= diff --git a/http/http.go b/http/http.go index bb57f395..ec8e52c1 100644 --- a/http/http.go +++ b/http/http.go @@ -19,6 +19,7 @@ type modifyRequest struct { func NewHandler( imgSvc ImgService, fileCache FileCache, + uploadCache UploadCache, store *storage.Storage, server *settings.Server, assetsFs fs.FS, @@ -67,10 +68,10 @@ func NewHandler( api.PathPrefix("/resources").Handler(monkey(resourcePutHandler, "/api/resources")).Methods("PUT") api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler(fileCache), "/api/resources")).Methods("PATCH") - api.PathPrefix("/tus").Handler(monkey(tusPostHandler(), "/api/tus")).Methods("POST") - api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(), "/api/tus")).Methods("HEAD", "GET") - api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(), "/api/tus")).Methods("PATCH") - api.PathPrefix("/tus").Handler(monkey(tusDeleteHandler(), "/api/tus")).Methods("DELETE") + api.PathPrefix("/tus").Handler(monkey(tusPostHandler(uploadCache), "/api/tus")).Methods("POST") + api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(uploadCache), "/api/tus")).Methods("HEAD", "GET") + api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(uploadCache), "/api/tus")).Methods("PATCH") + api.PathPrefix("/tus").Handler(monkey(tusDeleteHandler(uploadCache), "/api/tus")).Methods("DELETE") api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET") diff --git a/http/tus_handlers.go b/http/tus_handlers.go index 498d776f..b659d479 100644 --- a/http/tus_handlers.go +++ b/http/tus_handlers.go @@ -1,7 +1,6 @@ package fbhttp import ( - "context" "errors" "fmt" "io" @@ -12,48 +11,13 @@ import ( "strconv" "time" - "github.com/jellydator/ttlcache/v3" "github.com/spf13/afero" "github.com/filebrowser/filebrowser/v2/files" ) -const maxUploadWait = 3 * time.Minute - -// Tracks active uploads along with their respective upload lengths -var activeUploads = initActiveUploads() - -func initActiveUploads() *ttlcache.Cache[string, int64] { - cache := ttlcache.New[string, int64]() - cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, int64]) { - if reason == ttlcache.EvictionReasonExpired { - fmt.Printf("deleting incomplete upload file: \"%s\"", item.Key()) - os.Remove(item.Key()) - } - }) - go cache.Start() - - return cache -} - -func registerUpload(filePath string, fileSize int64) { - activeUploads.Set(filePath, fileSize, maxUploadWait) -} - -func completeUpload(filePath string) { - activeUploads.Delete(filePath) -} - -func getActiveUploadLength(filePath string) (int64, error) { - item := activeUploads.Get(filePath) - if item == nil { - return 0, fmt.Errorf("no active upload found for the given path") - } - - return item.Value(), nil -} - -func keepUploadActive(filePath string) func() { +// keepUploadActive periodically touches the cache entry to prevent eviction during transfer +func keepUploadActive(cache UploadCache, filePath string) func() { stop := make(chan bool) go func() { @@ -65,7 +29,7 @@ func keepUploadActive(filePath string) func() { case <-stop: return case <-ticker.C: - activeUploads.Touch(filePath) + cache.Touch(filePath) } } }() @@ -75,7 +39,7 @@ func keepUploadActive(filePath string) func() { } } -func tusPostHandler() handleFunc { +func tusPostHandler(cache UploadCache) handleFunc { return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { if !d.user.Perm.Create || !d.Check(r.URL.Path) { return http.StatusForbidden, nil @@ -146,7 +110,7 @@ func tusPostHandler() handleFunc { } // Enables the user to utilize the PATCH endpoint for uploading file data - registerUpload(file.RealPath(), uploadLength) + cache.Register(file.RealPath(), uploadLength) path, err := url.JoinPath("/", d.server.BaseURL, "/api/tus", r.URL.Path) if err != nil { @@ -158,7 +122,7 @@ func tusPostHandler() handleFunc { }) } -func tusHeadHandler() handleFunc { +func tusHeadHandler(cache UploadCache) handleFunc { return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { w.Header().Set("Cache-Control", "no-store") if !d.user.Perm.Create || !d.Check(r.URL.Path) { @@ -177,7 +141,7 @@ func tusHeadHandler() handleFunc { return errToStatus(err), err } - uploadLength, err := getActiveUploadLength(file.RealPath()) + uploadLength, err := cache.GetLength(file.RealPath()) if err != nil { return http.StatusNotFound, err } @@ -189,7 +153,7 @@ func tusHeadHandler() handleFunc { }) } -func tusPatchHandler() handleFunc { +func tusPatchHandler(cache UploadCache) handleFunc { return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { if !d.user.Perm.Create || !d.Check(r.URL.Path) { return http.StatusForbidden, nil @@ -219,13 +183,13 @@ func tusPatchHandler() handleFunc { return errToStatus(err), err } - uploadLength, err := getActiveUploadLength(file.RealPath()) + uploadLength, err := cache.GetLength(file.RealPath()) if err != nil { return http.StatusNotFound, err } // Prevent the upload from being evicted during the transfer - stop := keepUploadActive(file.RealPath()) + stop := keepUploadActive(cache, file.RealPath()) defer stop() switch { @@ -266,7 +230,7 @@ func tusPatchHandler() handleFunc { w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10)) if newOffset >= uploadLength { - completeUpload(file.RealPath()) + cache.Complete(file.RealPath()) _ = d.RunHook(func() error { return nil }, "upload", r.URL.Path, "", d.user) } @@ -274,7 +238,7 @@ func tusPatchHandler() handleFunc { }) } -func tusDeleteHandler() handleFunc { +func tusDeleteHandler(cache UploadCache) handleFunc { return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) { if r.URL.Path == "/" || !d.user.Perm.Create { return http.StatusForbidden, nil @@ -292,7 +256,7 @@ func tusDeleteHandler() handleFunc { return errToStatus(err), err } - _, err = getActiveUploadLength(file.RealPath()) + _, err = cache.GetLength(file.RealPath()) if err != nil { return http.StatusNotFound, err } @@ -302,7 +266,7 @@ func tusDeleteHandler() handleFunc { return errToStatus(err), err } - completeUpload(file.RealPath()) + cache.Complete(file.RealPath()) return http.StatusNoContent, nil }) diff --git a/http/upload_cache_memory.go b/http/upload_cache_memory.go new file mode 100644 index 00000000..5b080fec --- /dev/null +++ b/http/upload_cache_memory.go @@ -0,0 +1,85 @@ +package fbhttp + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jellydator/ttlcache/v3" +) + +const uploadCacheTTL = 3 * time.Minute + +// UploadCache is an interface for tracking active uploads. +// Allows for different backends (e.g. in-memory or redis) +// to support both single instance and multi replica deployments. +type UploadCache interface { + // Register stores an upload with its expected file size + Register(filePath string, fileSize int64) + + // Complete removes an upload from the cache + Complete(filePath string) + + // GetLength returns the expected file size for an active upload + GetLength(filePath string) (int64, error) + + // Touch refreshes the TTL for an active upload + Touch(filePath string) + + // Close cleans up any resources + Close() +} + +// memoryUploadCache is an upload cache for single replica deployments +type memoryUploadCache struct { + cache *ttlcache.Cache[string, int64] +} + +func newMemoryUploadCache() *memoryUploadCache { + cache := ttlcache.New[string, int64]() + cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, int64]) { + if reason == ttlcache.EvictionReasonExpired { + fmt.Printf("deleting incomplete upload file: \"%s\"\n", item.Key()) + os.Remove(item.Key()) + } + }) + go cache.Start() + + return &memoryUploadCache{cache: cache} +} + +func (c *memoryUploadCache) Register(filePath string, fileSize int64) { + c.cache.Set(filePath, fileSize, uploadCacheTTL) +} + +func (c *memoryUploadCache) Complete(filePath string) { + c.cache.Delete(filePath) +} + +func (c *memoryUploadCache) GetLength(filePath string) (int64, error) { + item := c.cache.Get(filePath) + if item == nil { + return 0, fmt.Errorf("no active upload found for the given path") + } + return item.Value(), nil +} + +func (c *memoryUploadCache) Touch(filePath string) { + c.cache.Touch(filePath) +} + +func (c *memoryUploadCache) Close() { + c.cache.Stop() +} + +// NewUploadCache creates a new upload cache. +// If redisURL is empty, an in-memory cache will be used (suitable for single instance deployments). +// Otherwise, Redis will be used for the cache (suitable for multi-instance deployments). +// The redisURL can include credentials, e.g. redis://user:pass@host:port +func NewUploadCache(redisURL string) (UploadCache, error) { + if redisURL != "" { + return newRedisUploadCache(redisURL) + } + return newMemoryUploadCache(), nil +} diff --git a/http/upload_cache_redis.go b/http/upload_cache_redis.go new file mode 100644 index 00000000..9926eba2 --- /dev/null +++ b/http/upload_cache_redis.go @@ -0,0 +1,82 @@ +package fbhttp + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + + "github.com/redis/go-redis/v9" +) + +// redisUploadCache is an upload cache for multi replica deployments +type redisUploadCache struct { + client *redis.Client +} + +func newRedisUploadCache(redisURL string) (*redisUploadCache, error) { + if redisURL == "" { + return nil, fmt.Errorf("redis URL is required") + } + + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("invalid redis URL: %w", err) + } + + client := redis.NewClient(opts) + + // Test connection + if err := client.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } + + return &redisUploadCache{client: client}, nil +} + +func (c *redisUploadCache) filePathKey(filePath string) string { + return "filebrowser:upload:" + filePath +} + +func (c *redisUploadCache) Register(filePath string, fileSize int64) { + err := c.client.Set(context.Background(), c.filePathKey(filePath), fileSize, uploadCacheTTL).Err() + if err != nil { + log.Printf("failed to register upload in redis cache: %v", err) + } +} + +func (c *redisUploadCache) Complete(filePath string) { + err := c.client.Del(context.Background(), c.filePathKey(filePath)).Err() + if err != nil { + log.Printf("failed to complete upload in redis cache: %v", err) + } +} + +func (c *redisUploadCache) GetLength(filePath string) (int64, error) { + result, err := c.client.Get(context.Background(), c.filePathKey(filePath)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return 0, fmt.Errorf("no active upload found for the given path") + } + return 0, fmt.Errorf("redis error: %w", err) + } + + size, err := strconv.ParseInt(result, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid upload length in cache: %w", err) + } + + return size, nil +} + +func (c *redisUploadCache) Touch(filePath string) { + err := c.client.Expire(context.Background(), c.filePathKey(filePath), uploadCacheTTL).Err() + if err != nil { + log.Printf("failed to touch upload in redis cache: %v", err) + } +} + +func (c *redisUploadCache) Close() { + c.client.Close() +}