fix(album): serialize cover upload/delete to close shared-file races

The shared-file handling is check-then-act (CountByImage, then remove/write):
two concurrent deletes on albums sharing one file could both see refs>1, both
skip removal, and both clear their rows — leaving the file orphaned with no
remaining reference for the purge GC to find. Serialize album image
operations behind a mutex; they are rare, admin-gated actions.
This commit is contained in:
Deluan 2026-07-18 02:07:07 -04:00
parent b0aff3c9d6
commit a79e670740
2 changed files with 8 additions and 0 deletions

View File

@ -32,6 +32,8 @@ func (api *Router) addAlbumRoute(r chi.Router) {
func (api *Router) uploadAlbumImage() http.HandlerFunc {
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
api.albumImgOps.Lock()
defer api.albumImgOps.Unlock()
albumID := chi.URLParamFromCtx(ctx, "id")
al, err := api.ds.Album(ctx).Get(albumID)
if err != nil {
@ -77,6 +79,8 @@ func (api *Router) albumImagePathToRemove(ctx context.Context, al *model.Album)
func (api *Router) deleteAlbumImage() http.HandlerFunc {
return handleImageDelete(func(ctx context.Context) error {
api.albumImgOps.Lock()
defer api.albumImgOps.Unlock()
albumID := chi.URLParamFromCtx(ctx, "id")
al, err := api.ds.Album(ctx).Get(albumID)
if err != nil {

View File

@ -6,6 +6,7 @@ import (
"html"
"net/http"
"strconv"
"sync"
"time"
"github.com/deluan/rest"
@ -45,6 +46,9 @@ type Router struct {
maintenance core.Maintenance
pluginManager PluginManager
imgUpload core.ImageUploadService
// Serializes album image check-and-act sequences: shared-file ref-counting is
// check-then-act, and concurrent requests could orphan or clobber a shared file.
albumImgOps sync.Mutex
}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router {