Skip to content

Commit e2e2bc4

Browse files
fix: serialize application image changes
Concurrent uploads for the same application could both read the same old image, both delete it, and leave the image written by the request whose database update was overwritten on disk without any reference. Serialize image uploads, image removal and application deletion, and read the application again under the lock before replacing its image. Fixes #1047
1 parent d02796b commit e2e2bc4

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

‎api/application.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"sync"
1011

1112
"github.com/gin-gonic/gin"
1213
"github.com/gotify/server/v3/auth"
@@ -29,6 +30,11 @@ type ApplicationDatabase interface {
2930
type ApplicationAPI struct {
3031
DB ApplicationDatabase
3132
ImageDir string
33+
34+
// imageMu serializes changes to application images, so that concurrent
35+
// requests neither delete an image that is still referenced nor leave
36+
// an unreferenced image behind.
37+
imageMu sync.Mutex
3238
}
3339

3440
// Application Params Model
@@ -188,6 +194,9 @@ func (a *ApplicationAPI) GetApplications(ctx *gin.Context) {
188194
// $ref: "#/definitions/Error"
189195
func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
190196
withID(ctx, "id", func(id uint) {
197+
a.imageMu.Lock()
198+
defer a.imageMu.Unlock()
199+
191200
app, err := a.DB.GetApplicationByID(id)
192201
if success := successOrAbort(ctx, 500, err); !success {
193202
return
@@ -434,6 +443,20 @@ func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
434443
return
435444
}
436445

446+
a.imageMu.Lock()
447+
defer a.imageMu.Unlock()
448+
449+
// Read the application again, a concurrent request may have
450+
// changed or deleted its image in the meantime.
451+
app, err = a.DB.GetApplicationByID(id)
452+
if success := successOrAbort(ctx, 500, err); !success {
453+
return
454+
}
455+
if app == nil || app.UserID != auth.GetUserID(ctx) {
456+
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
457+
return
458+
}
459+
437460
name := generateNonExistingImageName(a.ImageDir, func() string {
438461
return generateImageName() + ext
439462
})
@@ -500,6 +523,9 @@ func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
500523
// $ref: "#/definitions/Error"
501524
func (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) {
502525
withID(ctx, "id", func(id uint) {
526+
a.imageMu.Lock()
527+
defer a.imageMu.Unlock()
528+
503529
app, err := a.DB.GetApplicationByID(id)
504530
if success := successOrAbort(ctx, 500, err); !success {
505531
return

‎api/application_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http/httptest"
1111
"os"
1212
"strings"
13+
"sync"
1314
"testing"
1415

1516
"github.com/gin-gonic/gin"
@@ -483,6 +484,49 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImage(
483484
assert.Len(s.T(), listing, 1)
484485
}
485486

487+
func (s *ApplicationSuite) Test_UploadAppImage_Concurrent_KeepsOnlyReferencedImage() {
488+
existingImageName := "existing.png"
489+
s.db.User(5)
490+
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})
491+
fakeImage(s.T(), s.imageDir.Path(existingImageName))
492+
493+
// Both uploads read the application before either of them changes it.
494+
db := &concurrentReadsAppDB{ApplicationDatabase: s.db, pending: 2}
495+
db.wg.Add(2)
496+
s.a.DB = db
497+
498+
var wg sync.WaitGroup
499+
for range 2 {
500+
cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
501+
require.NoError(s.T(), err)
502+
recorder := httptest.NewRecorder()
503+
ctx, _ := gin.CreateTestContext(recorder)
504+
withURL(ctx, "http", "example.com")
505+
ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
506+
ctx.Request.Header.Set("Content-Type", cType)
507+
test.WithUser(ctx, 5)
508+
ctx.Params = gin.Params{{Key: "id", Value: "1"}}
509+
510+
wg.Add(1)
511+
go func() {
512+
defer wg.Done()
513+
s.a.UploadApplicationImage(ctx)
514+
assert.Equal(s.T(), 200, recorder.Code)
515+
}()
516+
}
517+
wg.Wait()
518+
519+
app, err := s.db.GetApplicationByID(1)
520+
require.NoError(s.T(), err)
521+
listing, err := os.ReadDir(s.imageDir.Path())
522+
require.NoError(s.T(), err)
523+
names := []string{}
524+
for _, entry := range listing {
525+
names = append(names, entry.Name())
526+
}
527+
assert.Equal(s.T(), []string{app.Image}, names)
528+
}
529+
486530
func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() {
487531
s.db.User(5).App(1)
488532

@@ -762,3 +806,27 @@ func fakeImage(t *testing.T, path string) {
762806
err = os.WriteFile(path, data, 0o644)
763807
assert.Nil(t, err)
764808
}
809+
810+
// concurrentReadsAppDB lets the first pending calls to GetApplicationByID
811+
// return only after all of them have been made.
812+
type concurrentReadsAppDB struct {
813+
ApplicationDatabase
814+
mu sync.Mutex
815+
pending int
816+
wg sync.WaitGroup
817+
}
818+
819+
func (d *concurrentReadsAppDB) GetApplicationByID(id uint) (*model.Application, error) {
820+
app, err := d.ApplicationDatabase.GetApplicationByID(id)
821+
d.mu.Lock()
822+
wait := d.pending > 0
823+
if wait {
824+
d.pending--
825+
}
826+
d.mu.Unlock()
827+
if wait {
828+
d.wg.Done()
829+
d.wg.Wait()
830+
}
831+
return app, err
832+
}

0 commit comments

Comments
 (0)