Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import (

"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/database"
"github.com/gotify/server/v2/model"
"github.com/h2non/filetype"
"gorm.io/gorm"
)

// The ApplicationDatabase interface for encapsulating database access.
type ApplicationDatabase interface {
CreateApplication(application *model.Application) error
CreateApplication(application *model.Application, quota uint32) error
GetApplicationByToken(token string) (*model.Application, error)
GetApplicationByID(id uint) (*model.Application, error)
GetApplicationsByUser(userID uint) ([]*model.Application, error)
Expand All @@ -29,6 +30,7 @@ type ApplicationDatabase interface {
type ApplicationAPI struct {
DB ApplicationDatabase
ImageDir string
Quota uint32
}

// Application Params Model
Expand Down Expand Up @@ -56,6 +58,10 @@ type ApplicationParams struct {
SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"`
}

func (p *ApplicationParams) EffectiveSize() uint64 {
return uint64(len(p.Name)) + uint64(len(p.Description)) + uint64(len(p.SortKey))
}

// CreateApplication creates an application and returns the access token.
// swagger:operation POST /application application createApp
//
Expand Down Expand Up @@ -89,9 +95,17 @@ type ApplicationParams struct {
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 422:
// description: Unprocessable Entity
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
applicationParams := ApplicationParams{}
if err := ctx.Bind(&applicationParams); err == nil {
if applicationParams.EffectiveSize() > MaxApplicationClientEntrySize {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("application entry too large (max: %d bytes)", MaxApplicationClientEntrySize))
return
}
app := model.Application{
Name: applicationParams.Name,
Description: applicationParams.Description,
Expand All @@ -102,7 +116,7 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
Internal: false,
}

if err := a.DB.CreateApplication(&app); err != nil {
if err := a.DB.CreateApplication(&app, a.Quota); err != nil {
handleApplicationError(ctx, err)
return
}
Expand Down Expand Up @@ -247,6 +261,10 @@ func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 422:
// description: Unprocessable Entity
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
app, err := a.DB.GetApplicationByID(id)
Expand All @@ -256,6 +274,10 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
if app != nil && app.UserID == auth.GetUserID(ctx) {
applicationParams := ApplicationParams{}
if err := ctx.Bind(&applicationParams); err == nil {
if applicationParams.EffectiveSize() > MaxApplicationClientEntrySize {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("application entry too large (max: %d bytes)", MaxApplicationClientEntrySize))
return
}
app.Description = applicationParams.Description
app.Name = applicationParams.Name
app.DefaultPriority = applicationParams.DefaultPriority
Expand Down Expand Up @@ -329,6 +351,17 @@ func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
// https://gin-gonic.com/en/docs/routing/upload-file/limit-bytes/
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, MaxUploadSize)
if err := ctx.Request.ParseMultipartForm(MaxUploadSize); err != nil {
if _, ok := err.(*http.MaxBytesError); ok {
ctx.AbortWithError(http.StatusRequestEntityTooLarge, fmt.Errorf("file too large (max: %d bytes)", MaxUploadSize))
return
}
ctx.AbortWithError(http.StatusBadRequest, err)
return
}

file, err := ctx.FormFile("file")
if err == http.ErrMissingFile {
ctx.AbortWithError(400, errors.New("file with key 'file' must be present"))
Expand Down Expand Up @@ -483,6 +516,8 @@ func ValidApplicationImageExt(ext string) bool {
func handleApplicationError(ctx *gin.Context, err error) {
if errors.Is(err, gorm.ErrDuplicatedKey) {
ctx.AbortWithError(400, errors.New("sort key is not unique"))
} else if errors.Is(err, database.ErrQuotaExceeded) {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("quota exceeded"))
} else {
ctx.AbortWithError(500, err)
}
Expand Down
75 changes: 69 additions & 6 deletions api/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
Expand Down Expand Up @@ -55,7 +56,7 @@ func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
s.db = testdb.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.a = &ApplicationAPI{DB: s.db}
s.a = &ApplicationAPI{DB: s.db, Quota: 128}
}

func (s *ApplicationSuite) AfterTest(suiteName, testName string) {
Expand Down Expand Up @@ -172,6 +173,29 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {
}
}

func (s *ApplicationSuite) Test_CreateApplication_tooBigApplicationEntry_expectBadRequest() {
s.db.User(5)

test.WithUser(s.ctx, 5)
s.withFormData(fmt.Sprintf("name=%s", strings.Repeat("a", MaxApplicationClientEntrySize+1)))
s.a.CreateApplication(s.ctx)

assert.Equal(s.T(), http.StatusUnprocessableEntity, s.recorder.Code)
}

func (s *ApplicationSuite) Test_CreateApplication_quotaExceeded_expectBadRequest() {
user := s.db.User(5)
for i := uint(0); i < uint(s.a.Quota); i++ {
user.AppWithToken(100+i, fmt.Sprintf("app%d", i))
}

test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateApplication(s.ctx)

assert.Equal(s.T(), http.StatusUnprocessableEntity, s.recorder.Code)
}

func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {
s.db.User(5)

Expand Down Expand Up @@ -336,6 +360,46 @@ func (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest(
assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file with key 'file' must be present"))
}

func (s *ApplicationSuite) Test_UploadAppImage_FileTooLarge_expectBadRequest() {
s.db.User(5).App(1)
testImageFileData, err := os.ReadFile("../test/assets/image.png")
assert.Nil(s.T(), err)

tempFile, err := os.CreateTemp("", "test-image-*.png")
assert.Nil(s.T(), err)
defer os.Remove(tempFile.Name())
totalSize := 0
for totalSize <= MaxUploadSize {
_, err := tempFile.Write(testImageFileData)
assert.Nil(s.T(), err)
totalSize += len(testImageFileData)
}
_, err = tempFile.Seek(0, io.SeekStart)
assert.Nil(s.T(), err)

cType, buffer, err := upload(map[string]*os.File{"file": tempFile})
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)

if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
imgName := app.Image

assert.Equal(s.T(), http.StatusRequestEntityTooLarge, s.recorder.Code)
_, err = os.Stat(imgName)
assert.True(s.T(), os.IsNotExist(err))

s.a.DeleteApplication(s.ctx)

_, err = os.Stat(imgName)
assert.True(s.T(), os.IsNotExist(err))
}
}

func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() {
s.db.User(5).App(1)
var b bytes.Buffer
Expand All @@ -349,8 +413,7 @@ func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() {

s.a.UploadApplicationImage(s.ctx)

assert.Equal(s.T(), 500, s.recorder.Code)
assert.Error(s.T(), s.ctx.Errors[0].Err, "multipart: NextPart: EOF")
assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() {
Expand Down Expand Up @@ -384,7 +447,7 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageA
firstGeneratedImageName := firstApplicationToken[1:] + ".png"
secondGeneratedImageName := secondApplicationToken[1:] + ".png"
s.db.User(5)
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName}, 0)

cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
assert.Nil(s.T(), err)
Expand All @@ -410,7 +473,7 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageA

func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() {
s.db.User(5)
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"})
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"}, 0)

fakeImage(s.T(), "existing.png")
cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
Expand Down Expand Up @@ -501,7 +564,7 @@ func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() {
s.db.User(5)

imageFile := "existing.png"
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile})
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile}, 0)
fakeImage(s.T(), imageFile)

test.WithUser(s.ctx, 5)
Expand Down
31 changes: 29 additions & 2 deletions api/client.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package api

import (
"errors"
"fmt"
"net/http"

"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/database"
"github.com/gotify/server/v2/model"
)

// The ClientDatabase interface for encapsulating database access.
type ClientDatabase interface {
CreateClient(client *model.Client) error
CreateClient(client *model.Client, quota uint32) error
GetClientByToken(token string) (*model.Client, error)
GetClientByID(id uint) (*model.Client, error)
GetClientsByUser(userID uint) ([]*model.Client, error)
Expand All @@ -23,6 +26,7 @@ type ClientAPI struct {
DB ClientDatabase
ImageDir string
NotifyDeleted func(uint, string)
Quota uint32
}

// Client Params Model
Expand All @@ -38,6 +42,10 @@ type ClientParams struct {
Name string `form:"name" query:"name" json:"name" binding:"required"`
}

func (p *ClientParams) EffectiveSize() uint64 {
return uint64(len(p.Name))
}

// UpdateClient updates a client by its id.
// swagger:operation PUT /client/{id} client updateClient
//
Expand Down Expand Up @@ -90,6 +98,10 @@ func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
if client != nil && client.UserID == auth.GetUserID(ctx) {
newValues := ClientParams{}
if err := ctx.Bind(&newValues); err == nil {
if newValues.EffectiveSize() > MaxApplicationClientEntrySize {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("client entry too large (max: %d bytes)", MaxApplicationClientEntrySize))
return
}
client.Name = newValues.Name

if success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success {
Expand Down Expand Up @@ -136,18 +148,33 @@ func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 422:
// description: Unprocessable Entity
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) CreateClient(ctx *gin.Context) {
clientParams := ClientParams{}
if err := ctx.Bind(&clientParams); err == nil {
if clientParams.EffectiveSize() > MaxApplicationClientEntrySize {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("client entry too large (max: %d bytes)", MaxApplicationClientEntrySize))
return
}
client := model.Client{
Name: clientParams.Name,
Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists),
UserID: auth.GetUserID(ctx),
}

if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success {
dbErr := a.DB.CreateClient(&client, a.Quota)
if dbErr != nil {
if errors.Is(dbErr, database.ErrQuotaExceeded) {
ctx.AbortWithError(http.StatusUnprocessableEntity, fmt.Errorf("quota exceeded"))
return
}
ctx.AbortWithError(500, dbErr)
return
}

ctx.JSON(200, client)
}
}
Expand Down
37 changes: 36 additions & 1 deletion api/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package api

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
Expand Down Expand Up @@ -44,7 +46,7 @@ func (s *ClientSuite) BeforeTest(suiteName, testName string) {
s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.notified = false
s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify}
s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify, Quota: 128}
}

func (s *ClientSuite) notify(uint, string) {
Expand Down Expand Up @@ -145,6 +147,39 @@ func (s *ClientSuite) Test_CreateClient_withExistingToken() {
test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *ClientSuite) Test_CreateClient_quotaRotationWorks() {
user := s.db.User(5)
for i := uint(0); i < uint(s.a.Quota); i++ {
user.ClientWithToken(100+i, fmt.Sprintf("client%d", i))
}

test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")

baseCount, err := s.db.CountClientsByUserID(s.db.DB, 5)
assert.NoError(s.T(), err)

s.a.CreateClient(s.ctx)

userCount, err := s.db.CountClientsByUserID(s.db.DB, 5)
assert.NoError(s.T(), err)

assert.Equal(s.T(), baseCount, userCount)

assert.Equal(s.T(), 200, s.recorder.Code)
}

func (s *ClientSuite) Test_CreateClient_rejectTooLargeClient() {
s.db.User(5)

test.WithUser(s.ctx, 5)
s.withFormData(fmt.Sprintf("name=%s", strings.Repeat("a", MaxApplicationClientEntrySize+1)))

s.a.CreateClient(s.ctx)

assert.Equal(s.T(), http.StatusUnprocessableEntity, s.recorder.Code)
}

func (s *ClientSuite) Test_GetClients() {
userBuilder := s.db.User(5)
first := userBuilder.NewClientWithToken(1, "perfper")
Expand Down
10 changes: 10 additions & 0 deletions api/limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package api

const (
// Max size for a client or application entry.
MaxApplicationClientEntrySize = 64 << 10
// Maximum upload size of 32MB for blobs and files.
MaxUploadSize = 32 << 20
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have the different limits?

// Catch-all request body limit of 64MB enforced at middleware level.
MaxBodySize = 64 << 20
)
Loading
Loading