Add UploadApplicationImage API

This commit is contained in:
Jannis Mattheis 2018-03-30 19:25:30 +02:00 committed by Jannis Mattheis
parent dfe242b0cd
commit 61d5fc59a7
9 changed files with 272 additions and 17 deletions

30
Gopkg.lock generated
View File

@ -66,6 +66,18 @@
revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b"
version = "v1.2.0" 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]] [[projects]]
branch = "master" branch = "master"
name = "github.com/jinzhu/configor" name = "github.com/jinzhu/configor"
@ -134,17 +146,10 @@
revision = "792786c7400a136282c1664665ae0a8db921c6c2" revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0" version = "v1.0.0"
[[projects]]
name = "github.com/stretchr/objx"
packages = ["."]
revision = "facf9a85c22f48d2f52f2380e4efce1768749a89"
version = "v0.1"
[[projects]] [[projects]]
name = "github.com/stretchr/testify" name = "github.com/stretchr/testify"
packages = [ packages = [
"assert", "assert",
"mock",
"require", "require",
"suite" "suite"
] ]
@ -180,6 +185,15 @@
revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf"
version = "v8.18.2" version = "v8.18.2"
[[projects]]
name = "gopkg.in/h2non/filetype.v1"
packages = [
"matchers",
"types"
]
revision = "cc14fdc9ca0e4c2bafad7458f6ff79fd3947cfbb"
version = "v1.0.5"
[[projects]] [[projects]]
branch = "v2" branch = "v2"
name = "gopkg.in/yaml.v2" name = "gopkg.in/yaml.v2"
@ -189,6 +203,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "c3c460b8861fdd2b2e1ebd3b9eafda81016511f59d0610308231f41d4111e687" inputs-digest = "272b085d12075d5deae7a7eac7472448f76f798644d80b17a4c6a4d5c7a8cfe9"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -3,9 +3,15 @@ package api
import ( import (
"fmt" "fmt"
"errors"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gotify/location"
"github.com/gotify/server/auth" "github.com/gotify/server/auth"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/h2non/filetype"
) )
// The TokenDatabase interface for encapsulating database access. // The TokenDatabase interface for encapsulating database access.
@ -15,6 +21,7 @@ type TokenDatabase interface {
GetApplicationByID(id uint) *model.Application GetApplicationByID(id uint) *model.Application
GetApplicationsByUser(userID uint) []*model.Application GetApplicationsByUser(userID uint) []*model.Application
DeleteApplicationByID(id uint) error DeleteApplicationByID(id uint) error
UpdateApplication(application *model.Application)
CreateClient(client *model.Client) error CreateClient(client *model.Client) error
GetClientByToken(token string) *model.Client GetClientByToken(token string) *model.Client
@ -26,6 +33,7 @@ type TokenDatabase interface {
// The TokenAPI provides handlers for managing clients and applications. // The TokenAPI provides handlers for managing clients and applications.
type TokenAPI struct { type TokenAPI struct {
DB TokenDatabase DB TokenDatabase
ImageDir string
} }
// CreateApplication creates an application and returns the access token. // 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.Token = generateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists)
app.UserID = auth.GetUserID(ctx) app.UserID = auth.GetUserID(ctx)
a.DB.CreateApplication(&app) 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) { func (a *TokenAPI) GetApplications(ctx *gin.Context) {
userID := auth.GetUserID(ctx) userID := auth.GetUserID(ctx)
apps := a.DB.GetApplicationsByUser(userID) apps := a.DB.GetApplicationsByUser(userID)
for _, app := range apps {
withAbsoluteURL(ctx, app)
}
ctx.JSON(200, apps) ctx.JSON(200, apps)
} }
// GetClients returns all clients a user has. // GetClients returns all clients a user has.
func (a *TokenAPI) GetClients(ctx *gin.Context) { func (a *TokenAPI) GetClients(ctx *gin.Context) {
userID := auth.GetUserID(ctx) userID := auth.GetUserID(ctx)
apps := a.DB.GetClientsByUser(userID) clients := a.DB.GetClientsByUser(userID)
ctx.JSON(200, apps) ctx.JSON(200, clients)
} }
// DeleteApplication deletes an application by its id. // 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 { func (a *TokenAPI) applicationExists(token string) bool {
return a.DB.GetApplicationByToken(token) != nil return a.DB.GetApplicationByToken(token) != nil
} }

View File

@ -7,6 +7,15 @@ import (
"strings" "strings"
"bytes"
"errors"
"io"
"mime/multipart"
"net/url"
"os"
"reflect"
"github.com/bouk/monkey"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
@ -40,6 +49,7 @@ func (s *TokenSuite) BeforeTest(suiteName, testName string) {
s.recorder = httptest.NewRecorder() s.recorder = httptest.NewRecorder()
s.db = test.NewDB(s.T()) s.db = test.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.a = &TokenAPI{DB: s.db} 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) assert.Contains(s.T(), s.db.GetApplicationsByUser(5), expected)
} }
func (s *TokenSuite) Test_ensureApplicationHasCorrectJsonRepresentation() { func (s *TokenSuite) Test_ensureApplicationHasCorrectJsonRepresentation() {
actual := &model.Application{ID: 1, UserID: 2, 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"}`) test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd"}`)
} }
func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() {
@ -110,7 +120,7 @@ func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() {
s.a.CreateApplication(s.ctx) 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) assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), expected, s.recorder) test.BodyEquals(s.T(), expected, s.recorder)
} }
@ -140,6 +150,26 @@ func (s *TokenSuite) Test_GetApplications() {
s.a.GetApplications(s.ctx) s.a.GetApplications(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code) 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) test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)
} }
@ -280,7 +310,137 @@ func (s *TokenSuite) Test_DeleteClient() {
s.db.AssertClientNotExist(8) 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) { func (s *TokenSuite) withFormData(formData string) {
s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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
}

View File

@ -21,10 +21,19 @@ func GenerateClientToken() string {
return generateRandomToken(clientPrefix) return generateRandomToken(clientPrefix)
} }
// GenerateImageName generates an image name.
func GenerateImageName() string {
return generateRandomString(25)
}
func generateRandomToken(prefix string) string { 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 { for i := range b {
b[i] = tokenCharacters[rand.Intn(len(tokenCharacters))] b[i] = tokenCharacters[rand.Intn(len(tokenCharacters))]
} }
return prefix + string(b) return string(b)
} }

View File

@ -11,5 +11,6 @@ func TestTokenHavePrefix(t *testing.T) {
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A"))
assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) assert.True(t, strings.HasPrefix(GenerateClientToken(), "C"))
assert.NotEmpty(t, GenerateImageName())
} }
} }

View File

@ -40,3 +40,8 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) []*model.Application {
d.DB.Where("user_id = ?", userID).Find(&apps) d.DB.Where("user_id = ?", userID).Find(&apps)
return apps return apps
} }
// UpdateApplication updates an application.
func (d *GormDatabase) UpdateApplication(app *model.Application) {
d.DB.Save(app)
}

View File

@ -29,6 +29,12 @@ func (s *DatabaseSuite) TestApplication() {
newApp = s.db.GetApplicationByID(app.ID) newApp = s.db.GetApplicationByID(app.ID)
assert.Equal(s.T(), app, newApp) 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) s.db.DeleteApplicationByID(app.ID)
apps = s.db.GetApplicationsByUser(user.ID) apps = s.db.GetApplicationsByUser(user.ID)

BIN
test/assets/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

0
test/assets/text.txt Normal file
View File