From 61d5fc59a7447f1e4b9d00d34364c122b156f1b3 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 30 Mar 2018 19:25:30 +0200 Subject: [PATCH] Add UploadApplicationImage API --- Gopkg.lock | 30 +++++-- api/token.go | 68 +++++++++++++- api/token_test.go | 166 ++++++++++++++++++++++++++++++++++- auth/token.go | 13 ++- auth/token_test.go | 1 + database/application.go | 5 ++ database/application_test.go | 6 ++ test/assets/image.png | Bin 0 -> 113 bytes test/assets/text.txt | 0 9 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 test/assets/image.png create mode 100644 test/assets/text.txt diff --git a/Gopkg.lock b/Gopkg.lock index a109253..a2e7114 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -66,6 +66,18 @@ revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" version = "v1.2.0" +[[projects]] + branch = "master" + name = "github.com/gotify/location" + packages = ["."] + revision = "03bc4ad20437a145024eaac6d47b82ef0979e738" + +[[projects]] + name = "github.com/h2non/filetype" + packages = ["."] + revision = "cc14fdc9ca0e4c2bafad7458f6ff79fd3947cfbb" + version = "v1.0.5" + [[projects]] branch = "master" name = "github.com/jinzhu/configor" @@ -134,17 +146,10 @@ revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" -[[projects]] - name = "github.com/stretchr/objx" - packages = ["."] - revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" - version = "v0.1" - [[projects]] name = "github.com/stretchr/testify" packages = [ "assert", - "mock", "require", "suite" ] @@ -180,6 +185,15 @@ revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" version = "v8.18.2" +[[projects]] + name = "gopkg.in/h2non/filetype.v1" + packages = [ + "matchers", + "types" + ] + revision = "cc14fdc9ca0e4c2bafad7458f6ff79fd3947cfbb" + version = "v1.0.5" + [[projects]] branch = "v2" name = "gopkg.in/yaml.v2" @@ -189,6 +203,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "c3c460b8861fdd2b2e1ebd3b9eafda81016511f59d0610308231f41d4111e687" + inputs-digest = "272b085d12075d5deae7a7eac7472448f76f798644d80b17a4c6a4d5c7a8cfe9" solver-name = "gps-cdcl" solver-version = 1 diff --git a/api/token.go b/api/token.go index e9c3969..a8ac8d7 100644 --- a/api/token.go +++ b/api/token.go @@ -3,9 +3,15 @@ package api import ( "fmt" + "errors" + "net/http" + "path/filepath" + "github.com/gin-gonic/gin" + "github.com/gotify/location" "github.com/gotify/server/auth" "github.com/gotify/server/model" + "github.com/h2non/filetype" ) // The TokenDatabase interface for encapsulating database access. @@ -15,6 +21,7 @@ type TokenDatabase interface { GetApplicationByID(id uint) *model.Application GetApplicationsByUser(userID uint) []*model.Application DeleteApplicationByID(id uint) error + UpdateApplication(application *model.Application) CreateClient(client *model.Client) error GetClientByToken(token string) *model.Client @@ -25,7 +32,8 @@ type TokenDatabase interface { // The TokenAPI provides handlers for managing clients and applications. type TokenAPI struct { - DB TokenDatabase + DB TokenDatabase + ImageDir string } // CreateApplication creates an application and returns the access token. @@ -35,7 +43,7 @@ func (a *TokenAPI) CreateApplication(ctx *gin.Context) { app.Token = generateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists) app.UserID = auth.GetUserID(ctx) a.DB.CreateApplication(&app) - ctx.JSON(200, app) + ctx.JSON(200, withAbsoluteURL(ctx, &app)) } } @@ -54,14 +62,17 @@ func (a *TokenAPI) CreateClient(ctx *gin.Context) { func (a *TokenAPI) GetApplications(ctx *gin.Context) { userID := auth.GetUserID(ctx) apps := a.DB.GetApplicationsByUser(userID) + for _, app := range apps { + withAbsoluteURL(ctx, app) + } ctx.JSON(200, apps) } // GetClients returns all clients a user has. func (a *TokenAPI) GetClients(ctx *gin.Context) { userID := auth.GetUserID(ctx) - apps := a.DB.GetClientsByUser(userID) - ctx.JSON(200, apps) + clients := a.DB.GetClientsByUser(userID) + ctx.JSON(200, clients) } // DeleteApplication deletes an application by its id. @@ -86,6 +97,55 @@ func (a *TokenAPI) DeleteClient(ctx *gin.Context) { }) } +// UploadApplicationImage uploads an image for an application. +func (a *TokenAPI) UploadApplicationImage(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { + file, err := ctx.FormFile("file") + if err == http.ErrMissingFile { + ctx.AbortWithError(400, errors.New("file with key 'file' must be present")) + return + } else if err != nil { + ctx.AbortWithError(500, err) + return + } + head := make([]byte, 261) + open, _ := file.Open() + open.Read(head) + if !filetype.IsImage(head) { + ctx.AbortWithError(400, errors.New("file must be an image")) + return + } + + name := auth.GenerateImageName() + ext := filepath.Ext(file.Filename) + err = ctx.SaveUploadedFile(file, a.ImageDir+name+ext) + if err != nil { + ctx.AbortWithError(500, err) + return + } + + app.Image = name + ext + a.DB.UpdateApplication(app) + ctx.JSON(200, withAbsoluteURL(ctx, app)) + } else { + ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) + } + }) +} + +func withAbsoluteURL(ctx *gin.Context, app *model.Application) *model.Application { + url := location.Get(ctx) + + if app.Image == "" { + url.Path = "static/defaultapp.png" + } else { + url.Path = "image/" + app.Image + } + app.Image = url.String() + return app +} + func (a *TokenAPI) applicationExists(token string) bool { return a.DB.GetApplicationByToken(token) != nil } diff --git a/api/token_test.go b/api/token_test.go index 3cb23b5..243017a 100644 --- a/api/token_test.go +++ b/api/token_test.go @@ -7,6 +7,15 @@ import ( "strings" + "bytes" + "errors" + "io" + "mime/multipart" + "net/url" + "os" + "reflect" + + "github.com/bouk/monkey" "github.com/gin-gonic/gin" "github.com/gotify/server/mode" "github.com/gotify/server/model" @@ -40,6 +49,7 @@ func (s *TokenSuite) BeforeTest(suiteName, testName string) { s.recorder = httptest.NewRecorder() s.db = test.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) + withURL(s.ctx, "http", "example.com") s.a = &TokenAPI{DB: s.db} } @@ -98,8 +108,8 @@ func (s *TokenSuite) Test_CreateApplication_onlyRequiredParameters() { assert.Contains(s.T(), s.db.GetApplicationsByUser(5), expected) } func (s *TokenSuite) Test_ensureApplicationHasCorrectJsonRepresentation() { - actual := &model.Application{ID: 1, UserID: 2, Token: "Aasdasfgeeg", Name: "myapp", Description: "mydesc"} - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc"}`) + actual := &model.Application{ID: 1, UserID: 2, Token: "Aasdasfgeeg", Name: "myapp", Description: "mydesc", Image: "asd"} + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd"}`) } func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { @@ -110,7 +120,7 @@ func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5} + expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", Image: "http://example.com/static/defaultapp.png", UserID: 5} assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } @@ -140,6 +150,26 @@ func (s *TokenSuite) Test_GetApplications() { s.a.GetApplications(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) + first.Image = "http://example.com/static/defaultapp.png" + second.Image = "http://example.com/static/defaultapp.png" + test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) +} + +func (s *TokenSuite) Test_GetApplications_WithImage() { + userBuilder := s.db.User(5) + first := userBuilder.NewAppWithToken(1, "perfper") + second := userBuilder.NewAppWithToken(2, "asdasd") + first.Image = "abcd.jpg" + s.db.UpdateApplication(first) + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) + + s.a.GetApplications(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + first.Image = "http://example.com/image/abcd.jpg" + second.Image = "http://example.com/static/defaultapp.png" test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } @@ -280,7 +310,137 @@ func (s *TokenSuite) Test_DeleteClient() { s.db.AssertClientNotExist(8) } +func (s *TokenSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() { + s.db.User(5).App(1) + var b bytes.Buffer + writer := multipart.NewWriter(&b) + writer.Close() + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b) + s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file with key 'file' must be present")) +} + +func (s *TokenSuite) Test_UploadAppImage_OtherErrors_expectServerError() { + s.db.User(5).App(1) + var b bytes.Buffer + writer := multipart.NewWriter(&b) + defer writer.Close() + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b) + s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) + assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("multipart: NextPart: EOF")) +} + +func (s *TokenSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { + s.db.User(5).App(1) + + cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) + assert.Nil(s.T(), err) + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) + s.ctx.Request.Header.Set("Content-Type", cType) + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + _, err = os.Stat("PorrUa5b1IIK3yKo_Pp6ww_9v.png") + assert.Nil(s.T(), err) + assert.Nil(s.T(), os.Remove("PorrUa5b1IIK3yKo_Pp6ww_9v.png")) +} + +func (s *TokenSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { + s.db.User(5).App(1) + + cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/text.txt")}) + assert.Nil(s.T(), err) + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) + s.ctx.Request.Header.Set("Content-Type", cType) + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file must be an image")) +} + +func (s *TokenSuite) Test_UploadAppImage_expectNotFound() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *TokenSuite) Test_UploadAppImage_WithSaveError_expectServerError() { + s.db.User(5).App(1) + + cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) + assert.Nil(s.T(), err) + s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) + s.ctx.Request.Header.Set("Content-Type", cType) + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + // try emulate a save error. + patch := monkey.PatchInstanceMethod(reflect.TypeOf(s.ctx), "SaveUploadedFile", func(ctx *gin.Context, file *multipart.FileHeader, dst string) error { + return errors.New("could not do something") + }) + defer patch.Unpatch() + + s.a.UploadApplicationImage(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) +} + func (s *TokenSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } + +func withURL(ctx *gin.Context, scheme, host string) { + ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) +} + +// A modified version of https://stackoverflow.com/a/20397167/4244993 from Attila O. +func upload(values map[string]*os.File) (contentType string, buffer bytes.Buffer, err error) { + w := multipart.NewWriter(&buffer) + for key, r := range values { + var fw io.Writer + if fw, err = w.CreateFormFile(key, r.Name()); err != nil { + return + } + + if _, err = io.Copy(fw, r); err != nil { + return + } + } + contentType = w.FormDataContentType() + w.Close() + return +} + +func mustOpen(f string) *os.File { + r, err := os.Open(f) + if err != nil { + panic(err) + } + return r +} diff --git a/auth/token.go b/auth/token.go index eea1761..4812d01 100644 --- a/auth/token.go +++ b/auth/token.go @@ -21,10 +21,19 @@ func GenerateClientToken() string { return generateRandomToken(clientPrefix) } +// GenerateImageName generates an image name. +func GenerateImageName() string { + return generateRandomString(25) +} + func generateRandomToken(prefix string) string { - b := make([]rune, randomTokenLength) + return prefix + generateRandomString(randomTokenLength) +} + +func generateRandomString(length int) string { + b := make([]rune, length) for i := range b { b[i] = tokenCharacters[rand.Intn(len(tokenCharacters))] } - return prefix + string(b) + return string(b) } diff --git a/auth/token_test.go b/auth/token_test.go index 71d7ee0..93817aa 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -11,5 +11,6 @@ func TestTokenHavePrefix(t *testing.T) { for i := 0; i < 50; i++ { assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) + assert.NotEmpty(t, GenerateImageName()) } } diff --git a/database/application.go b/database/application.go index 1291f13..213ef7b 100644 --- a/database/application.go +++ b/database/application.go @@ -40,3 +40,8 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) []*model.Application { d.DB.Where("user_id = ?", userID).Find(&apps) return apps } + +// UpdateApplication updates an application. +func (d *GormDatabase) UpdateApplication(app *model.Application) { + d.DB.Save(app) +} diff --git a/database/application_test.go b/database/application_test.go index 58a775c..b25cfcb 100644 --- a/database/application_test.go +++ b/database/application_test.go @@ -29,6 +29,12 @@ func (s *DatabaseSuite) TestApplication() { newApp = s.db.GetApplicationByID(app.ID) assert.Equal(s.T(), app, newApp) + newApp.Image = "asdasd" + s.db.UpdateApplication(newApp) + + newApp = s.db.GetApplicationByID(app.ID) + assert.Equal(s.T(), "asdasd", newApp.Image) + s.db.DeleteApplicationByID(app.ID) apps = s.db.GetApplicationsByUser(user.ID) diff --git a/test/assets/image.png b/test/assets/image.png new file mode 100644 index 0000000000000000000000000000000000000000..2bd2689d0c43ff7b05825d529385916e040ca4a8 GIT binary patch literal 113 zcmeAS@N?(olHy`uVBq!ia0vp@K+MO%1|+}KPrC%97>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`03d%8G=Xapz!`1kmFebC1hY8rA^S&lYGU0(m6q50Br{{2Ubj{uc1c)I$z JtaD0e0suaJBMtxn literal 0 HcmV?d00001 diff --git a/test/assets/text.txt b/test/assets/text.txt new file mode 100644 index 0000000..e69de29