Skip to content
Merged
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
23 changes: 21 additions & 2 deletions api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/model"
"github.com/h2non/filetype"
"gorm.io/gorm"
)

// The ApplicationDatabase interface for encapsulating database access.
Expand Down Expand Up @@ -49,6 +50,10 @@ type ApplicationParams struct {
//
// example: 5
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
// The sortKey for the application. Uses fractional indexing.
//
// example: a1
SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"`
}

// CreateApplication creates an application and returns the access token.
Expand Down Expand Up @@ -91,12 +96,14 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
Name: applicationParams.Name,
Description: applicationParams.Description,
DefaultPriority: applicationParams.DefaultPriority,
SortKey: applicationParams.SortKey,
Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists),
UserID: auth.GetUserID(ctx),
Internal: false,
}

if success := successOrAbort(ctx, 500, a.DB.CreateApplication(&app)); !success {
if err := a.DB.CreateApplication(&app); err != nil {
handleApplicationError(ctx, err)
return
}
ctx.JSON(200, withResolvedImage(&app))
Expand Down Expand Up @@ -252,8 +259,12 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
app.Description = applicationParams.Description
app.Name = applicationParams.Name
app.DefaultPriority = applicationParams.DefaultPriority
if applicationParams.SortKey != "" {
app.SortKey = applicationParams.SortKey
}

if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
if err := a.DB.UpdateApplication(app); err != nil {
handleApplicationError(ctx, err)
return
}
ctx.JSON(200, withResolvedImage(app))
Expand Down Expand Up @@ -468,3 +479,11 @@ func ValidApplicationImageExt(ext string) bool {
return false
}
}

func handleApplicationError(ctx *gin.Context, err error) {
if errors.Is(err, gorm.ErrDuplicatedKey) {
ctx.AbortWithError(400, errors.New("sort key is not unique"))
} else {
ctx.AbortWithError(500, err)
}
}
92 changes: 80 additions & 12 deletions api/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http/httptest"
Expand All @@ -17,12 +18,14 @@ import (
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

var (
firstApplicationToken = "Aaaaaaaaaaaaaaa"
secondApplicationToken = "Abbbbbbbbbbbbbb"
thirdApplicationToken = "Acccccccccccccc"
)

func TestApplicationSuite(t *testing.T) {
Expand All @@ -45,8 +48,8 @@ var (
func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
originalGenerateApplicationToken = generateApplicationToken
originalGenerateImageName = generateImageName
generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken)
generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:])
generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken)
generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:])
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.db = testdb.NewDB(s.T())
Expand All @@ -65,7 +68,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
s.db.User(5)

test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name&description=description_text")
s.withFormData("name=custom_name&description=description_text&sortKey=a5")
s.a.CreateApplication(s.ctx)

expected := &model.Application{
Expand All @@ -74,6 +77,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
UserID: 5,
Name: "custom_name",
Description: "description_text",
SortKey: "a5",
}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
Expand All @@ -91,8 +95,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
Image: "asd",
Internal: true,
LastUsed: nil,
SortKey: "a1",
}
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`)
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`)
}

func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
Expand All @@ -119,6 +124,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar
Internal: true,
Token: "token",
Image: "adfdf",
SortKey: "a5",
})

s.a.CreateApplication(s.ctx)
Expand All @@ -131,6 +137,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar
Description: "description",
Internal: false,
Image: "static/defaultapp.png",
SortKey: "a5",
})

assert.Equal(s.T(), 200, s.recorder.Code)
Expand Down Expand Up @@ -158,7 +165,7 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {
s.withFormData("name=custom_name")
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", UserID: 5, SortKey: "a0"}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
assert.Contains(s.T(), app, expected)
Expand All @@ -174,11 +181,12 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {
s.a.CreateApplication(s.ctx)

expected := &model.Application{
ID: 1,
Token: firstApplicationToken,
Name: "custom_name",
Image: "static/defaultapp.png",
UserID: 5,
ID: 1,
Token: firstApplicationToken,
Name: "custom_name",
Image: "static/defaultapp.png",
UserID: 5,
SortKey: "a0",
}
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), expected, s.recorder)
Expand All @@ -193,13 +201,53 @@ func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {

s.a.CreateApplication(s.ctx)

expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5}
expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
assert.Contains(s.T(), app, expected)
}
}

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

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

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

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

apps, err := s.db.GetApplicationsByUser(5)
require.NoError(s.T(), err)
require.Len(s.T(), apps, 3)
assert.Equal(s.T(), apps[0].Name, "one")
assert.Equal(s.T(), apps[0].SortKey, "a0")
assert.Equal(s.T(), apps[1].Name, "two")
assert.Equal(s.T(), apps[1].SortKey, "a1")
assert.Equal(s.T(), apps[2].Name, "three")
assert.Equal(s.T(), apps[2].SortKey, "a2")

s.withFormData("name=one&description=&sortKey=a1V")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(apps[0].ID)}}
s.a.UpdateApplication(s.ctx)

apps, err = s.db.GetApplicationsByUser(5)
require.NoError(s.T(), err)
require.Len(s.T(), apps, 3)
assert.Equal(s.T(), apps[0].Name, "two")
assert.Equal(s.T(), apps[0].SortKey, "a1")
assert.Equal(s.T(), apps[1].Name, "one")
assert.Equal(s.T(), apps[1].SortKey, "a1V")
assert.Equal(s.T(), apps[2].Name, "three")
assert.Equal(s.T(), apps[2].SortKey, "a2")
}

func (s *ApplicationSuite) Test_GetApplications() {
userBuilder := s.db.User(5)
first := userBuilder.NewAppWithToken(1, "perfper")
Expand Down Expand Up @@ -481,6 +529,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSucces
UserID: 5,
Name: "new_name",
Description: "new_description_text",
SortKey: "a0",
}

assert.Equal(s.T(), 200, s.recorder.Code)
Expand All @@ -503,6 +552,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {
UserID: 5,
Name: "new_name",
Description: "",
SortKey: "a0",
}

assert.Equal(s.T(), 200, s.recorder.Code)
Expand All @@ -526,6 +576,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess()
Name: "name",
Description: "",
DefaultPriority: 4,
SortKey: "a0",
}

assert.Equal(s.T(), 200, s.recorder.Code)
Expand All @@ -534,9 +585,10 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess()
}
}

func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() {
func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() {
app := s.db.User(5).NewAppWithToken(2, "app-2")
app.Image = "existing.png"
app.SortKey = "a5"
assert.Nil(s.T(), s.db.UpdateApplication(app))

test.WithUser(s.ctx, 5)
Expand All @@ -548,6 +600,7 @@ func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() {
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
assert.Equal(s.T(), "existing.png", app.Image)
assert.Equal(s.T(), "a5", app.SortKey)
}
}

Expand Down Expand Up @@ -594,6 +647,21 @@ func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFou
assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() {
user := s.db.User(5)
user.App(1) // sortKey=a0
user.App(2) // sortKey=a1

s.withFormData("name=new_name&sortKey=a0")
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

s.a.UpdateApplication(s.ctx)

assert.EqualError(s.T(), s.ctx.Errors[0].Err, "sort key is not unique")
assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) 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")
Expand Down
20 changes: 18 additions & 2 deletions database/application.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package database

import (
"database/sql"
"time"

"github.com/gotify/server/v2/fracdex"
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
Expand Down Expand Up @@ -35,7 +37,21 @@ func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) {

// CreateApplication creates an application.
func (d *GormDatabase) CreateApplication(application *model.Application) error {
return d.DB.Create(application).Error
return d.DB.Transaction(func(tx *gorm.DB) error {
if application.SortKey == "" {
sortKey := ""
err := tx.Model(&model.Application{}).Select("sort_key").Where("user_id = ?", application.UserID).Order("sort_key DESC").Limit(1).Find(&sortKey).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
application.SortKey, err = fracdex.KeyBetween(sortKey, "")
if err != nil {
return err
}
}

return tx.Create(application).Error
}, &sql.TxOptions{Isolation: sql.LevelSerializable})
}

// DeleteApplicationByID deletes an application by its id.
Expand All @@ -47,7 +63,7 @@ func (d *GormDatabase) DeleteApplicationByID(id uint) error {
// GetApplicationsByUser returns all applications from a user.
func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) {
var apps []*model.Application
err := d.DB.Where("user_id = ?", userID).Order("id ASC").Find(&apps).Error
err := d.DB.Where("user_id = ?", userID).Order("sort_key, id ASC").Find(&apps).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
Expand Down
Loading
Loading