diff --git a/conf/configuration.go b/conf/configuration.go index 58239884a..24d116e66 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" + "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" @@ -80,6 +81,7 @@ type configOptions struct { EnableStarRating bool EnableUserEditing bool EnableArtworkUpload bool + MaxImageUploadSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -360,6 +362,7 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, + validateMaxImageUploadSize, validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { @@ -584,6 +587,15 @@ func validatePurgeMissingOption() error { return nil } +func validateMaxImageUploadSize() error { + if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { + err = fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) + log.Error(err.Error()) + return err + } + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -742,6 +754,7 @@ func setViperDefaults() { viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) viper.SetDefault("enableartworkupload", true) + viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index eb2176e83..121b1902c 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -219,6 +219,37 @@ var _ = Describe("Configuration", func() { }) + Describe("ValidateMaxImageUploadSize", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + DescribeTable("accepts valid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) + }, + Entry("megabytes", "10MB"), + Entry("gigabytes", "1GB"), + Entry("raw bytes", "10485760"), + Entry("mebibytes", "10MiB"), + Entry("lower case", "50mb"), + ) + + DescribeTable("rejects invalid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) + }, + Entry("garbage string", "not-a-size"), + Entry("negative-looking", "-10MB"), + ) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index 051f9bb65..85755aa12 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,6 +14,8 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase +var ValidateMaxImageUploadSize = validateMaxImageUploadSize + func SetLogFatal(f func(...any)) func() { old := logFatal logFatal = f diff --git a/consts/consts.go b/consts/consts.go index ff5dedc2b..3db0b831a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -85,7 +85,8 @@ const ( ) const ( - DefaultUICoverArtSize = 300 + DefaultUICoverArtSize = 300 + DefaultMaxImageUploadSize = "10MB" ) // Prometheus options diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 1f55e3851..5e2d29876 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,14 +13,22 @@ import ( "path/filepath" "strings" + "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -const maxImageSize = 10 << 20 // 10MB +func maxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) @@ -32,13 +40,14 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + maxImageSize := maxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { return } r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) - if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + if err := r.ParseMultipartForm(min(maxImageSize, 10<<20)); err != nil { log.Error(ctx, "Error parsing multipart form", err) http.Error(w, "file too large or invalid form", http.StatusBadRequest) return diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go new file mode 100644 index 000000000..291912e67 --- /dev/null +++ b/server/nativeapi/image_upload_test.go @@ -0,0 +1,34 @@ +package nativeapi + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("maxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +})