Files
navidrome/server/nativeapi/image_upload.go
m8tec c49e5855b9 feat(artwork): make max image upload size configurable (#5335)
* feat(config): make max image upload size configurable

Let max image upload size be set from config or environment instead of a fixed 10 MB cap. The upload handler still falls back to 10 MB when MaxImageUploadSize is not set.

Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com>

* feat(config): support human-readable MaxImageUploadSize values

Max image upload size can now be configured as a readable string like 10MB or 1GB instead of raw bytes. The config load validates it at startup, and the upload handler parses it before applying request limits (10MB fallback if it fails).

+ MaxImageUploadSize as human-readable string
+ removed redundant max(1, ...) to address code review
+ cap memory usage of ParseMultipartForm to 10MB (address code review)

Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com>

* refactor(config): consolidate MaxImageUploadSize default and add tests

Move the "10MB" default constant to consts.DefaultMaxImageUploadSize so
both the viper default and the runtime fallback share a single source of
truth. Improve the validator error message with fmt.Errorf wrapping to
match the project convention (e.g. validatePurgeMissingOption). Add unit
tests for validateMaxImageUploadSize (valid/invalid inputs) and
maxImageUploadSize (configured, empty, invalid, raw bytes). Compute
maxImageSize once at handler creation rather than per request.

---------

Signed-off-by: M8te <38794725+m8tec@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-04-12 11:16:00 -04:00

130 lines
3.6 KiB
Go

package nativeapi
import (
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"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"
)
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())
if !conf.Server.EnableArtworkUpload && !user.IsAdmin {
http.Error(w, "artwork upload is disabled", http.StatusForbidden)
return false
}
return true
}
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(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
}
defer func() {
if r.MultipartForm != nil {
if err := r.MultipartForm.RemoveAll(); err != nil {
log.Warn(ctx, "Error removing multipart temp files", err)
}
}
}()
file, header, err := r.FormFile("image")
if err != nil {
log.Error(ctx, "Error reading uploaded file", err)
http.Error(w, "missing image file", http.StatusBadRequest)
return
}
defer file.Close()
_, format, err := image.DecodeConfig(file)
if err != nil {
log.Error(ctx, "Uploaded file is not a valid image", err)
http.Error(w, "invalid image file", http.StatusBadRequest)
return
}
if seeker, ok := file.(io.Seeker); ok {
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
log.Error(ctx, "Error seeking file", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
ext := "." + format
if ext == "." {
ext = strings.ToLower(filepath.Ext(header.Filename))
}
if ext == "" || ext == "." {
log.Error(ctx, "Could not determine image type", "filename", header.Filename)
http.Error(w, "could not determine image type", http.StatusBadRequest)
return
}
if err := saveFn(ctx, file, ext); err != nil {
if errors.Is(err, model.ErrNotAuthorized) {
http.Error(w, "not authorized", http.StatusForbidden)
return
}
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
log.Error(ctx, "Error saving image", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = fmt.Fprintf(w, `{"status":"ok"}`)
}
}
func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !checkImageUploadPermission(w, r) {
return
}
if err := deleteFn(ctx); err != nil {
if errors.Is(err, model.ErrNotAuthorized) {
http.Error(w, "not authorized", http.StatusForbidden)
return
}
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
log.Error(ctx, "Error removing image", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = fmt.Fprintf(w, `{"status":"ok"}`)
}
}