mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 03:17:27 +02:00
feat(server): add shared user avatar serving helper
ServeUserAvatar lives in server/imghttp so both server/subsonic and server/jellyfin can call it without importing each other. It delegates ETag/If-None-Match handling and Content-Type detection to http.ServeContent instead of hand-parsing the header, avoiding a false-304 on multi-valued or wildcard If-None-Match headers.
This commit is contained in:
parent
cac170781b
commit
61bb68fc6b
2 changed files with 121 additions and 0 deletions
34
server/imghttp/avatar.go
Normal file
34
server/imghttp/avatar.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package imghttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// ServeUserAvatar writes the user's uploaded avatar, reporting whether it answered the request;
|
||||
// when false the caller must fall back. http.ServeContent gives correct ETag/If-None-Match handling.
|
||||
func ServeUserAvatar(w http.ResponseWriter, r *http.Request, u *model.User) bool {
|
||||
path := u.UploadedImagePath()
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Could not open user avatar", "user", u.UserName, err)
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Could not stat user avatar", "user", u.UserName, err)
|
||||
return false
|
||||
}
|
||||
|
||||
w.Header().Set("ETag", `"`+u.AvatarTag()+`"`)
|
||||
w.Header().Set("Cache-Control", "private, no-cache")
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
||||
return true
|
||||
}
|
||||
87
server/imghttp/avatar_test.go
Normal file
87
server/imghttp/avatar_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package imghttp_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/imghttp"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ServeUserAvatar", func() {
|
||||
var w *httptest.ResponseRecorder
|
||||
var r *http.Request
|
||||
var usr model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/avatar", nil)
|
||||
usr = model.User{ID: "u1", UserName: "deluan", UpdatedAt: time.Unix(1000, 0)}
|
||||
})
|
||||
|
||||
It("returns false when the user has no avatar", func() {
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when the file is missing on disk", func() {
|
||||
usr.UploadedImage = "u1_deluan.png"
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("serves the file with a content type and an ETag", func() {
|
||||
usr.UploadedImage = writeAvatar(usr, "png")
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeTrue())
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
||||
Expect(w.Header().Get("ETag")).To(Equal(`"` + usr.AvatarTag() + `"`))
|
||||
})
|
||||
|
||||
It("answers 304 when the ETag matches", func() {
|
||||
usr.UploadedImage = writeAvatar(usr, "png")
|
||||
r.Header.Set("If-None-Match", `"`+usr.AvatarTag()+`"`)
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeTrue())
|
||||
Expect(w.Code).To(Equal(http.StatusNotModified))
|
||||
})
|
||||
|
||||
It("does not leak the absolute filesystem path in the response", func() {
|
||||
usr.UploadedImage = writeAvatar(usr, "png")
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeTrue())
|
||||
Expect(w.Body.String()).NotTo(ContainSubstring(conf.Server.DataFolder.String()))
|
||||
for _, values := range w.Header() {
|
||||
for _, v := range values {
|
||||
Expect(v).NotTo(ContainSubstring(conf.Server.DataFolder.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
It("does not false-304 on an unrelated multi-value If-None-Match", func() {
|
||||
usr.UploadedImage = writeAvatar(usr, "png")
|
||||
r.Header.Set("If-None-Match", `"deadbeefdeadbeef", "cafecafecafecafe"`)
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeTrue())
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("treats If-None-Match: * as matching the current representation", func() {
|
||||
usr.UploadedImage = writeAvatar(usr, "png")
|
||||
r.Header.Set("If-None-Match", "*")
|
||||
Expect(imghttp.ServeUserAvatar(w, r, &usr)).To(BeTrue())
|
||||
Expect(w.Code).To(Equal(http.StatusNotModified))
|
||||
})
|
||||
})
|
||||
|
||||
func writeAvatar(u model.User, ext string) string {
|
||||
name := u.ID + "_" + u.UserName + "." + ext
|
||||
path := filepath.Join(conf.Server.DataFolder.String(), "avatar", name)
|
||||
Expect(os.MkdirAll(filepath.Dir(path), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(path, []byte{0x89, 'P', 'N', 'G'}, 0600)).To(Succeed())
|
||||
return name
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue