Skip to content

Commit efb90f3

Browse files
fix: clean up application images when an upload fails
A failed write left a partially written image file behind, and a failed database update after saving the new image had already deleted the image the application still referenced. Write the uploaded image with a helper that removes the file if it can't be written completely, and only delete the old image once the application has been updated. If the update fails, remove the new image instead.
1 parent d02796b commit efb90f3

2 files changed

Lines changed: 91 additions & 6 deletions

File tree

‎api/application.go‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package api
33
import (
44
"errors"
55
"fmt"
6+
"io"
7+
"mime/multipart"
68
"net/http"
79
"os"
810
"path/filepath"
@@ -438,20 +440,25 @@ func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
438440
return generateImageName() + ext
439441
})
440442

441-
err = ctx.SaveUploadedFile(file, a.ImageDir+name)
443+
err = saveImage(file, a.ImageDir+name)
442444
if err != nil {
443445
ctx.AbortWithError(500, err)
444446
return
445447
}
446448

447-
if app.Image != "" {
448-
os.Remove(a.ImageDir + app.Image)
449-
}
450-
449+
oldImage := app.Image
451450
app.Image = name
452-
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
451+
if err := a.DB.UpdateApplication(app); err != nil {
452+
// The application still references the old image, so keep
453+
// it and drop the new one that nothing references.
454+
os.Remove(a.ImageDir + name)
455+
ctx.AbortWithError(500, err)
453456
return
454457
}
458+
459+
if oldImage != "" {
460+
os.Remove(a.ImageDir + oldImage)
461+
}
455462
ctx.JSON(200, withResolvedImage(app))
456463
} else {
457464
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
@@ -533,6 +540,34 @@ func withResolvedImage(app *model.Application) *model.Application {
533540
return app
534541
}
535542

543+
// saveImage writes the uploaded file to dst. If it can't be written
544+
// completely, the partially written file is removed.
545+
func saveImage(file *multipart.FileHeader, dst string) error {
546+
src, err := file.Open()
547+
if err != nil {
548+
return err
549+
}
550+
defer src.Close()
551+
return writeImage(dst, src)
552+
}
553+
554+
func writeImage(dst string, src io.Reader) (err error) {
555+
out, err := os.Create(dst)
556+
if err != nil {
557+
return err
558+
}
559+
defer func() {
560+
if closeErr := out.Close(); err == nil {
561+
err = closeErr
562+
}
563+
if err != nil {
564+
os.Remove(dst)
565+
}
566+
}()
567+
_, err = io.Copy(out, src)
568+
return err
569+
}
570+
536571
func exist(path string) bool {
537572
if _, err := os.Stat(path); os.IsNotExist(err) {
538573
return false

‎api/application_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,43 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImage(
483483
assert.Len(s.T(), listing, 1)
484484
}
485485

486+
func (s *ApplicationSuite) Test_UploadAppImage_UpdateFails_KeepsExistingImage() {
487+
existingImageName := "existing.png"
488+
s.db.User(5)
489+
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})
490+
fakeImage(s.T(), s.imageDir.Path(existingImageName))
491+
s.a.DB = &failingUpdateAppDB{ApplicationDatabase: s.db}
492+
493+
cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
494+
require.NoError(s.T(), err)
495+
s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
496+
s.ctx.Request.Header.Set("Content-Type", cType)
497+
test.WithUser(s.ctx, 5)
498+
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
499+
500+
s.a.UploadApplicationImage(s.ctx)
501+
502+
assert.Equal(s.T(), 500, s.recorder.Code)
503+
app, err := s.db.GetApplicationByID(1)
504+
require.NoError(s.T(), err)
505+
assert.Equal(s.T(), existingImageName, app.Image)
506+
listing, err := os.ReadDir(s.imageDir.Path())
507+
require.NoError(s.T(), err)
508+
require.Len(s.T(), listing, 1)
509+
assert.Equal(s.T(), existingImageName, listing[0].Name())
510+
}
511+
512+
func (s *ApplicationSuite) Test_WriteImage_RemovesPartialFileOnError() {
513+
dst := s.imageDir.Path("partial.png")
514+
src := io.MultiReader(strings.NewReader("partial image data"), errReader{errors.New("connection reset")})
515+
516+
err := writeImage(dst, src)
517+
518+
assert.EqualError(s.T(), err, "connection reset")
519+
_, err = os.Stat(dst)
520+
assert.True(s.T(), os.IsNotExist(err), "the partially written file must be removed")
521+
}
522+
486523
func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() {
487524
s.db.User(5).App(1)
488525

@@ -762,3 +799,16 @@ func fakeImage(t *testing.T, path string) {
762799
err = os.WriteFile(path, data, 0o644)
763800
assert.Nil(t, err)
764801
}
802+
803+
// failingUpdateAppDB fails every UpdateApplication call.
804+
type failingUpdateAppDB struct {
805+
ApplicationDatabase
806+
}
807+
808+
func (d *failingUpdateAppDB) UpdateApplication(*model.Application) error {
809+
return errors.New("update failed")
810+
}
811+
812+
type errReader struct{ err error }
813+
814+
func (r errReader) Read([]byte) (int, error) { return 0, r.err }

0 commit comments

Comments
 (0)