Repository: gotify/server
Branch: master
Commit: 061053711ffa
Files: 216
Total size: 670.4 KB
Directory structure:
gitextract_5ctt_wna/
├── .dockerignore
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── feature_request.md
│ │ └── questions.md
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .golangci.yml
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── GO_VERSION
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── api/
│ ├── application.go
│ ├── application_test.go
│ ├── client.go
│ ├── client_test.go
│ ├── errorHandling.go
│ ├── errorHandling_test.go
│ ├── health.go
│ ├── health_test.go
│ ├── internalutil.go
│ ├── message.go
│ ├── message_test.go
│ ├── plugin.go
│ ├── plugin_test.go
│ ├── stream/
│ │ ├── client.go
│ │ ├── once.go
│ │ ├── once_test.go
│ │ ├── stream.go
│ │ └── stream_test.go
│ ├── tokens.go
│ ├── tokens_test.go
│ ├── user.go
│ └── user_test.go
├── app.go
├── auth/
│ ├── authentication.go
│ ├── authentication_test.go
│ ├── cors.go
│ ├── cors_test.go
│ ├── password/
│ │ ├── password.go
│ │ └── password_test.go
│ ├── token.go
│ ├── token_test.go
│ ├── util.go
│ └── util_test.go
├── config/
│ ├── config.go
│ └── config_test.go
├── config.example.yml
├── database/
│ ├── application.go
│ ├── application_test.go
│ ├── client.go
│ ├── client_test.go
│ ├── database.go
│ ├── database_test.go
│ ├── message.go
│ ├── message_test.go
│ ├── migration_test.go
│ ├── ping.go
│ ├── ping_test.go
│ ├── plugin.go
│ ├── plugin_test.go
│ ├── user.go
│ └── user_test.go
├── docker/
│ └── Dockerfile
├── docs/
│ ├── package.go
│ ├── spec.json
│ ├── swagger.go
│ ├── swagger_test.go
│ ├── ui.go
│ └── ui_test.go
├── error/
│ ├── handler.go
│ ├── handler_test.go
│ ├── notfound.go
│ └── notfound_test.go
├── fracdex/
│ ├── fracdex.go
│ └── fracdex_test.go
├── go.mod
├── go.sum
├── mode/
│ ├── mode.go
│ └── mode_test.go
├── model/
│ ├── application.go
│ ├── client.go
│ ├── error.go
│ ├── health.go
│ ├── message.go
│ ├── paging.go
│ ├── pluginconf.go
│ ├── user.go
│ └── version.go
├── plugin/
│ ├── compat/
│ │ ├── instance.go
│ │ ├── plugin.go
│ │ ├── plugin_test.go
│ │ ├── v1.go
│ │ ├── v1_test.go
│ │ ├── wrap.go
│ │ ├── wrap_test.go
│ │ ├── wrap_test_norace.go
│ │ └── wrap_test_race.go
│ ├── example/
│ │ ├── clock/
│ │ │ └── main.go
│ │ ├── echo/
│ │ │ └── echo.go
│ │ └── minimal/
│ │ └── main.go
│ ├── manager.go
│ ├── manager_test.go
│ ├── manager_test_norace.go
│ ├── manager_test_race.go
│ ├── messagehandler.go
│ ├── pluginenabled.go
│ ├── pluginenabled_test.go
│ ├── storagehandler.go
│ └── testing/
│ ├── broken/
│ │ ├── cantinstantiate/
│ │ │ └── main.go
│ │ ├── malformedconstructor/
│ │ │ └── main.go
│ │ ├── noinstance/
│ │ │ └── main.go
│ │ ├── nothing/
│ │ │ └── main.go
│ │ └── unknowninfo/
│ │ └── main.go
│ └── mock/
│ └── mock.go
├── renovate.json
├── router/
│ ├── router.go
│ └── router_test.go
├── runner/
│ ├── runner.go
│ ├── umask.go
│ └── umask_fallback.go
├── test/
│ ├── asserts.go
│ ├── asserts_test.go
│ ├── assets/
│ │ ├── image-header-with.html
│ │ └── text.txt
│ ├── auth.go
│ ├── auth_test.go
│ ├── filepath.go
│ ├── filepath_test.go
│ ├── testdb/
│ │ ├── database.go
│ │ └── database_test.go
│ ├── tmpdir.go
│ ├── tmpdir_test.go
│ ├── token.go
│ └── token_test.go
└── ui/
├── .gitignore
├── .prettierrc
├── .yarnrc
├── eslint.config.mjs
├── index.html
├── package.json
├── public/
│ ├── manifest.json
│ └── static/
│ └── notification.ogg
├── serve.go
├── src/
│ ├── CurrentUser.ts
│ ├── apiAuth.ts
│ ├── application/
│ │ ├── AddApplicationDialog.tsx
│ │ ├── AppStore.ts
│ │ ├── Applications.tsx
│ │ └── UpdateApplicationDialog.tsx
│ ├── client/
│ │ ├── AddClientDialog.tsx
│ │ ├── ClientStore.ts
│ │ ├── Clients.tsx
│ │ └── UpdateClientDialog.tsx
│ ├── common/
│ │ ├── BaseStore.ts
│ │ ├── ConfirmDialog.tsx
│ │ ├── ConnectionErrorBanner.tsx
│ │ ├── Container.tsx
│ │ ├── CopyableSecret.tsx
│ │ ├── DefaultPage.tsx
│ │ ├── LastUsedCell.tsx
│ │ ├── LoadingSpinner.tsx
│ │ ├── Markdown.tsx
│ │ ├── NumberField.tsx
│ │ ├── ScrollUpButton.tsx
│ │ ├── SettingsDialog.tsx
│ │ └── TimeAgoFormatter.ts
│ ├── config.ts
│ ├── index.tsx
│ ├── layout/
│ │ ├── Header.tsx
│ │ ├── Layout.tsx
│ │ ├── Navigation.tsx
│ │ └── theme.ts
│ ├── message/
│ │ ├── Message.tsx
│ │ ├── Messages.tsx
│ │ ├── MessagesStore.ts
│ │ ├── PushMessageDialog.tsx
│ │ ├── WebSocketStore.ts
│ │ └── extras.ts
│ ├── plugin/
│ │ ├── PluginDetailView.tsx
│ │ ├── PluginStore.ts
│ │ └── Plugins.tsx
│ ├── react-app-env.d.ts
│ ├── reactions.ts
│ ├── registerServiceWorker.ts
│ ├── snack/
│ │ ├── SnackManager.ts
│ │ └── browserNotification.ts
│ ├── stores.tsx
│ ├── tests/
│ │ ├── application.test.ts
│ │ ├── authentication.ts
│ │ ├── client.test.ts
│ │ ├── message.test.ts
│ │ ├── plugin.test.ts
│ │ ├── selector.ts
│ │ ├── setup.ts
│ │ ├── user.test.ts
│ │ └── utils.ts
│ ├── typedef/
│ │ ├── notifyjs.d.ts
│ │ └── react-timeago.d.ts
│ ├── types.ts
│ └── user/
│ ├── AddEditUserDialog.tsx
│ ├── Login.tsx
│ ├── Register.tsx
│ ├── UserStore.ts
│ └── Users.tsx
├── tsconfig.json
├── tsconfig.prod.json
├── tsconfig.test.json
├── vite-env.d.ts
├── vite.config.ts
└── vitest.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
vendor/
.idea/
build/
licenses/
coverage.txt
data/
images/
.git/
*/node_modules/
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_size = 4
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[*.{js,ts,tsx}]
indent_style = space
quote_type = single
[*.json]
indent_style = space
[*.html]
indent_style = space
[*.md]
indent_style = space
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: jmattheis
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: https://jmattheis.de/donate
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Found a bug? Tell us and help us improve
title: ''
labels: a:bug
assignees: ''
---
**Can the issue be reproduced with the latest available release? (y/n)**
**Which one is the environment gotify server is running in?**
- [ ] Docker
- [ ] Linux machine
- [ ] Windows machine
Docker startup command or config file here (please mask sensitive information)
```
```
Reverse proxy configuration (please mask sensitive information)
```
```
Name of the information here
contents here
* send messages via REST-API
* receive messages via WebSocket
* manage users, clients and applications
* [Plugins](https://gotify.net/docs/plugin)
* Web-UI -> [./ui](ui)
* CLI for sending messages -> [gotify/cli](https://github.com/gotify/cli)
* Android-App -> [gotify/android](https://github.com/gotify/android)
[
][playstore]
[
][fdroid]
(Google Play and the Google Play logo are trademarks of Google LLC.)
---
**[Documentation](https://gotify.net/docs)**
[Install](https://gotify.net/docs/install) ᛫
[Configuration](https://gotify.net/docs/config) ᛫
[REST-API](https://gotify.net/api-docs) ᛫
[Setup Dev Environment](https://gotify.net/docs/dev-setup)
## Contributing
We welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Versioning
We use [SemVer](http://semver.org/) for versioning. For the versions available, see the
[tags on this repository](https://github.com/gotify/server/tags).
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
[playstore]: https://play.google.com/store/apps/details?id=com.github.gotify
[fdroid]: https://f-droid.org/de/packages/com.github.gotify/
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
Only the latest version.
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to
**[gotify@protonmail.com](mailto:gotify@protonmail.com)**. You will receive a
response from us within a few days. If the issue is confirmed, we will release a
patch as soon as possible.
================================================
FILE: api/application.go
================================================
package api
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"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.
type ApplicationDatabase interface {
CreateApplication(application *model.Application) error
GetApplicationByToken(token string) (*model.Application, error)
GetApplicationByID(id uint) (*model.Application, error)
GetApplicationsByUser(userID uint) ([]*model.Application, error)
DeleteApplicationByID(id uint) error
UpdateApplication(application *model.Application) error
}
// The ApplicationAPI provides handlers for managing applications.
type ApplicationAPI struct {
DB ApplicationDatabase
ImageDir string
}
// Application Params Model
//
// Params allowed to create or update Applications.
//
// swagger:model ApplicationParams
type ApplicationParams struct {
// The application name. This is how the application should be displayed to the user.
//
// required: true
// example: Backup Server
Name string `form:"name" query:"name" json:"name" binding:"required"`
// The description of the application.
//
// example: Backup server for the interwebs
Description string `form:"description" query:"description" json:"description"`
// The default priority of messages sent by this application. Defaults to 0.
//
// 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.
// swagger:operation POST /application application createApp
//
// Create an application.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the application to add
// required: true
// schema:
// $ref: "#/definitions/ApplicationParams"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Application"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
applicationParams := ApplicationParams{}
if err := ctx.Bind(&applicationParams); err == nil {
app := model.Application{
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 err := a.DB.CreateApplication(&app); err != nil {
handleApplicationError(ctx, err)
return
}
ctx.JSON(200, withResolvedImage(&app))
}
}
// GetApplications returns all applications a user has.
// swagger:operation GET /application application getApps
//
// Return all applications.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: array
// items:
// $ref: "#/definitions/Application"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) GetApplications(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
apps, err := a.DB.GetApplicationsByUser(userID)
if success := successOrAbort(ctx, 500, err); !success {
return
}
for _, app := range apps {
withResolvedImage(app)
}
ctx.JSON(200, apps)
}
// DeleteApplication deletes an application by its id.
// swagger:operation DELETE /application/{id} application deleteApp
//
// Delete an application.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
app, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
if app.Internal {
ctx.AbortWithError(400, errors.New("cannot delete internal application"))
return
}
if success := successOrAbort(ctx, 500, a.DB.DeleteApplicationByID(id)); !success {
return
}
if app.Image != "" {
os.Remove(a.ImageDir + app.Image)
}
} else {
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
}
})
}
// UpdateApplication updates an application info by its id.
// swagger:operation PUT /application/{id} application updateApplication
//
// Update an application.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the application to update
// required: true
// schema:
// $ref: "#/definitions/ApplicationParams"
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Application"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
app, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
applicationParams := ApplicationParams{}
if err := ctx.Bind(&applicationParams); err == nil {
app.Description = applicationParams.Description
app.Name = applicationParams.Name
app.DefaultPriority = applicationParams.DefaultPriority
if applicationParams.SortKey != "" {
app.SortKey = applicationParams.SortKey
}
if err := a.DB.UpdateApplication(app); err != nil {
handleApplicationError(ctx, err)
return
}
ctx.JSON(200, withResolvedImage(app))
}
} else {
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
}
})
}
// UploadApplicationImage uploads an image for an application.
// swagger:operation POST /application/{id}/image application uploadAppImage
//
// Upload an image for an application.
//
// ---
// consumes:
// - multipart/form-data
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: file
// in: formData
// description: the application image
// required: true
// type: file
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Application"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Server Error
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
app, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if 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
}
ext := filepath.Ext(file.Filename)
if !ValidApplicationImageExt(ext) {
ctx.AbortWithError(400, errors.New("invalid file extension"))
return
}
name := generateNonExistingImageName(a.ImageDir, func() string {
return generateImageName() + ext
})
err = ctx.SaveUploadedFile(file, a.ImageDir+name)
if err != nil {
ctx.AbortWithError(500, err)
return
}
if app.Image != "" {
os.Remove(a.ImageDir + app.Image)
}
app.Image = name
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
return
}
ctx.JSON(200, withResolvedImage(app))
} else {
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
}
})
}
// RemoveApplicationImage deletes an image of an application.
// swagger:operation DELETE /application/{id}/image application removeAppImage
//
// Deletes an image of an application.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Server Error
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
app, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
if app.Image == "" {
ctx.AbortWithError(400, fmt.Errorf("app with id %d does not have a customized image", id))
return
}
image := app.Image
app.Image = ""
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
return
}
os.Remove(a.ImageDir + image)
ctx.JSON(200, withResolvedImage(app))
} else {
ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
}
})
}
func withResolvedImage(app *model.Application) *model.Application {
if app.Image == "" {
// This must stay in sync with the isDefaultImage check in ui/src/application/Applications.tsx.
app.Image = "static/defaultapp.png"
} else {
app.Image = "image/" + app.Image
}
return app
}
func (a *ApplicationAPI) applicationExists(token string) bool {
app, _ := a.DB.GetApplicationByToken(token)
return app != nil
}
func exist(path string) bool {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
return true
}
func generateNonExistingImageName(imgDir string, gen func() string) string {
for {
name := gen()
if !exist(imgDir + name) {
return name
}
}
}
func ValidApplicationImageExt(ext string) bool {
switch strings.ToLower(ext) {
case ".gif", ".png", ".jpg", ".jpeg":
return true
default:
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)
}
}
================================================
FILE: api/application_test.go
================================================
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"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) {
suite.Run(t, new(ApplicationSuite))
}
type ApplicationSuite struct {
suite.Suite
db *testdb.Database
a *ApplicationAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
}
var (
originalGenerateApplicationToken func() string
originalGenerateImageName func() string
)
func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
originalGenerateApplicationToken = generateApplicationToken
originalGenerateImageName = generateImageName
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())
s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.a = &ApplicationAPI{DB: s.db}
}
func (s *ApplicationSuite) AfterTest(suiteName, testName string) {
generateApplicationToken = originalGenerateApplicationToken
generateImageName = originalGenerateImageName
s.db.Close()
}
func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name&description=description_text&sortKey=a5")
s.a.CreateApplication(s.ctx)
expected := &model.Application{
ID: 1,
Token: firstApplicationToken,
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) {
assert.Equal(s.T(), expected, app)
}
}
func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() {
actual := &model.Application{
ID: 1,
UserID: 2,
Token: "Aasdasfgeeg",
Name: "myapp",
Description: "mydesc",
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, "sortKey":"a1"}`)
}
func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=&description=description_text")
s.a.CreateApplication(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
assert.Empty(s.T(), app)
}
}
func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInParams() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withJSON(&model.Application{
Name: "name",
Description: "description",
ID: 333,
Internal: true,
Token: "token",
Image: "adfdf",
SortKey: "a5",
})
s.a.CreateApplication(s.ctx)
expectedJSONValue, _ := json.Marshal(&model.Application{
ID: 1,
Token: firstApplicationToken,
UserID: 5,
Name: "name",
Description: "description",
Internal: false,
Image: "static/defaultapp.png",
SortKey: "a5",
})
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), string(expectedJSONValue), s.recorder.Body.String())
}
func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() {
s.db.User(2)
s.db.User(5).App(5)
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/5", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}
s.a.DeleteApplication(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.db.AssertAppExist(5)
}
func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateApplication(s.ctx)
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)
}
}
func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateApplication(s.ctx)
expected := &model.Application{
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)
}
func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {
s.db.User(5)
s.db.User(6).AppWithToken(1, firstApplicationToken)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateApplication(s.ctx)
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")
second := userBuilder.NewAppWithToken(2, "asdasd")
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 = "static/defaultapp.png"
second.Image = "static/defaultapp.png"
test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)
}
func (s *ApplicationSuite) 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 = "image/abcd.jpg"
second.Image = "static/defaultapp.png"
test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)
}
func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() {
s.db.User(5).InternalApp(10)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "10"}}
s.a.DeleteApplication(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "4"}}
s.a.DeleteApplication(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ApplicationSuite) Test_DeleteApplication() {
s.db.User(5).App(1)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.DeleteApplication(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertAppNotExist(1)
}
func (s *ApplicationSuite) 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 *ApplicationSuite) 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.Error(s.T(), s.ctx.Errors[0].Err, "multipart: NextPart: EOF")
}
func (s *ApplicationSuite) 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)
if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
imgName := app.Image
assert.Equal(s.T(), 200, s.recorder.Code)
_, err = os.Stat(imgName)
assert.Nil(s.T(), err)
s.a.DeleteApplication(s.ctx)
_, err = os.Stat(imgName)
assert.True(s.T(), os.IsNotExist(err))
}
}
func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() {
existingImageName := "2lHMAel6BDHLL-HrwphcviX-l.png"
firstGeneratedImageName := firstApplicationToken[1:] + ".png"
secondGeneratedImageName := secondApplicationToken[1:] + ".png"
s.db.User(5)
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})
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"}}
fakeImage(s.T(), existingImageName)
fakeImage(s.T(), firstGeneratedImageName)
s.a.UploadApplicationImage(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
_, err = os.Stat(existingImageName)
assert.True(s.T(), os.IsNotExist(err))
_, err = os.Stat(secondGeneratedImageName)
assert.Nil(s.T(), err)
assert.Nil(s.T(), os.Remove(secondGeneratedImageName))
assert.Nil(s.T(), os.Remove(firstGeneratedImageName))
}
func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() {
s.db.User(5)
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"})
fakeImage(s.T(), "existing.png")
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("existing.png")
assert.True(s.T(), os.IsNotExist(err))
os.Remove(firstApplicationToken[1:] + ".png")
}
func (s *ApplicationSuite) 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 *ApplicationSuite) Test_UploadAppImage_WithHtmlFileHavingImageHeader() {
s.db.User(5).App(1)
cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image-header-with.html")})
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("invalid file extension"))
}
func (s *ApplicationSuite) 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 *ApplicationSuite) Test_RemoveAppImage_expectNotFound() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "4"}}
s.a.RemoveApplicationImage(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ApplicationSuite) Test_RemoveAppImage_noCustomizedImage() {
s.db.User(5).App(1)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.RemoveApplicationImage(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() {
s.db.User(5)
imageFile := "existing.png"
s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile})
fakeImage(s.T(), imageFile)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.RemoveApplicationImage(s.ctx)
_, err := os.Stat(imageFile)
assert.True(s.T(), os.IsNotExist(err))
assert.Equal(s.T(), 200, s.recorder.Code)
}
func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSuccess() {
s.db.User(5).NewAppWithToken(2, "app-2")
test.WithUser(s.ctx, 5)
s.withFormData("name=new_name&description=new_description_text")
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
expected := &model.Application{
ID: 2,
Token: "app-2",
UserID: 5,
Name: "new_name",
Description: "new_description_text",
SortKey: "a0",
}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
assert.Equal(s.T(), expected, app)
}
}
func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {
s.db.User(5).NewAppWithToken(2, "app-2")
test.WithUser(s.ctx, 5)
s.withFormData("name=new_name")
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
expected := &model.Application{
ID: 2,
Token: "app-2",
UserID: 5,
Name: "new_name",
Description: "",
SortKey: "a0",
}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
assert.Equal(s.T(), expected, app)
}
}
func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() {
s.db.User(5).NewAppWithToken(2, "app-2")
test.WithUser(s.ctx, 5)
s.withFormData("name=name&description=&defaultPriority=4")
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
expected := &model.Application{
ID: 2,
Token: "app-2",
UserID: 5,
Name: "name",
Description: "",
DefaultPriority: 4,
SortKey: "a0",
}
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
assert.Equal(s.T(), expected, app)
}
}
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)
s.withFormData("name=new_name")
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
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)
}
}
func (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() {
app := s.db.User(5).NewAppWithToken(2, "app-2")
app.Description = "my desc"
assert.Nil(s.T(), s.db.UpdateApplication(app))
test.WithUser(s.ctx, 5)
s.withFormData("name=new_name&desc=")
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
assert.Equal(s.T(), "", app.Description)
}
}
func (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() {
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttributes_expectBadRequest() {
test.WithUser(s.ctx, 5)
s.a.UpdateApplication(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFound() {
s.db.User(5).NewAppWithToken(2, "app-2")
test.WithUser(s.ctx, 4)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateApplication(s.ctx)
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")
}
func (s *ApplicationSuite) withJSON(value interface{}) {
jsonVal, _ := json.Marshal(value)
s.ctx.Request = httptest.NewRequest("POST", "/application", bytes.NewBuffer(jsonVal))
s.ctx.Request.Header.Set("Content-Type", "application/json")
}
// 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 contentType, buffer, err
}
if _, err = io.Copy(fw, r); err != nil {
return contentType, buffer, err
}
}
contentType = w.FormDataContentType()
w.Close()
return contentType, buffer, err
}
func mustOpen(f string) *os.File {
r, err := os.Open(f)
if err != nil {
panic(err)
}
return r
}
func fakeImage(t *testing.T, path string) {
data, err := os.ReadFile("../test/assets/image.png")
assert.Nil(t, err)
// Write data to dst
err = os.WriteFile(path, data, 0o644)
assert.Nil(t, err)
}
================================================
FILE: api/client.go
================================================
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/model"
)
// The ClientDatabase interface for encapsulating database access.
type ClientDatabase interface {
CreateClient(client *model.Client) error
GetClientByToken(token string) (*model.Client, error)
GetClientByID(id uint) (*model.Client, error)
GetClientsByUser(userID uint) ([]*model.Client, error)
DeleteClientByID(id uint) error
UpdateClient(client *model.Client) error
}
// The ClientAPI provides handlers for managing clients and applications.
type ClientAPI struct {
DB ClientDatabase
ImageDir string
NotifyDeleted func(uint, string)
}
// Client Params Model
//
// Params allowed to create or update Clients.
//
// swagger:model ClientParams
type ClientParams struct {
// The client name
//
// required: true
// example: My Client
Name string `form:"name" query:"name" json:"name" binding:"required"`
}
// UpdateClient updates a client by its id.
// swagger:operation PUT /client/{id} client updateClient
//
// Update a client.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the client to update
// required: true
// schema:
// $ref: "#/definitions/ClientParams"
// - name: id
// in: path
// description: the client id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Client"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
client, err := a.DB.GetClientByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if client != nil && client.UserID == auth.GetUserID(ctx) {
newValues := ClientParams{}
if err := ctx.Bind(&newValues); err == nil {
client.Name = newValues.Name
if success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success {
return
}
ctx.JSON(200, client)
}
} else {
ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id))
}
})
}
// CreateClient creates a client and returns the access token.
// swagger:operation POST /client client createClient
//
// Create a client.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the client to add
// required: true
// schema:
// $ref: "#/definitions/ClientParams"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Client"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) CreateClient(ctx *gin.Context) {
clientParams := ClientParams{}
if err := ctx.Bind(&clientParams); err == nil {
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 {
return
}
ctx.JSON(200, client)
}
}
// GetClients returns all clients a user has.
// swagger:operation GET /client client getClients
//
// Return all clients.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: array
// items:
// $ref: "#/definitions/Client"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) GetClients(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
clients, err := a.DB.GetClientsByUser(userID)
if success := successOrAbort(ctx, 500, err); !success {
return
}
ctx.JSON(200, clients)
}
// DeleteClient deletes a client by its id.
// swagger:operation DELETE /client/{id} client deleteClient
//
// Delete a client.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the client id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) DeleteClient(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
client, err := a.DB.GetClientByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if client != nil && client.UserID == auth.GetUserID(ctx) {
a.NotifyDeleted(client.UserID, client.Token)
successOrAbort(ctx, 500, a.DB.DeleteClientByID(id))
} else {
ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id))
}
})
}
func (a *ClientAPI) clientExists(token string) bool {
client, _ := a.DB.GetClientByToken(token)
return client != nil
}
================================================
FILE: api/client_test.go
================================================
package api
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
var (
firstClientToken = "Caaaaaaaaaaaaaa"
secondClientToken = "Cbbbbbbbbbbbbbb"
)
func TestClientSuite(t *testing.T) {
suite.Run(t, new(ClientSuite))
}
type ClientSuite struct {
suite.Suite
db *testdb.Database
a *ClientAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
notified bool
}
var originalGenerateClientToken func() string
func (s *ClientSuite) BeforeTest(suiteName, testName string) {
originalGenerateClientToken = generateClientToken
generateClientToken = test.Tokens(firstClientToken, secondClientToken)
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.db = testdb.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.notified = false
s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify}
}
func (s *ClientSuite) notify(uint, string) {
s.notified = true
}
func (s *ClientSuite) AfterTest(suiteName, testName string) {
generateClientToken = originalGenerateClientToken
s.db.Close()
}
func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"}
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`)
}
func (s *ClientSuite) Test_CreateClient_mapAllParameters() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name&description=description_text")
s.a.CreateClient(s.ctx)
expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"}
assert.Equal(s.T(), 200, s.recorder.Code)
if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
assert.Contains(s.T(), clients, expected)
}
}
func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=myclient&ID=45&Token=12341234&UserID=333")
s.a.CreateClient(s.ctx)
expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: "myclient"}
assert.Equal(s.T(), 200, s.recorder.Code)
if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
assert.Contains(s.T(), clients, expected)
}
}
func (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=&description=description_text")
s.a.CreateClient(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
assert.Empty(s.T(), clients)
}
}
func (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() {
s.db.User(5).Client(7)
s.db.User(2)
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/7", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}
s.a.DeleteClient(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.db.AssertClientExist(7)
}
func (s *ClientSuite) Test_CreateClient_returnsClientWithID() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateClient(s.ctx)
expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5}
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *ClientSuite) Test_CreateClient_withExistingToken() {
s.db.User(5).ClientWithToken(1, firstClientToken)
test.WithUser(s.ctx, 5)
s.withFormData("name=custom_name")
s.a.CreateClient(s.ctx)
expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5}
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *ClientSuite) Test_GetClients() {
userBuilder := s.db.User(5)
first := userBuilder.NewClientWithToken(1, "perfper")
second := userBuilder.NewClientWithToken(2, "asdasd")
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil)
s.a.GetClients(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder)
}
func (s *ClientSuite) Test_DeleteClient_expectNotFound() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "8"}}
s.a.DeleteClient(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ClientSuite) Test_DeleteClient() {
s.db.User(5).Client(8)
test.WithUser(s.ctx, 5)
s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "8"}}
assert.False(s.T(), s.notified)
s.a.DeleteClient(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertClientNotExist(8)
assert.True(s.T(), s.notified)
}
func (s *ClientSuite) Test_UpdateClient_expectSuccess() {
s.db.User(5).NewClientWithToken(1, firstClientToken)
test.WithUser(s.ctx, 5)
s.withFormData("name=firefox")
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.UpdateClient(s.ctx)
expected := &model.Client{
ID: 1,
Token: firstClientToken,
UserID: 5,
Name: "firefox",
}
assert.Equal(s.T(), 200, s.recorder.Code)
if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) {
assert.Equal(s.T(), expected, client)
}
}
func (s *ClientSuite) Test_UpdateClient_expectNotFound() {
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateClient(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() {
test.WithUser(s.ctx, 5)
s.a.UpdateClient(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *ClientSuite) 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})
}
================================================
FILE: api/errorHandling.go
================================================
package api
import "github.com/gin-gonic/gin"
func successOrAbort(ctx *gin.Context, code int, err error) (success bool) {
if err != nil {
ctx.AbortWithError(code, err)
}
return err == nil
}
================================================
FILE: api/errorHandling_test.go
================================================
package api
import (
"errors"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestErrorHandling(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
successOrAbort(ctx, 500, errors.New("err"))
if rec.Code != 500 {
t.Fail()
}
}
================================================
FILE: api/health.go
================================================
package api
import (
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/model"
)
// The HealthDatabase interface for encapsulating database access.
type HealthDatabase interface {
Ping() error
}
// The HealthAPI provides handlers for the health information.
type HealthAPI struct {
DB HealthDatabase
}
// Health returns health information.
// swagger:operation GET /health health getHealth
//
// Get health information.
//
// ---
// produces: [application/json]
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Health"
// 500:
// description: Ok
// schema:
// $ref: "#/definitions/Health"
func (a *HealthAPI) Health(ctx *gin.Context) {
if err := a.DB.Ping(); err != nil {
ctx.JSON(500, model.Health{
Health: model.StatusOrange,
Database: model.StatusRed,
})
return
}
ctx.JSON(200, model.Health{
Health: model.StatusGreen,
Database: model.StatusGreen,
})
}
================================================
FILE: api/health_test.go
================================================
package api
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/suite"
)
func TestHealthSuite(t *testing.T) {
suite.Run(t, new(HealthSuite))
}
type HealthSuite struct {
suite.Suite
db *testdb.Database
a *HealthAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
}
func (s *HealthSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.db = testdb.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com")
s.a = &HealthAPI{DB: s.db}
}
func (s *HealthSuite) AfterTest(suiteName, testName string) {
s.db.Close()
}
func (s *HealthSuite) TestHealthSuccess() {
s.a.Health(s.ctx)
test.BodyEquals(s.T(), model.Health{Health: model.StatusGreen, Database: model.StatusGreen}, s.recorder)
}
func (s *HealthSuite) TestDatabaseFailure() {
s.db.Close()
s.a.Health(s.ctx)
test.BodyEquals(s.T(), model.Health{Health: model.StatusOrange, Database: model.StatusRed}, s.recorder)
}
================================================
FILE: api/internalutil.go
================================================
package api
import (
"errors"
"math/bits"
"strconv"
"github.com/gin-gonic/gin"
)
func withID(ctx *gin.Context, name string, f func(id uint)) {
if id, err := strconv.ParseUint(ctx.Param(name), 10, bits.UintSize); err == nil {
f(uint(id))
} else {
ctx.AbortWithError(400, errors.New("invalid id"))
}
}
================================================
FILE: api/message.go
================================================
package api
import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/gotify/location"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/model"
)
// The MessageDatabase interface for encapsulating database access.
type MessageDatabase interface {
GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error)
GetApplicationByID(id uint) (*model.Application, error)
GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error)
DeleteMessageByID(id uint) error
GetMessageByID(id uint) (*model.Message, error)
DeleteMessagesByUser(userID uint) error
DeleteMessagesByApplication(applicationID uint) error
CreateMessage(message *model.Message) error
GetApplicationByToken(token string) (*model.Application, error)
}
var timeNow = time.Now
// Notifier notifies when a new message was created.
type Notifier interface {
Notify(userID uint, message *model.MessageExternal)
}
// The MessageAPI provides handlers for managing messages.
type MessageAPI struct {
DB MessageDatabase
Notifier Notifier
}
type pagingParams struct {
Limit int `form:"limit" binding:"min=1,max=200"`
Since uint `form:"since" binding:"min=0"`
}
// GetMessages returns all messages from a user.
// swagger:operation GET /message message getMessages
//
// Return all messages.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: limit
// in: query
// description: the maximal amount of messages to return
// required: false
// maximum: 200
// minimum: 1
// default: 100
// type: integer
// - name: since
// in: query
// description: return all messages with an ID less than this value
// minimum: 0
// required: false
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/PagedMessages"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) GetMessages(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
withPaging(ctx, func(params *pagingParams) {
// the +1 is used to check if there are more messages and will be removed on buildWithPaging
messages, err := a.DB.GetMessagesByUserSince(userID, params.Limit+1, params.Since)
if success := successOrAbort(ctx, 500, err); !success {
return
}
ctx.JSON(200, buildWithPaging(ctx, params, messages))
})
}
func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages {
next := ""
since := uint(0)
useMessages := messages
if len(messages) > paging.Limit {
useMessages = messages[:len(messages)-1]
since = useMessages[len(useMessages)-1].ID
url := location.Get(ctx)
url.Path = ctx.Request.URL.Path
query := url.Query()
query.Add("limit", strconv.Itoa(paging.Limit))
query.Add("since", strconv.FormatUint(uint64(since), 10))
url.RawQuery = query.Encode()
next = url.String()
}
return &model.PagedMessages{
Paging: model.Paging{Size: len(useMessages), Limit: paging.Limit, Next: next, Since: since},
Messages: toExternalMessages(useMessages),
}
}
func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) {
params := &pagingParams{Limit: 100}
if err := ctx.MustBindWith(params, binding.Query); err == nil {
f(params)
}
}
// GetMessagesWithApplication returns all messages from a specific application.
// swagger:operation GET /application/{id}/message message getAppMessages
//
// Return all messages from a specific application.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// - name: limit
// in: query
// description: the maximal amount of messages to return
// required: false
// maximum: 200
// minimum: 1
// default: 100
// type: integer
// - name: since
// in: query
// description: return all messages with an ID less than this value
// minimum: 0
// required: false
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/PagedMessages"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
withPaging(ctx, func(params *pagingParams) {
app, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
// the +1 is used to check if there are more messages and will be removed on buildWithPaging
messages, err := a.DB.GetMessagesByApplicationSince(id, params.Limit+1, params.Since)
if success := successOrAbort(ctx, 500, err); !success {
return
}
ctx.JSON(200, buildWithPaging(ctx, params, messages))
} else {
ctx.AbortWithError(404, errors.New("application does not exist"))
}
})
})
}
// DeleteMessages delete all messages from a user.
// swagger:operation DELETE /message message deleteMessages
//
// Delete all messages.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessages(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
successOrAbort(ctx, 500, a.DB.DeleteMessagesByUser(userID))
}
// DeleteMessageWithApplication deletes all messages from a specific application.
// swagger:operation DELETE /application/{id}/message message deleteAppMessages
//
// Delete all messages from a specific application.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the application id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessageWithApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
application, err := a.DB.GetApplicationByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if application != nil && application.UserID == auth.GetUserID(ctx) {
successOrAbort(ctx, 500, a.DB.DeleteMessagesByApplication(id))
} else {
ctx.AbortWithError(404, errors.New("application does not exists"))
}
})
}
// DeleteMessage deletes a message with an id.
// swagger:operation DELETE /message/{id} message deleteMessage
//
// Deletes a message with an id.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the message id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessage(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
msg, err := a.DB.GetMessageByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if msg == nil {
ctx.AbortWithError(404, errors.New("message does not exist"))
return
}
app, err := a.DB.GetApplicationByID(msg.ApplicationID)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil && app.UserID == auth.GetUserID(ctx) {
successOrAbort(ctx, 500, a.DB.DeleteMessageByID(id))
} else {
ctx.AbortWithError(404, errors.New("message does not exist"))
}
})
}
// CreateMessage creates a message, authentication via application-token is required.
// swagger:operation POST /message message createMessage
//
// Create a message.
//
// __NOTE__: This API ONLY accepts an application token as authentication.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []]
// parameters:
// - name: body
// in: body
// description: the message to add
// required: true
// schema:
// $ref: "#/definitions/Message"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Message"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *MessageAPI) CreateMessage(ctx *gin.Context) {
message := model.MessageExternal{}
if err := ctx.Bind(&message); err == nil {
application, err := a.DB.GetApplicationByToken(auth.GetTokenID(ctx))
if success := successOrAbort(ctx, 500, err); !success {
return
}
message.ApplicationID = application.ID
if strings.TrimSpace(message.Title) == "" {
message.Title = application.Name
}
if message.Priority == nil {
message.Priority = &application.DefaultPriority
}
message.Date = timeNow()
message.ID = 0
msgInternal := toInternalMessage(&message)
if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success {
return
}
a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal))
ctx.JSON(200, toExternalMessage(msgInternal))
}
}
func toInternalMessage(msg *model.MessageExternal) *model.Message {
res := &model.Message{
ID: msg.ID,
ApplicationID: msg.ApplicationID,
Message: msg.Message,
Title: msg.Title,
Date: msg.Date,
}
if msg.Priority != nil {
res.Priority = *msg.Priority
}
if msg.Extras != nil {
res.Extras, _ = json.Marshal(msg.Extras)
}
return res
}
func toExternalMessage(msg *model.Message) *model.MessageExternal {
res := &model.MessageExternal{
ID: msg.ID,
ApplicationID: msg.ApplicationID,
Message: msg.Message,
Title: msg.Title,
Priority: &msg.Priority,
Date: msg.Date,
}
if len(msg.Extras) != 0 {
res.Extras = make(map[string]interface{})
json.Unmarshal(msg.Extras, &res.Extras)
}
return res
}
func toExternalMessages(msg []*model.Message) []*model.MessageExternal {
res := make([]*model.MessageExternal, len(msg))
for i := range msg {
res[i] = toExternalMessage(msg[i])
}
return res
}
================================================
FILE: api/message_test.go
================================================
package api
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
func TestMessageSuite(t *testing.T) {
suite.Run(t, new(MessageSuite))
}
type MessageSuite struct {
suite.Suite
db *testdb.Database
a *MessageAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
notifiedMessage *model.MessageExternal
}
func (s *MessageSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder)
s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil)
s.db = testdb.NewDB(s.T())
s.notifiedMessage = nil
s.a = &MessageAPI{DB: s.db, Notifier: s}
}
func (s *MessageSuite) AfterTest(string, string) {
s.db.Close()
}
func (s *MessageSuite) Notify(userID uint, msg *model.MessageExternal) {
s.notifiedMessage = msg
}
func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() {
t, _ := time.Parse("2006/01/02", "2017/01/02")
actual := &model.PagedMessages{
Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"},
Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{
"test::string": "string",
"test::array": []interface{}{1, 2, 3},
"test::int": 1,
"test::float": 0.5,
}}},
}
test.JSONEquals(s.T(), actual, `{"paging": {"limit":5, "since": 122, "size": 5, "next": "http://example.com/message?limit=5&since=122"},
"messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z","extras":{"test::string":"string","test::array":[1,2,3],"test::int":1,"test::float":0.5}}]}`)
}
func (s *MessageSuite) Test_GetMessages() {
user := s.db.User(5)
first := user.App(1).NewMessage(1)
second := user.App(2).NewMessage(2)
firstExternal := toExternalMessage(&first)
secondExternal := toExternalMessage(&second)
test.WithUser(s.ctx, 5)
s.a.GetMessages(s.ctx)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 100, Size: 2, Next: ""},
Messages: []*model.MessageExternal{secondExternal, firstExternal},
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() {
user := s.db.User(5)
app1 := user.App(1)
app2 := user.App(2)
var messages []*model.Message
for i := 100; i >= 1; i -= 2 {
one := app2.NewMessage(uint(i))
two := app1.NewMessage(uint(i - 1))
messages = append(messages, &one, &two)
}
s.withURL("http", "example.com", "/messages", "limit=5")
test.WithUser(s.ctx, 5)
s.a.GetMessages(s.ctx)
// Since: entries with ids from 100 - 96 will be returned (5 entries)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 5, Size: 5, Since: 96, Next: "http://example.com/messages?limit=5&since=96"},
Messages: toExternalMessages(messages[:5]),
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() {
user := s.db.User(5)
app1 := user.App(1)
app2 := user.App(2)
var messages []*model.Message
for i := 100; i >= 1; i -= 2 {
one := app2.NewMessage(uint(i))
two := app1.NewMessage(uint(i - 1))
messages = append(messages, &one, &two)
}
s.withURL("http", "example.com", "/messages", "limit=13&since=55")
test.WithUser(s.ctx, 5)
s.a.GetMessages(s.ctx)
// Since: entries with ids from 54 - 42 will be returned (13 entries)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/messages?limit=13&since=42"},
Messages: toExternalMessages(messages[46 : 46+13]),
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withURL("http", "example.com", "/messages", "limit=555")
s.a.GetMessages(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withURL("http", "example.com", "/messages", "limit=-5")
s.a.GetMessages(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() {
s.db.User(4).App(2).NewMessage(1)
test.WithUser(s.ctx, 4)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.withURL("http", "example.com", "/messages", "limit=555")
s.a.GetMessagesWithApplication(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetMessagesWithToken() {
msg := s.db.User(4).App(2).NewMessage(1)
test.WithUser(s.ctx, 4)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.GetMessagesWithApplication(s.ctx)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 100, Size: 1, Next: ""},
Messages: toExternalMessages([]*model.Message{&msg}),
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext() {
user := s.db.User(5)
app1 := user.App(2)
var messages []*model.Message
for i := 100; i >= 1; i-- {
msg := app1.NewMessage(uint(i))
messages = append(messages, &msg)
}
s.withURL("http", "example.com", "/app/2/message", "limit=9")
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.GetMessagesWithApplication(s.ctx)
// Since: entries with ids from 100 - 92 will be returned (9 entries)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 9, Size: 9, Since: 92, Next: "http://example.com/app/2/message?limit=9&since=92"},
Messages: toExternalMessages(messages[:9]),
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext() {
user := s.db.User(5)
app1 := user.App(2)
var messages []*model.Message
for i := 100; i >= 1; i-- {
msg := app1.NewMessage(uint(i))
messages = append(messages, &msg)
}
s.withURL("http", "example.com", "/app/2/message", "limit=13&since=55")
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.GetMessagesWithApplication(s.ctx)
// Since: entries with ids from 54 - 42 will be returned (13 entries)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/app/2/message?limit=13&since=42"},
Messages: toExternalMessages(messages[46 : 46+13]),
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectNotFound() {
s.db.User(4)
s.db.User(5).App(2).Message(66)
test.WithUser(s.ctx, 4)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.GetMessagesWithApplication(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessage_invalidID() {
s.ctx.Params = gin.Params{{Key: "id", Value: "string"}}
s.a.DeleteMessage(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessage_notExistingID() {
s.db.User(1).App(5).Message(55)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.DeleteMessage(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessage_existingIDButNotOwner() {
s.db.User(1).App(10).Message(100)
s.db.User(2)
test.WithUser(s.ctx, 2)
s.ctx.Params = gin.Params{{Key: "id", Value: "100"}}
s.a.DeleteMessage(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessage() {
s.db.User(6).App(1).Message(50)
test.WithUser(s.ctx, 6)
s.ctx.Params = gin.Params{{Key: "id", Value: "50"}}
s.a.DeleteMessage(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertMessageNotExist(50)
}
func (s *MessageSuite) Test_DeleteMessageWithID() {
s.db.User(2).AppWithToken(5, "mytoken").Message(55)
test.WithUser(s.ctx, 2)
s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}
s.a.DeleteMessageWithApplication(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertMessageNotExist(55)
}
func (s *MessageSuite) Test_DeleteMessageWithToken_notExistingID() {
s.db.User(2).AppWithToken(1, "wrong").Message(1)
test.WithUser(s.ctx, 2)
s.ctx.Params = gin.Params{{Key: "id", Value: "55"}}
s.a.DeleteMessageWithApplication(s.ctx)
s.db.AssertMessageExist(1)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessageWithToken_notOwner() {
s.db.User(4)
s.db.User(2).App(55).Message(5)
test.WithUser(s.ctx, 4)
s.ctx.Params = gin.Params{{Key: "id", Value: "55"}}
s.a.DeleteMessageWithApplication(s.ctx)
s.db.AssertMessageExist(5)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_DeleteMessages() {
userBuilder := s.db.User(4)
userBuilder.App(5).Message(5).Message(6)
userBuilder.App(2).Message(7).Message(8)
s.db.User(5).App(7).Message(22)
test.WithUser(s.ctx, 4)
s.a.DeleteMessages(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertMessageExist(22)
s.db.AssertMessageNotExist(5, 6, 7, 8)
}
func (s *MessageSuite) Test_CreateMessage_onJson_allParams() {
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(7, "app-token")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": 1}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(7)
assert.NoError(s.T(), err)
expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), expected, s.notifiedMessage)
}
func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() {
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5)
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(8)
assert.NoError(s.T(), err)
expected := &model.MessageExternal{ID: 1, ApplicationID: 8, Title: "mytitle", Message: "mymessage", Priority: intPtr(5), Date: t}
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), expected, s.notifiedMessage)
}
func (s *MessageSuite) Test_CreateMessage_WithTitle() {
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(5, "app-token")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(5)
assert.NoError(s.T(), err)
expected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: "mytitle", Message: "mymessage", Date: t, Priority: intPtr(0)}
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), expected, s.notifiedMessage)
}
func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(1, "app-token")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {
assert.Empty(s.T(), msgs)
}
assert.Equal(s.T(), 400, s.recorder.Code)
assert.Nil(s.T(), s.notifiedMessage)
}
func (s *MessageSuite) Test_CreateMessage_WithoutTitle() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(8)
assert.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), "Application name", msgs[0].Title)
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), "mymessage", s.notifiedMessage.Message)
}
func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": " "}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(8)
assert.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), "Application name", msgs[0].Title)
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), "mymessage", msgs[0].Message)
}
func (s *MessageSuite) Test_CreateMessage_IgnoreID() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "id": 1337}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(8)
assert.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assert.NotEqual(s.T(), msgs[0].ID, uint(1337))
assert.Equal(s.T(), 200, s.recorder.Code)
}
func (s *MessageSuite) Test_CreateMessage_WithExtras() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": "msg with extras", "extras": {"gotify::test":{"int":1,"float":0.5,"string":"test","array":[1,2,3]}}}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
msgs, err := s.db.GetMessagesByApplication(8)
assert.NoError(s.T(), err)
expected := &model.MessageExternal{
ID: 1,
ApplicationID: 8,
Message: "mymessage",
Title: "msg with extras",
Date: t,
Priority: intPtr(0),
Extras: map[string]interface{}{
"gotify::test": map[string]interface{}{
"string": "test",
"array": []interface{}{float64(1), float64(2), float64(3)},
"int": float64(1),
"float": float64(0.5),
},
},
}
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}
func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(8, "app-token")
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": "asd"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateMessage(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
assert.Nil(s.T(), s.notifiedMessage)
if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {
assert.Empty(s.T(), msgs)
}
}
func (s *MessageSuite) Test_CreateMessage_onQueryData() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(2, "app-token")
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
s.ctx.Request = httptest.NewRequest("POST", "/message?title=mytitle&message=mymessage&priority=1", nil)
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.a.CreateMessage(s.ctx)
expected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
msgs, err := s.db.GetMessagesByApplication(2)
assert.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}
func (s *MessageSuite) Test_CreateMessage_onFormData() {
auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
s.db.User(4).AppWithToken(99, "app-token")
t, _ := time.Parse("2006/01/02", "2017/01/02")
timeNow = func() time.Time { return t }
defer func() { timeNow = time.Now }()
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`title=mytitle&message=mymessage&priority=1`))
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.a.CreateMessage(s.ctx)
expected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
msgs, err := s.db.GetMessagesByApplication(99)
assert.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}
func (s *MessageSuite) withURL(scheme, host, path, query string) {
s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query}
s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host})
}
func intPtr(x int) *int {
return &x
}
================================================
FILE: api/plugin.go
================================================
package api
import (
"errors"
"fmt"
"io"
"github.com/gin-gonic/gin"
"github.com/gotify/location"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/plugin"
"github.com/gotify/server/v2/plugin/compat"
"gopkg.in/yaml.v3"
)
// The PluginDatabase interface for encapsulating database access.
type PluginDatabase interface {
GetPluginConfByUser(userid uint) ([]*model.PluginConf, error)
UpdatePluginConf(p *model.PluginConf) error
GetPluginConfByID(id uint) (*model.PluginConf, error)
}
// The PluginAPI provides handlers for managing plugins.
type PluginAPI struct {
Notifier Notifier
Manager *plugin.Manager
DB PluginDatabase
}
// GetPlugins returns all plugins a user has.
// swagger:operation GET /plugin plugin getPlugins
//
// Return all plugins.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: array
// items:
// $ref: "#/definitions/PluginConf"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) GetPlugins(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
plugins, err := c.DB.GetPluginConfByUser(userID)
if success := successOrAbort(ctx, 500, err); !success {
return
}
result := make([]model.PluginConfExternal, 0)
for _, conf := range plugins {
if inst, err := c.Manager.Instance(conf.ID); err == nil {
info := c.Manager.PluginInfo(conf.ModulePath)
result = append(result, model.PluginConfExternal{
ID: conf.ID,
Name: info.String(),
Token: conf.Token,
ModulePath: conf.ModulePath,
Author: info.Author,
Website: info.Website,
License: info.License,
Enabled: conf.Enabled,
Capabilities: inst.Supports().Strings(),
})
}
}
ctx.JSON(200, result)
}
// EnablePlugin enables a plugin.
// swagger:operation POST /plugin/{id}/enable plugin enablePlugin
//
// Enable a plugin.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the plugin id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) EnablePlugin(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
conf, err := c.DB.GetPluginConfByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if conf == nil || !isPluginOwner(ctx, conf) {
ctx.AbortWithError(404, errors.New("unknown plugin"))
return
}
_, err = c.Manager.Instance(id)
if err != nil {
ctx.AbortWithError(404, errors.New("plugin instance not found"))
return
}
if err := c.Manager.SetPluginEnabled(id, true); err == plugin.ErrAlreadyEnabledOrDisabled {
ctx.AbortWithError(400, err)
} else if err != nil {
ctx.AbortWithError(500, err)
}
})
}
// DisablePlugin disables a plugin.
// swagger:operation POST /plugin/{id}/disable plugin disablePlugin
//
// Disable a plugin.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the plugin id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) DisablePlugin(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
conf, err := c.DB.GetPluginConfByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if conf == nil || !isPluginOwner(ctx, conf) {
ctx.AbortWithError(404, errors.New("unknown plugin"))
return
}
_, err = c.Manager.Instance(id)
if err != nil {
ctx.AbortWithError(404, errors.New("plugin instance not found"))
return
}
if err := c.Manager.SetPluginEnabled(id, false); err == plugin.ErrAlreadyEnabledOrDisabled {
ctx.AbortWithError(400, err)
} else if err != nil {
ctx.AbortWithError(500, err)
}
})
}
// GetDisplay get display info for Displayer plugin.
// swagger:operation GET /plugin/{id}/display plugin getPluginDisplay
//
// Get display info for a Displayer plugin.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the plugin id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: string
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) GetDisplay(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
conf, err := c.DB.GetPluginConfByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if conf == nil || !isPluginOwner(ctx, conf) {
ctx.AbortWithError(404, errors.New("unknown plugin"))
return
}
instance, err := c.Manager.Instance(id)
if err != nil {
ctx.AbortWithError(404, errors.New("plugin instance not found"))
return
}
ctx.JSON(200, instance.GetDisplay(location.Get(ctx)))
})
}
// GetConfig returns Configurer plugin configuration in YAML format.
// swagger:operation GET /plugin/{id}/config plugin getPluginConfig
//
// Get YAML configuration for Configurer plugin.
//
// ---
// consumes: [application/json]
// produces: [application/x-yaml]
// parameters:
// - name: id
// in: path
// description: the plugin id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: object
// description: plugin configuration
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) GetConfig(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
conf, err := c.DB.GetPluginConfByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if conf == nil || !isPluginOwner(ctx, conf) {
ctx.AbortWithError(404, errors.New("unknown plugin"))
return
}
instance, err := c.Manager.Instance(id)
if err != nil {
ctx.AbortWithError(404, errors.New("plugin instance not found"))
return
}
if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {
return
}
ctx.Header("content-type", "application/x-yaml")
ctx.Writer.Write(conf.Config)
})
}
// UpdateConfig updates Configurer plugin configuration in YAML format.
// swagger:operation POST /plugin/{id}/config plugin updatePluginConfig
//
// Update YAML configuration for Configurer plugin.
//
// ---
// consumes: [application/x-yaml]
// produces: [application/json]
// parameters:
// - name: id
// in: path
// description: the plugin id
// required: true
// type: integer
// format: int64
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Internal Server Error
// schema:
// $ref: "#/definitions/Error"
func (c *PluginAPI) UpdateConfig(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
conf, err := c.DB.GetPluginConfByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if conf == nil || !isPluginOwner(ctx, conf) {
ctx.AbortWithError(404, errors.New("unknown plugin"))
return
}
instance, err := c.Manager.Instance(id)
if err != nil {
ctx.AbortWithError(404, errors.New("plugin instance not found"))
return
}
if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {
return
}
newConf := instance.DefaultConfig()
newconfBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.AbortWithError(500, err)
return
}
if err := yaml.Unmarshal(newconfBytes, newConf); err != nil {
ctx.AbortWithError(400, err)
return
}
if err := instance.ValidateAndSetConfig(newConf); err != nil {
ctx.AbortWithError(400, err)
return
}
conf.Config = newconfBytes
successOrAbort(ctx, 500, c.DB.UpdatePluginConf(conf))
})
}
func isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool {
return conf.UserID == auth.GetUserID(ctx)
}
func supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, module compat.Capability) (aborted bool) {
if compat.HasSupport(instance, module) {
return false
}
ctx.AbortWithError(400, fmt.Errorf("plugin does not support %s", module))
return true
}
================================================
FILE: api/plugin_test.go
================================================
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/plugin"
"github.com/gotify/server/v2/plugin/compat"
"github.com/gotify/server/v2/plugin/testing/mock"
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gopkg.in/yaml.v3"
)
func TestPluginSuite(t *testing.T) {
suite.Run(t, new(PluginSuite))
}
type PluginSuite struct {
suite.Suite
db *testdb.Database
a *PluginAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
manager *plugin.Manager
notified bool
}
func (s *PluginSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
s.db = testdb.NewDB(s.T())
s.resetRecorder()
manager, err := plugin.NewManager(s.db, "", nil, s)
assert.Nil(s.T(), err)
s.manager = manager
withURL(s.ctx, "http", "example.com")
s.a = &PluginAPI{DB: s.db, Manager: manager, Notifier: s}
mockPluginCompat := new(mock.Plugin)
assert.Nil(s.T(), s.manager.LoadPlugin(mockPluginCompat))
s.db.User(1)
assert.Nil(s.T(), s.manager.InitializeForUserID(1))
s.db.User(2)
assert.Nil(s.T(), s.manager.InitializeForUserID(2))
s.db.CreatePluginConf(&model.PluginConf{
UserID: 1,
ModulePath: "github.com/gotify/server/v2/plugin/example/removed",
Token: "P1234",
Enabled: false,
})
}
func (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf {
conf, err := s.db.GetPluginConfByUserAndPath(uid, "github.com/gotify/server/v2/plugin/example/removed")
assert.NoError(s.T(), err)
return conf
}
func (s *PluginSuite) resetRecorder() {
s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder)
}
func (s *PluginSuite) AfterTest(suiteName, testName string) {
s.db.Close()
}
func (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) {
s.notified = true
}
func (s *PluginSuite) Test_GetPlugins() {
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", "/plugin", nil)
s.a.GetPlugins(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
pluginConfs := make([]model.PluginConfExternal, 0)
assert.Nil(s.T(), json.Unmarshal(s.recorder.Body.Bytes(), &pluginConfs))
assert.Equal(s.T(), mock.Name, pluginConfs[0].Name)
assert.Equal(s.T(), mock.ModulePath, pluginConfs[0].ModulePath)
assert.False(s.T(), pluginConfs[0].Enabled, "Plugins should be disabled by default")
}
func (s *PluginSuite) Test_EnableDisablePlugin() {
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.True(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.True(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
}
func (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expect500() {
s.db.User(16)
assert.Nil(s.T(), s.manager.InitializeForUserID(16))
mock.ReturnErrorOnEnableForUser(16, errors.New("test error"))
conf, err := s.db.GetPluginConfByUserAndPath(16, mock.ModulePath)
assert.NoError(s.T(), err)
{
test.WithUser(s.ctx, 16)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 500, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
}
func (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_expect500() {
s.db.User(17)
assert.Nil(s.T(), s.manager.InitializeForUserID(17))
mock.ReturnErrorOnDisableForUser(17, errors.New("test error"))
conf, err := s.db.GetPluginConfByUserAndPath(17, mock.ModulePath)
assert.NoError(s.T(), err)
s.manager.SetPluginEnabled(conf.ID, true)
{
test.WithUser(s.ctx, 17)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 500, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
}
func (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNotFound() {
{
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
{
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
assert.False(s.T(), pluginConf.Enabled)
}
s.resetRecorder()
}
}
func (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNotFound() {
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/enable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.resetRecorder()
}
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/disable", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.resetRecorder()
}
}
func (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotFound() {
conf := s.getDanglingConf(1)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.EnablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.resetRecorder()
}
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.DisablePlugin(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
s.resetRecorder()
}
}
func (s *PluginSuite) Test_GetDisplay() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
mockInst.DisplayString = "test string"
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetDisplay(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.JSONEquals(s.T(), mockInst.DisplayString, s.recorder.Body.String())
}
}
func (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
mockInst.SetCapability(compat.Displayer, false)
defer mockInst.SetCapability(compat.Displayer, true)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetDisplay(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.JSONEquals(s.T(), "", s.recorder.Body.String())
}
}
func (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
mockInst.DisplayString = "test string"
{
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetDisplay(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() {
conf := s.getDanglingConf(1)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetDisplay(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() {
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/display", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
s.a.GetDisplay(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetConfig() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
assert.Equal(s.T(), mockInst.DefaultConfig(), mockInst.Config, "Initial config should be default config")
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetConfig(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
returnedConfig := new(mock.PluginConfig)
assert.Nil(s.T(), yaml.Unmarshal(s.recorder.Body.Bytes(), returnedConfig))
assert.Equal(s.T(), mockInst.Config, returnedConfig)
}
}
func (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
mockInst.SetCapability(compat.Configurer, false)
defer mockInst.SetCapability(compat.Configurer, true)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetConfig(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
{
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() {
conf := s.getDanglingConf(1)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.GetConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() {
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/config", nil)
s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
s.a.GetConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_UpdateConfig() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
newConfig := &mock.PluginConfig{
TestKey: "test__new__config",
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), newConfig, mockInst.Config, "config should be received by plugin")
var pluginFromDBBytes []byte
if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
pluginFromDBBytes = pluginConf.Config
}
pluginFromDB := new(mock.PluginConfig)
err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
assert.Nil(s.T(), err)
assert.Equal(s.T(), newConfig, pluginFromDB, "config should be updated in database")
}
}
func (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
origConfig := mockInst.Config
newConfig := &mock.PluginConfig{
TestKey: "test__new__config__invalid",
IsNotValid: true,
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")
var pluginFromDBBytes []byte
if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
pluginFromDBBytes = pluginConf.Config
}
pluginFromDB := new(mock.PluginConfig)
err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
assert.Nil(s.T(), err)
assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
}
}
func (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
origConfig := mockInst.Config
newConfigYAML := []byte(`--- "rg e""`)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")
var pluginFromDBBytes []byte
if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
pluginFromDBBytes = pluginConf.Config
}
pluginFromDB := new(mock.PluginConfig)
err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
assert.Nil(s.T(), err)
assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
}
}
func (s *PluginSuite) Test_UpdateConfig_ioError_expect500() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
origConfig := mockInst.Config
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), test.UnreadableReader())
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 500, s.recorder.Code)
assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")
var pluginFromDBBytes []byte
if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
pluginFromDBBytes = pluginConf.Config
}
pluginFromDB := new(mock.PluginConfig)
err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
assert.Nil(s.T(), err)
assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
}
}
func (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
newConfig := &mock.PluginConfig{
TestKey: "test__new__config",
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
mockInst.SetCapability(compat.Configurer, false)
defer mockInst.SetCapability(compat.Configurer, true)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
}
func (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() {
conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
assert.NoError(s.T(), err)
inst, err := s.manager.Instance(conf.ID)
assert.Nil(s.T(), err)
mockInst := inst.(*mock.PluginInstance)
origConfig := mockInst.Config
newConfig := &mock.PluginConfig{
TestKey: "test__new__config",
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
{
test.WithUser(s.ctx, 2)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")
var pluginFromDBBytes []byte
if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
pluginFromDBBytes = pluginConf.Config
}
pluginFromDB := new(mock.PluginConfig)
err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
assert.Nil(s.T(), err)
assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
}
}
func (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() {
conf := s.getDanglingConf(1)
newConfig := &mock.PluginConfig{
TestKey: "test__new__config",
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
func (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() {
newConfig := &mock.PluginConfig{
TestKey: "test__new__config",
}
newConfigYAML, err := yaml.Marshal(newConfig)
assert.Nil(s.T(), err)
{
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/config", bytes.NewReader(newConfigYAML))
s.ctx.Header("Content-Type", "application/x-yaml")
s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
s.a.UpdateConfig(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
}
================================================
FILE: api/stream/client.go
================================================
package stream
import (
"fmt"
"time"
"github.com/gorilla/websocket"
"github.com/gotify/server/v2/model"
)
const (
writeWait = 2 * time.Second
)
var ping = func(conn *websocket.Conn) error {
return conn.WriteMessage(websocket.PingMessage, nil)
}
var writeJSON = func(conn *websocket.Conn, v interface{}) error {
return conn.WriteJSON(v)
}
type client struct {
conn *websocket.Conn
onClose func(*client)
write chan *model.MessageExternal
userID uint
token string
once once
}
func newClient(conn *websocket.Conn, userID uint, token string, onClose func(*client)) *client {
return &client{
conn: conn,
write: make(chan *model.MessageExternal, 1),
userID: userID,
token: token,
onClose: onClose,
}
}
// Close closes the connection.
func (c *client) Close() {
c.once.Do(func() {
c.conn.Close()
close(c.write)
})
}
// NotifyClose closes the connection and notifies that the connection was closed.
func (c *client) NotifyClose() {
c.once.Do(func() {
c.conn.Close()
close(c.write)
c.onClose(c)
})
}
// startWriteHandler starts listening on the client connection. As we do not need anything from the client,
// we ignore incoming messages. Leaves the loop on errors.
func (c *client) startReading(pongWait time.Duration) {
defer c.NotifyClose()
c.conn.SetReadLimit(64)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(appData string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
if _, _, err := c.conn.NextReader(); err != nil {
printWebSocketError("ReadError", err)
return
}
}
}
// startWriteHandler starts the write loop. The method has the following tasks:
// * ping the client in the interval provided as parameter
// * write messages send by the channel to the client
// * on errors exit the loop.
func (c *client) startWriteHandler(pingPeriod time.Duration) {
pingTicker := time.NewTicker(pingPeriod)
defer func() {
c.NotifyClose()
pingTicker.Stop()
}()
for {
select {
case message, ok := <-c.write:
if !ok {
return
}
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := writeJSON(c.conn, message); err != nil {
printWebSocketError("WriteError", err)
return
}
case <-pingTicker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := ping(c.conn); err != nil {
printWebSocketError("PingError", err)
return
}
}
}
}
func printWebSocketError(prefix string, err error) {
closeError, ok := err.(*websocket.CloseError)
if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) {
// normal closure
return
}
fmt.Println("WebSocket:", prefix, err)
}
================================================
FILE: api/stream/once.go
================================================
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package stream
import (
"sync"
"sync/atomic"
)
// Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go)
// This version unlocks the mutex early and therefore doesn't hold the lock while executing func f().
type once struct {
m sync.Mutex
done uint32
}
func (o *once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
if o.mayExecute() {
f()
}
}
func (o *once) mayExecute() bool {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
atomic.StoreUint32(&o.done, 1)
return true
}
return false
}
================================================
FILE: api/stream/once_test.go
================================================
package stream
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func Test_Execute(t *testing.T) {
executeOnce := once{}
execution := make(chan struct{})
fExecute := func() {
execution <- struct{}{}
}
go executeOnce.Do(fExecute)
go executeOnce.Do(fExecute)
select {
case <-execution:
// expected
case <-time.After(100 * time.Millisecond):
t.Fatal("fExecute should be executed once")
}
select {
case <-execution:
t.Fatal("should only execute once")
case <-time.After(100 * time.Millisecond):
// expected
}
assert.False(t, executeOnce.mayExecute())
go executeOnce.Do(fExecute)
select {
case <-execution:
t.Fatal("should only execute once")
case <-time.After(100 * time.Millisecond):
// expected
}
}
================================================
FILE: api/stream/stream.go
================================================
package stream
import (
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
)
// The API provides a handler for a WebSocket stream API.
type API struct {
clients map[uint][]*client
lock sync.RWMutex
pingPeriod time.Duration
pongTimeout time.Duration
upgrader *websocket.Upgrader
}
// New creates a new instance of API.
// pingPeriod: is the interval, in which is server sends the a ping to the client.
// pongTimeout: is the duration after the connection will be terminated, when the client does not respond with the
// pong command.
func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string) *API {
return &API{
clients: make(map[uint][]*client),
pingPeriod: pingPeriod,
pongTimeout: pingPeriod + pongTimeout,
upgrader: newUpgrader(allowedWebSocketOrigins),
}
}
// CollectConnectedClientTokens returns all tokens of the connected clients.
func (a *API) CollectConnectedClientTokens() []string {
a.lock.RLock()
defer a.lock.RUnlock()
var clients []string
for _, cs := range a.clients {
for _, c := range cs {
clients = append(clients, c.token)
}
}
return uniq(clients)
}
// NotifyDeletedUser closes existing connections for the given user.
func (a *API) NotifyDeletedUser(userID uint) error {
a.lock.Lock()
defer a.lock.Unlock()
if clients, ok := a.clients[userID]; ok {
for _, client := range clients {
client.Close()
}
delete(a.clients, userID)
}
return nil
}
// NotifyDeletedClient closes existing connections with the given token.
func (a *API) NotifyDeletedClient(userID uint, token string) {
a.lock.Lock()
defer a.lock.Unlock()
if clients, ok := a.clients[userID]; ok {
for i := len(clients) - 1; i >= 0; i-- {
client := clients[i]
if client.token == token {
client.Close()
clients = append(clients[:i], clients[i+1:]...)
}
}
a.clients[userID] = clients
}
}
// Notify notifies the clients with the given userID that a new messages was created.
func (a *API) Notify(userID uint, msg *model.MessageExternal) {
a.lock.RLock()
defer a.lock.RUnlock()
if clients, ok := a.clients[userID]; ok {
for _, c := range clients {
c.write <- msg
}
}
}
func (a *API) remove(remove *client) {
a.lock.Lock()
defer a.lock.Unlock()
if userIDClients, ok := a.clients[remove.userID]; ok {
for i, client := range userIDClients {
if client == remove {
a.clients[remove.userID] = append(userIDClients[:i], userIDClients[i+1:]...)
break
}
}
}
}
func (a *API) register(client *client) {
a.lock.Lock()
defer a.lock.Unlock()
a.clients[client.userID] = append(a.clients[client.userID], client)
}
// Handle handles incoming requests. First it upgrades the protocol to the WebSocket protocol and then starts listening
// for read and writes.
// swagger:operation GET /stream message streamMessages
//
// Websocket, return newly created messages.
//
// ---
// schema: ws, wss
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Message"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Server Error
// schema:
// $ref: "#/definitions/Error"
func (a *API) Handle(ctx *gin.Context) {
conn, err := a.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
ctx.Error(err)
return
}
client := newClient(conn, auth.GetUserID(ctx), auth.GetTokenID(ctx), a.remove)
a.register(client)
go client.startReading(a.pongTimeout)
go client.startWriteHandler(a.pingPeriod)
}
// Close closes all client connections and stops answering new connections.
func (a *API) Close() {
a.lock.Lock()
defer a.lock.Unlock()
for _, clients := range a.clients {
for _, client := range clients {
client.Close()
}
}
for k := range a.clients {
delete(a.clients, k)
}
}
func uniq[T comparable](s []T) []T {
m := make(map[T]struct{}, len(s))
r := make([]T, 0, len(s))
for _, v := range s {
if _, ok := m[v]; !ok {
m[v] = struct{}{}
r = append(r, v)
}
}
return r
}
func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
origin := r.Header.Get("origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
for _, allowedOrigin := range allowedOrigins {
if allowedOrigin.MatchString(strings.ToLower(u.Hostname())) {
return true
}
}
return false
}
func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {
compiledAllowedOrigins := compileAllowedWebSocketOrigins(allowedWebSocketOrigins)
return &websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
if mode.IsDev() {
return true
}
return isAllowedOrigin(r, compiledAllowedOrigins)
},
}
}
func compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.Regexp {
var compiledAllowedOrigins []*regexp.Regexp
for _, origin := range allowedOrigins {
compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin))
}
return compiledAllowedOrigins
}
================================================
FILE: api/stream/stream_test.go
================================================
package stream
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/fortytw2/leaktest"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
)
func TestFailureOnNormalHttpRequest(t *testing.T) {
mode.Set(mode.TestDev)
defer leaktest.Check(t)()
server, api := bootTestServer(staticUserID())
defer server.Close()
defer api.Close()
resp, err := http.Get(server.URL)
assert.Nil(t, err)
assert.Equal(t, 400, resp.StatusCode)
resp.Body.Close()
}
func TestWriteMessageFails(t *testing.T) {
mode.Set(mode.TestDev)
oldWrite := writeJSON
// try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know.
writeJSON = func(conn *websocket.Conn, v interface{}) error {
return errors.New("asd")
}
defer func() {
writeJSON = oldWrite
}()
defer leaktest.Check(t)()
server, api := bootTestServer(func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, 1, "")
})
defer server.Close()
defer api.Close()
wsURL := wsURL(server.URL)
user := testClient(t, wsURL)
waitForConnectedClients(api, 1)
clients := clients(api, 1)
assert.NotEmpty(t, clients)
api.Notify(1, &model.MessageExternal{Message: "HI"})
user.expectNoMessage()
}
func TestWritePingFails(t *testing.T) {
mode.Set(mode.TestDev)
oldPing := ping
// try emulate an write error, mostly this should kill the ReadMessage gorouting first but you'll never know.
ping = func(conn *websocket.Conn) error {
return errors.New("asd")
}
defer func() {
ping = oldPing
}()
defer leaktest.CheckTimeout(t, 10*time.Second)()
server, api := bootTestServer(staticUserID())
defer api.Close()
defer server.Close()
wsURL := wsURL(server.URL)
user := testClient(t, wsURL)
defer user.conn.Close()
waitForConnectedClients(api, 1)
clients := clients(api, 1)
assert.NotEmpty(t, clients)
time.Sleep(api.pingPeriod + (50 * time.Millisecond)) // waiting for ping
api.Notify(1, &model.MessageExternal{Message: "HI"})
user.expectNoMessage()
}
func TestPing(t *testing.T) {
mode.Set(mode.TestDev)
server, api := bootTestServer(staticUserID())
defer server.Close()
defer api.Close()
wsURL := wsURL(server.URL)
user := createClient(t, wsURL)
defer user.conn.Close()
ping := make(chan bool)
oldPingHandler := user.conn.PingHandler()
user.conn.SetPingHandler(func(appData string) error {
err := oldPingHandler(appData)
ping <- true
return err
})
startReading(user)
expectNoMessage(user)
select {
case <-time.After(2 * time.Second):
assert.Fail(t, "Expected ping but there was one :(")
case <-ping:
// expected
}
expectNoMessage(user)
api.Notify(1, &model.MessageExternal{Message: "HI"})
user.expectMessage(&model.MessageExternal{Message: "HI"})
}
func TestCloseClientOnNotReading(t *testing.T) {
mode.Set(mode.TestDev)
server, api := bootTestServer(staticUserID())
defer server.Close()
defer api.Close()
wsURL := wsURL(server.URL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
assert.Nil(t, err)
defer ws.Close()
waitForConnectedClients(api, 1)
assert.NotEmpty(t, clients(api, 1))
time.Sleep(api.pingPeriod + api.pongTimeout)
assert.Empty(t, clients(api, 1))
}
func TestMessageDirectlyAfterConnect(t *testing.T) {
mode.Set(mode.Prod)
defer leaktest.Check(t)()
server, api := bootTestServer(staticUserID())
defer server.Close()
defer api.Close()
wsURL := wsURL(server.URL)
user := testClient(t, wsURL)
defer user.conn.Close()
waitForConnectedClients(api, 1)
api.Notify(1, &model.MessageExternal{Message: "msg"})
user.expectMessage(&model.MessageExternal{Message: "msg"})
}
func TestDeleteClientShouldCloseConnection(t *testing.T) {
mode.Set(mode.Prod)
defer leaktest.Check(t)()
server, api := bootTestServer(staticUserID())
defer server.Close()
defer api.Close()
wsURL := wsURL(server.URL)
user := testClient(t, wsURL)
defer user.conn.Close()
waitForConnectedClients(api, 1)
api.Notify(1, &model.MessageExternal{Message: "msg"})
user.expectMessage(&model.MessageExternal{Message: "msg"})
api.NotifyDeletedClient(1, "customtoken")
api.Notify(1, &model.MessageExternal{Message: "msg"})
user.expectNoMessage()
}
func TestDeleteMultipleClients(t *testing.T) {
mode.Set(mode.TestDev)
defer leaktest.Check(t)()
userIDs := []uint{1, 1, 1, 1, 2, 2, 3}
tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"}
i := 0
server, api := bootTestServer(func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
i++
})
defer server.Close()
wsURL := wsURL(server.URL)
userOneIPhone := testClient(t, wsURL)
defer userOneIPhone.conn.Close()
userOneAndroid := testClient(t, wsURL)
defer userOneAndroid.conn.Close()
userOneBrowser := testClient(t, wsURL)
defer userOneBrowser.conn.Close()
userOneOther := testClient(t, wsURL)
defer userOneOther.conn.Close()
userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}
userTwoBrowser := testClient(t, wsURL)
defer userTwoBrowser.conn.Close()
userTwoAndroid := testClient(t, wsURL)
defer userTwoAndroid.conn.Close()
userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}
userThreeAndroid := testClient(t, wsURL)
defer userThreeAndroid.conn.Close()
userThree := []*testingClient{userThreeAndroid}
waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))
api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"})
expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.NotifyDeletedClient(1, "1-2")
api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"})
expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userOneIPhone, userOneOther)
expectNoMessage(userOneBrowser, userOneAndroid)
expectNoMessage(userThree...)
expectNoMessage(userTwo...)
api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
expectNoMessage(userOne...)
expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
expectNoMessage(userThree...)
api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"})
expectNoMessage(userOne...)
expectNoMessage(userTwo...)
expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...)
api.Close()
}
func TestDeleteUser(t *testing.T) {
mode.Set(mode.TestDev)
defer leaktest.Check(t)()
userIDs := []uint{1, 1, 1, 1, 2, 2, 3}
tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"}
i := 0
server, api := bootTestServer(func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
i++
})
defer server.Close()
wsURL := wsURL(server.URL)
userOneIPhone := testClient(t, wsURL)
defer userOneIPhone.conn.Close()
userOneAndroid := testClient(t, wsURL)
defer userOneAndroid.conn.Close()
userOneBrowser := testClient(t, wsURL)
defer userOneBrowser.conn.Close()
userOneOther := testClient(t, wsURL)
defer userOneOther.conn.Close()
userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}
userTwoBrowser := testClient(t, wsURL)
defer userTwoBrowser.conn.Close()
userTwoAndroid := testClient(t, wsURL)
defer userTwoAndroid.conn.Close()
userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}
userThreeAndroid := testClient(t, wsURL)
defer userThreeAndroid.conn.Close()
userThree := []*testingClient{userThreeAndroid}
waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))
api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"})
expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.NotifyDeletedUser(1)
api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"})
expectNoMessage(userOne...)
expectNoMessage(userThree...)
expectNoMessage(userTwo...)
api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
expectNoMessage(userOne...)
expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
expectNoMessage(userThree...)
api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"})
expectNoMessage(userOne...)
expectNoMessage(userTwo...)
expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...)
api.Close()
}
func TestCollectConnectedClientTokens(t *testing.T) {
mode.Set(mode.TestDev)
defer leaktest.Check(t)()
userIDs := []uint{1, 1, 1, 2, 2}
tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"}
i := 0
server, api := bootTestServer(func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
i++
})
defer server.Close()
wsURL := wsURL(server.URL)
userOneConnOne := testClient(t, wsURL)
defer userOneConnOne.conn.Close()
userOneConnTwo := testClient(t, wsURL)
defer userOneConnTwo.conn.Close()
userOneConnThree := testClient(t, wsURL)
defer userOneConnThree.conn.Close()
waitForConnectedClients(api, 3)
ret := api.CollectConnectedClientTokens()
sort.Strings(ret)
assert.Equal(t, []string{"1-1", "1-2"}, ret)
userTwoConnOne := testClient(t, wsURL)
defer userTwoConnOne.conn.Close()
userTwoConnTwo := testClient(t, wsURL)
defer userTwoConnTwo.conn.Close()
waitForConnectedClients(api, 5)
ret = api.CollectConnectedClientTokens()
sort.Strings(ret)
assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret)
}
func TestMultipleClients(t *testing.T) {
mode.Set(mode.TestDev)
defer leaktest.Check(t)()
userIDs := []uint{1, 1, 1, 2, 2, 3}
i := 0
server, api := bootTestServer(func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, userIDs[i], "t"+fmt.Sprint(userIDs[i]))
i++
})
defer server.Close()
wsURL := wsURL(server.URL)
userOneIPhone := testClient(t, wsURL)
defer userOneIPhone.conn.Close()
userOneAndroid := testClient(t, wsURL)
defer userOneAndroid.conn.Close()
userOneBrowser := testClient(t, wsURL)
defer userOneBrowser.conn.Close()
userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone}
userTwoBrowser := testClient(t, wsURL)
defer userTwoBrowser.conn.Close()
userTwoAndroid := testClient(t, wsURL)
defer userTwoAndroid.conn.Close()
userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}
userThreeAndroid := testClient(t, wsURL)
defer userThreeAndroid.conn.Close()
userThree := []*testingClient{userThreeAndroid}
waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))
// there should not be messages at the beginning
expectNoMessage(userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.Notify(1, &model.MessageExternal{ID: 1, Message: "hello"})
time.Sleep(500 * time.Millisecond)
expectMessage(&model.MessageExternal{ID: 1, Message: "hello"}, userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
expectNoMessage(userOne...)
expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
expectNoMessage(userThree...)
userOneIPhone.conn.Close()
expectNoMessage(userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.Notify(1, &model.MessageExternal{ID: 3, Message: "how"})
expectMessage(&model.MessageExternal{ID: 3, Message: "how"}, userOneAndroid, userOneBrowser)
expectNoMessage(userOneIPhone)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
api.Notify(2, &model.MessageExternal{ID: 4, Message: "are"})
expectNoMessage(userOne...)
expectMessage(&model.MessageExternal{ID: 4, Message: "are"}, userTwo...)
expectNoMessage(userThree...)
api.Close()
api.Notify(2, &model.MessageExternal{ID: 5, Message: "you"})
expectNoMessage(userOne...)
expectNoMessage(userTwo...)
expectNoMessage(userThree...)
}
func Test_sameOrigin_returnsTrue(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com/stream", nil)
req.Header.Set("Origin", "http://example.com")
actual := isAllowedOrigin(req, nil)
assert.True(t, actual)
}
func Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com:8080/stream", nil)
req.Header.Set("Origin", "http://example.com:8080")
actual := isAllowedOrigin(req, nil)
assert.True(t, actual)
}
func Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com/stream", nil)
req.Header.Set("Origin", "http://gorify.example.com")
actual := isAllowedOrigin(req, nil)
assert.False(t, actual)
}
func Test_isAllowedOriginMatching(t *testing.T) {
mode.Set(mode.Prod)
compiledAllowedOrigins := compileAllowedWebSocketOrigins([]string{"go.{4}\\.example\\.com", "go\\.example\\.com"})
req := httptest.NewRequest("GET", "http://example.me/stream", nil)
req.Header.Set("Origin", "http://gorify.example.com")
assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))
req.Header.Set("Origin", "http://go.example.com")
assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))
req.Header.Set("Origin", "http://hello.example.com")
assert.False(t, isAllowedOrigin(req, compiledAllowedOrigins))
}
func Test_emptyOrigin_returnsTrue(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com/stream", nil)
actual := isAllowedOrigin(req, nil)
assert.True(t, actual)
}
func Test_otherOrigin_returnsFalse(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com/stream", nil)
req.Header.Set("Origin", "http://otherexample.de")
actual := isAllowedOrigin(req, nil)
assert.False(t, actual)
}
func Test_invalidOrigin_returnsFalse(t *testing.T) {
mode.Set(mode.Prod)
req := httptest.NewRequest("GET", "http://example.com/stream", nil)
req.Header.Set("Origin", "http\\://otherexample.de")
actual := isAllowedOrigin(req, nil)
assert.False(t, actual)
}
func Test_compileAllowedWebSocketOrigins(t *testing.T) {
assert.Equal(t, 0, len(compileAllowedWebSocketOrigins([]string{})))
assert.Equal(t, 3, len(compileAllowedWebSocketOrigins([]string{"^.*$", "", "abc"})))
}
func clients(api *API, user uint) []*client {
api.lock.RLock()
defer api.lock.RUnlock()
return api.clients[user]
}
func countClients(a *API) int {
a.lock.RLock()
defer a.lock.RUnlock()
var i int
for _, clients := range a.clients {
i += len(clients)
}
return i
}
func testClient(t *testing.T, url string) *testingClient {
client := createClient(t, url)
startReading(client)
return client
}
func startReading(client *testingClient) {
go func() {
for {
_, payload, err := client.conn.ReadMessage()
if err != nil {
return
}
actual := &model.MessageExternal{}
json.NewDecoder(bytes.NewBuffer(payload)).Decode(actual)
client.readMessage <- *actual
}
}()
}
func createClient(t *testing.T, url string) *testingClient {
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
assert.Nil(t, err)
readMessages := make(chan model.MessageExternal)
return &testingClient{conn: ws, readMessage: readMessages, t: t}
}
type testingClient struct {
conn *websocket.Conn
readMessage chan model.MessageExternal
t *testing.T
}
func (c *testingClient) expectMessage(expected *model.MessageExternal) {
select {
case <-time.After(50 * time.Millisecond):
assert.Fail(c.t, "Expected message but none was send :(")
case actual := <-c.readMessage:
assert.Equal(c.t, *expected, actual)
}
}
func expectMessage(expected *model.MessageExternal, clients ...*testingClient) {
for _, client := range clients {
client.expectMessage(expected)
}
}
func expectNoMessage(clients ...*testingClient) {
for _, client := range clients {
client.expectNoMessage()
}
}
func (c *testingClient) expectNoMessage() {
select {
case <-time.After(50 * time.Millisecond):
// no message == as expected
case msg := <-c.readMessage:
assert.Fail(c.t, "Expected NO message but there was one :(", fmt.Sprint(msg))
}
}
func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) {
r := gin.New()
r.Use(handlerFunc)
// ping every 500 ms, and the client has 500 ms to respond
api := New(500*time.Millisecond, 500*time.Millisecond, []string{})
r.GET("/", api.Handle)
server := httptest.NewServer(r)
return server, api
}
func wsURL(httpURL string) string {
return "ws" + strings.TrimPrefix(httpURL, "http")
}
func staticUserID() gin.HandlerFunc {
return func(context *gin.Context) {
auth.RegisterAuthentication(context, nil, 1, "customtoken")
}
}
func waitForConnectedClients(api *API, count int) {
for i := 0; i < 10; i++ {
if countClients(api) == count {
// ok
return
}
time.Sleep(10 * time.Millisecond)
}
}
================================================
FILE: api/tokens.go
================================================
package api
import (
"github.com/gotify/server/v2/auth"
)
var generateApplicationToken = auth.GenerateApplicationToken
var generateClientToken = auth.GenerateClientToken
var generateImageName = auth.GenerateImageName
================================================
FILE: api/tokens_test.go
================================================
package api
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTokenGeneration(t *testing.T) {
assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken())
assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken())
assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName())
}
================================================
FILE: api/user.go
================================================
package api
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/model"
)
// The UserDatabase interface for encapsulating database access.
type UserDatabase interface {
GetUsers() ([]*model.User, error)
GetUserByID(id uint) (*model.User, error)
GetUserByName(name string) (*model.User, error)
DeleteUserByID(id uint) error
UpdateUser(user *model.User) error
CreateUser(user *model.User) error
CountUser(condition ...interface{}) (int64, error)
}
// UserChangeNotifier notifies listeners for user changes.
type UserChangeNotifier struct {
userDeletedCallbacks []func(uid uint) error
userAddedCallbacks []func(uid uint) error
}
// OnUserDeleted is called on user deletion.
func (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) {
c.userDeletedCallbacks = append(c.userDeletedCallbacks, cb)
}
// OnUserAdded is called on user creation.
func (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) {
c.userAddedCallbacks = append(c.userAddedCallbacks, cb)
}
func (c *UserChangeNotifier) fireUserDeleted(uid uint) error {
for _, cb := range c.userDeletedCallbacks {
if err := cb(uid); err != nil {
return err
}
}
return nil
}
func (c *UserChangeNotifier) fireUserAdded(uid uint) error {
for _, cb := range c.userAddedCallbacks {
if err := cb(uid); err != nil {
return err
}
}
return nil
}
// The UserAPI provides handlers for managing users.
type UserAPI struct {
DB UserDatabase
PasswordStrength int
UserChangeNotifier *UserChangeNotifier
Registration bool
}
// GetUsers returns all the users
// swagger:operation GET /user user getUsers
//
// Return all users.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// type: array
// items:
// $ref: "#/definitions/User"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) GetUsers(ctx *gin.Context) {
users, err := a.DB.GetUsers()
if success := successOrAbort(ctx, 500, err); !success {
return
}
var resp []*model.UserExternal
for _, user := range users {
resp = append(resp, toExternalUser(user))
}
ctx.JSON(200, resp)
}
// GetCurrentUser returns the current user
// swagger:operation GET /current/user user currentUser
//
// Return the current user.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/User"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) GetCurrentUser(ctx *gin.Context) {
user, err := a.DB.GetUserByID(auth.GetUserID(ctx))
if success := successOrAbort(ctx, 500, err); !success {
return
}
ctx.JSON(200, toExternalUser(user))
}
// CreateUser create a user.
// swagger:operation POST /user user createUser
//
// Create a user.
//
// With enabled registration: non admin users can be created without authentication.
// With disabled registrations: users can only be created by admin users.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the user to add
// required: true
// schema:
// $ref: "#/definitions/CreateUserExternal"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/User"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) CreateUser(ctx *gin.Context) {
user := model.CreateUserExternal{}
if err := ctx.Bind(&user); err == nil {
internal := &model.User{
Name: user.Name,
Admin: user.Admin,
Pass: password.CreatePassword(user.Pass, a.PasswordStrength),
}
existingUser, err := a.DB.GetUserByName(internal.Name)
if success := successOrAbort(ctx, 500, err); !success {
return
}
var requestedBy *model.User
uid := auth.TryGetUserID(ctx)
if uid != nil {
requestedBy, err = a.DB.GetUserByID(*uid)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("could not get user: %s", err))
return
}
}
if requestedBy == nil || !requestedBy.Admin {
status := http.StatusUnauthorized
if requestedBy != nil {
status = http.StatusForbidden
}
if !a.Registration {
ctx.AbortWithError(status, errors.New("you are not allowed to access this api"))
return
}
if internal.Admin {
ctx.AbortWithError(status, errors.New("you are not allowed to create an admin user"))
return
}
}
if existingUser == nil {
if success := successOrAbort(ctx, 500, a.DB.CreateUser(internal)); !success {
return
}
if err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.JSON(200, toExternalUser(internal))
} else {
ctx.AbortWithError(400, errors.New("username already exists"))
}
}
}
// GetUserByID returns the user by id
// swagger:operation GET /user/{id} user getUser
//
// Get a user.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the user id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/User"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) GetUserByID(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
user, err := a.DB.GetUserByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if user != nil {
ctx.JSON(200, toExternalUser(user))
} else {
ctx.AbortWithError(404, errors.New("user does not exist"))
}
})
}
// DeleteUserByID deletes the user by id
// swagger:operation DELETE /user/{id} user deleteUser
//
// Deletes a user.
//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the user id
// required: true
// type: integer
// format: int64
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) DeleteUserByID(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
user, err := a.DB.GetUserByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if user != nil {
adminCount, err := a.DB.CountUser(&model.User{Admin: true})
if success := successOrAbort(ctx, 500, err); !success {
return
}
if user.Admin && adminCount == 1 {
ctx.AbortWithError(400, errors.New("cannot delete last admin"))
return
}
if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil {
ctx.AbortWithError(500, err)
return
}
successOrAbort(ctx, 500, a.DB.DeleteUserByID(id))
} else {
ctx.AbortWithError(404, errors.New("user does not exist"))
}
})
}
// ChangePassword changes the password from the current user
// swagger:operation POST /current/user/password user updateCurrentUser
//
// Update the password of the current user.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the user
// required: true
// schema:
// $ref: "#/definitions/UserPass"
// responses:
// 200:
// description: Ok
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) ChangePassword(ctx *gin.Context) {
pw := model.UserExternalPass{}
if err := ctx.Bind(&pw); err == nil {
user, err := a.DB.GetUserByID(auth.GetUserID(ctx))
if success := successOrAbort(ctx, 500, err); !success {
return
}
user.Pass = password.CreatePassword(pw.Pass, a.PasswordStrength)
successOrAbort(ctx, 500, a.DB.UpdateUser(user))
}
}
// UpdateUserByID updates and user by id
// swagger:operation POST /user/{id} user updateUser
//
// Update a user.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: id
// in: path
// description: the user id
// required: true
// type: integer
// format: int64
// - name: body
// in: body
// description: the updated user
// required: true
// schema:
// $ref: "#/definitions/UpdateUserExternal"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/User"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *UserAPI) UpdateUserByID(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
var user *model.UpdateUserExternal
if err := ctx.Bind(&user); err == nil {
oldUser, err := a.DB.GetUserByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if oldUser != nil {
adminCount, err := a.DB.CountUser(&model.User{Admin: true})
if success := successOrAbort(ctx, 500, err); !success {
return
}
if !user.Admin && oldUser.Admin && adminCount == 1 {
ctx.AbortWithError(400, errors.New("cannot delete last admin"))
return
}
internal := &model.User{
ID: oldUser.ID,
Name: user.Name,
Admin: user.Admin,
Pass: oldUser.Pass,
}
if user.Pass != "" {
internal.Pass = password.CreatePassword(user.Pass, a.PasswordStrength)
}
if success := successOrAbort(ctx, 500, a.DB.UpdateUser(internal)); !success {
return
}
ctx.JSON(200, toExternalUser(internal))
} else {
ctx.AbortWithError(404, errors.New("user does not exist"))
}
}
})
}
func toExternalUser(internal *model.User) *model.UserExternal {
return &model.UserExternal{
Name: internal.Name,
Admin: internal.Admin,
ID: internal.ID,
}
}
================================================
FILE: api/user_test.go
================================================
package api
import (
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
func TestUserSuite(t *testing.T) {
suite.Run(t, new(UserSuite))
}
type UserSuite struct {
suite.Suite
db *testdb.Database
a *UserAPI
ctx *gin.Context
recorder *httptest.ResponseRecorder
notifiedAdd bool
notifiedDelete bool
notifier *UserChangeNotifier
}
func (s *UserSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder)
s.db = testdb.NewDB(s.T())
s.notifier = new(UserChangeNotifier)
s.notifier.OnUserDeleted(func(uint) error {
s.notifiedDelete = true
return nil
})
s.notifier.OnUserAdded(func(uint) error {
s.notifiedAdd = true
return nil
})
s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier}
}
func (s *UserSuite) AfterTest(suiteName, testName string) {
s.db.Close()
}
func (s *UserSuite) Test_GetUsers() {
first := s.db.NewUser(2)
second := s.db.NewUser(5)
s.a.GetUsers(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), []*model.UserExternal{externalOf(first), externalOf(second)}, s.recorder)
}
func (s *UserSuite) Test_GetCurrentUser() {
user := s.db.NewUser(5)
test.WithUser(s.ctx, 5)
s.a.GetCurrentUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), externalOf(user), s.recorder)
}
func (s *UserSuite) Test_GetUserByID() {
user := s.db.NewUser(2)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.GetUserByID(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
test.BodyEquals(s.T(), externalOf(user), s.recorder)
}
func (s *UserSuite) Test_GetUserByID_InvalidID() {
s.db.User(2)
s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}
s.a.GetUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_GetUserByID_UnknownUser() {
s.db.User(2)
s.ctx.Params = gin.Params{{Key: "id", Value: "3"}}
s.a.GetUserByID(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *UserSuite) Test_DeleteUserByID_LastAdmin_Expect400() {
s.db.CreateUser(&model.User{
ID: 7,
Name: "admin",
Admin: true,
})
s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}
s.a.DeleteUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_DeleteUserByID_InvalidID() {
s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}
s.a.DeleteUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_DeleteUserByID_UnknownUser() {
s.db.User(2)
s.ctx.Params = gin.Params{{Key: "id", Value: "3"}}
s.a.DeleteUserByID(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *UserSuite) Test_DeleteUserByID() {
assert.False(s.T(), s.notifiedDelete)
s.db.User(2)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.DeleteUserByID(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertUserNotExist(2)
assert.True(s.T(), s.notifiedDelete)
}
func (s *UserSuite) Test_DeleteUserByID_NotifyFail() {
s.db.User(5)
s.notifier.OnUserDeleted(func(id uint) error {
if id == 5 {
return errors.New("some error")
}
return nil
})
s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}
s.a.DeleteUserByID(s.ctx)
assert.Equal(s.T(), 500, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser() {
s.loginAdmin()
assert.False(s.T(), s.notifiedAdd)
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
user := &model.UserExternal{ID: 2, Name: "tom", Admin: true}
test.BodyEquals(s.T(), user, s.recorder)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
assert.True(s.T(), password.ComparePassword(created.Pass, []byte("mylittlepony")))
}
assert.True(s.T(), s.notifiedAdd)
}
func (s *UserSuite) Test_CreateUser_ByNonAdmin() {
s.loginUser()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() {
s.loginUser()
s.a.Registration = true
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
}
}
func (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() {
s.a.Registration = true
s.loginUser()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_Anonymous() {
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 401, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_Register_Anonymous() {
s.a.Registration = true
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
}
}
func (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() {
s.a.Registration = true
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 401, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_NotifyFail() {
s.loginAdmin()
s.notifier.OnUserAdded(func(id uint) error {
user, err := s.db.GetUserByID(id)
if err != nil {
return err
}
if user.Name == "eva" {
return errors.New("some error")
}
return nil
})
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "eva", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 500, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser_NoPassword() {
s.loginAdmin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser_NoName() {
s.loginAdmin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "", "pass": "asd", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser_NameAlreadyExists() {
s.loginAdmin()
s.db.NewUserWithName(2, "tom")
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_UpdateUserByID_InvalidID() {
s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}
s.ctx.Request = httptest.NewRequest("POST", "/user/abc", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_UpdateUserByID_LastAdmin_Expect400() {
s.db.CreateUser(&model.User{
ID: 7,
Name: "admin",
Admin: true,
})
s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}
s.ctx.Request = httptest.NewRequest("POST", "/user/7", strings.NewReader(`{"name": "admin", "pass": "", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *UserSuite) Test_UpdateUserByID_UnknownUser() {
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() {
s.db.CreateUser(&model.User{ID: 2, Name: "nico", Pass: password.CreatePassword("old", 5)})
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
user, err := s.db.GetUserByID(2)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), user)
assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old")))
}
func (s *UserSuite) Test_UpdateUserByID_UpdatePassword() {
s.db.CreateUser(&model.User{ID: 2, Name: "tom", Pass: password.CreatePassword("old", 5)})
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "new", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
user, err := s.db.GetUserByID(2)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), user)
assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new")))
}
func (s *UserSuite) Test_UpdatePassword() {
s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)})
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass": "new"}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.ChangePassword(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
user, err := s.db.GetUserByID(1)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), user)
assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new")))
}
func (s *UserSuite) Test_UpdatePassword_EmptyPassword() {
s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)})
test.WithUser(s.ctx, 1)
s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass":""}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.ChangePassword(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
user, err := s.db.GetUserByID(1)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), user)
assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old")))
}
func (s *UserSuite) loginAdmin() {
s.db.CreateUser(&model.User{ID: 1, Name: "admin", Admin: true})
auth.RegisterAuthentication(s.ctx, nil, 1, "")
}
func (s *UserSuite) loginUser() {
s.db.CreateUser(&model.User{ID: 1, Name: "user", Admin: false})
auth.RegisterAuthentication(s.ctx, nil, 1, "")
}
func (s *UserSuite) noLogin() {
auth.RegisterAuthentication(s.ctx, nil, 0, "")
}
func externalOf(user *model.User) *model.UserExternal {
return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID}
}
================================================
FILE: app.go
================================================
package main
import (
"fmt"
"os"
"github.com/gotify/server/v2/config"
"github.com/gotify/server/v2/database"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/router"
"github.com/gotify/server/v2/runner"
)
var (
// Version the version of Gotify.
Version = "unknown"
// Commit the git commit hash of this version.
Commit = "unknown"
// BuildDate the date on which this binary was build.
BuildDate = "unknown"
// Mode the build mode.
Mode = mode.Dev
)
func main() {
vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate}
mode.Set(Mode)
fmt.Println("Starting Gotify version", vInfo.Version+"@"+BuildDate)
conf := config.Get()
if conf.PluginsDir != "" {
if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil {
panic(err)
}
}
if err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil {
panic(err)
}
db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true)
if err != nil {
panic(err)
}
defer db.Close()
engine, closeable := router.Create(db, vInfo, conf)
defer closeable()
if err := runner.Run(engine, conf); err != nil {
fmt.Println("Server error: ", err)
os.Exit(1)
}
}
================================================
FILE: auth/authentication.go
================================================
package auth
import (
"errors"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/model"
)
const (
headerName = "X-Gotify-Key"
)
// The Database interface for encapsulating database access.
type Database interface {
GetApplicationByToken(token string) (*model.Application, error)
GetClientByToken(token string) (*model.Client, error)
GetPluginConfByToken(token string) (*model.PluginConf, error)
GetUserByName(name string) (*model.User, error)
GetUserByID(id uint) (*model.User, error)
UpdateClientTokensLastUsed(tokens []string, t *time.Time) error
UpdateApplicationTokenLastUsed(token string, t *time.Time) error
}
// Auth is the provider for authentication middleware.
type Auth struct {
DB Database
}
type authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error)
// RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied
// with the request. Also the authenticated user must be an administrator.
func (a *Auth) RequireAdmin() gin.HandlerFunc {
return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
if user != nil {
return true, user.Admin, user.ID, nil
}
if token, err := a.DB.GetClientByToken(tokenID); err != nil {
return false, false, 0, err
} else if token != nil {
user, err := a.DB.GetUserByID(token.UserID)
if err != nil {
return false, false, token.UserID, err
}
return true, user.Admin, token.UserID, nil
}
return false, false, 0, nil
})
}
// RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied
// with the request.
func (a *Auth) RequireClient() gin.HandlerFunc {
return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
if user != nil {
return true, true, user.ID, nil
}
if client, err := a.DB.GetClientByToken(tokenID); err != nil {
return false, false, 0, err
} else if client != nil {
now := time.Now()
if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) {
if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil {
return false, false, 0, err
}
}
return true, true, client.UserID, nil
}
return false, false, 0, nil
})
}
// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request.
func (a *Auth) RequireApplicationToken() gin.HandlerFunc {
return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
if user != nil {
return true, false, 0, nil
}
if app, err := a.DB.GetApplicationByToken(tokenID); err != nil {
return false, false, 0, err
} else if app != nil {
now := time.Now()
if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) {
if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil {
return false, false, 0, err
}
}
return true, true, app.UserID, nil
}
return false, false, 0, nil
})
}
func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string {
if token := a.tokenFromQuery(ctx); token != "" {
return token
} else if token := a.tokenFromXGotifyHeader(ctx); token != "" {
return token
} else if token := a.tokenFromAuthorizationHeader(ctx); token != "" {
return token
}
return ""
}
func (a *Auth) tokenFromQuery(ctx *gin.Context) string {
return ctx.Request.URL.Query().Get("token")
}
func (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string {
return ctx.Request.Header.Get(headerName)
}
func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string {
const prefix = "Bearer "
authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" {
return ""
}
if len(authHeader) < len(prefix) || !strings.EqualFold(prefix, authHeader[:len(prefix)]) {
return ""
}
return authHeader[len(prefix):]
}
func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) {
if name, pass, ok := ctx.Request.BasicAuth(); ok {
if user, err := a.DB.GetUserByName(name); err != nil {
return nil, err
} else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) {
return user, nil
}
}
return nil, nil
}
func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc {
return func(ctx *gin.Context) {
token := a.tokenFromQueryOrHeader(ctx)
user, err := a.userFromBasicAuth(ctx)
if err != nil {
ctx.AbortWithError(500, errors.New("an error occurred while authenticating user"))
return
}
if user != nil || token != "" {
authenticated, ok, userID, err := auth(token, user)
if err != nil {
ctx.AbortWithError(500, errors.New("an error occurred while authenticating user"))
return
} else if ok {
RegisterAuthentication(ctx, user, userID, token)
ctx.Next()
return
} else if authenticated {
ctx.AbortWithError(403, errors.New("you are not allowed to access this api"))
return
}
}
ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api"))
}
}
func (a *Auth) Optional() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := a.tokenFromQueryOrHeader(ctx)
user, err := a.userFromBasicAuth(ctx)
if err != nil {
RegisterAuthentication(ctx, nil, 0, "")
ctx.Next()
return
}
if user != nil {
RegisterAuthentication(ctx, user, user.ID, token)
ctx.Next()
return
} else if token != "" {
if tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil {
RegisterAuthentication(ctx, user, tokenClient.UserID, token)
ctx.Next()
return
}
}
RegisterAuthentication(ctx, nil, 0, "")
ctx.Next()
}
}
================================================
FILE: auth/authentication_test.go
================================================
package auth
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
func TestSuite(t *testing.T) {
suite.Run(t, new(AuthenticationSuite))
}
type AuthenticationSuite struct {
suite.Suite
auth *Auth
DB *testdb.Database
}
func (s *AuthenticationSuite) SetupSuite() {
mode.Set(mode.TestDev)
s.DB = testdb.NewDB(s.T())
s.auth = &Auth{s.DB}
s.DB.CreateUser(&model.User{
Name: "existing",
Pass: password.CreatePassword("pw", 5),
Admin: false,
Applications: []model.Application{{Token: "apptoken", Name: "backup server1", Description: "irrelevant"}},
Clients: []model.Client{{Token: "clienttoken", Name: "android phone1"}},
})
s.DB.CreateUser(&model.User{
Name: "admin",
Pass: password.CreatePassword("pw", 5),
Admin: true,
Applications: []model.Application{{Token: "apptoken_admin", Name: "backup server2", Description: "irrelevant"}},
Clients: []model.Client{{Token: "clienttoken_admin", Name: "android phone2"}},
})
}
func (s *AuthenticationSuite) TearDownSuite() {
s.DB.Close()
}
func (s *AuthenticationSuite) TestQueryToken() {
// not existing token
s.assertQueryRequest("token", "ergerogerg", s.auth.RequireApplicationToken, 401)
s.assertQueryRequest("token", "ergerogerg", s.auth.RequireClient, 401)
s.assertQueryRequest("token", "ergerogerg", s.auth.RequireAdmin, 401)
// not existing key
s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireApplicationToken, 401)
s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireClient, 401)
s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireAdmin, 401)
// apptoken
s.assertQueryRequest("token", "apptoken", s.auth.RequireApplicationToken, 200)
s.assertQueryRequest("token", "apptoken", s.auth.RequireClient, 401)
s.assertQueryRequest("token", "apptoken", s.auth.RequireAdmin, 401)
s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireApplicationToken, 200)
s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireClient, 401)
s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireAdmin, 401)
// clienttoken
s.assertQueryRequest("token", "clienttoken", s.auth.RequireApplicationToken, 401)
s.assertQueryRequest("token", "clienttoken", s.auth.RequireClient, 200)
s.assertQueryRequest("token", "clienttoken", s.auth.RequireAdmin, 403)
s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireApplicationToken, 401)
s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireClient, 200)
s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireAdmin, 200)
}
func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {
recorder := httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/?%s=%s", key, value), nil)
f()(ctx)
assert.Equal(s.T(), code, recorder.Code)
return ctx
}
func (s *AuthenticationSuite) TestNothingProvided() {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/", nil)
s.auth.RequireApplicationToken()(ctx)
assert.Equal(s.T(), 401, recorder.Code)
}
func (s *AuthenticationSuite) TestHeaderApiKeyToken() {
// not existing token
s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireClient, 401)
s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireAdmin, 401)
// not existing key
s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireClient, 401)
s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireAdmin, 401)
// apptoken
s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireApplicationToken, 200)
s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireClient, 401)
s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireAdmin, 401)
s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireApplicationToken, 200)
s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireClient, 401)
s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireAdmin, 401)
// clienttoken
s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireClient, 200)
s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireAdmin, 403)
s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireClient, 200)
s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireAdmin, 200)
}
func (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() {
// not existing token
s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireAdmin, 401)
// no authentication schema
s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireAdmin, 401)
// wrong authentication schema
s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireAdmin, 401)
// Authorization Bearer apptoken
s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireApplicationToken, 200)
s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireAdmin, 401)
s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireApplicationToken, 200)
s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireAdmin, 401)
// Authorization Bearer clienttoken
s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireClient, 200)
s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireAdmin, 403)
s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireClient, 200)
s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireAdmin, 200)
}
func (s *AuthenticationSuite) TestBasicAuth() {
s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireAdmin, 401)
// user existing:pw
s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 403)
s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 200)
s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 403)
// user admin:pw
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireApplicationToken, 403)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireClient, 200)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 200)
// user admin:pwx
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireAdmin, 401)
// user notexisting:pw
s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 401)
s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 401)
}
func (s *AuthenticationSuite) TestOptionalAuth() {
// various invalid users
ctx := s.assertQueryRequest("token", "ergerogerg", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertQueryRequest("tokenx", "clienttoken", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "apptoken_admin", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
// user existing:pw
ctx = s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200)
assert.Equal(s.T(), uint(1), *TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "clienttoken", s.auth.Optional, 200)
assert.Equal(s.T(), uint(1), *TryGetUserID(ctx))
// user admin:pw
ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.Optional, 200)
assert.Equal(s.T(), uint(2), *TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "clienttoken_admin", s.auth.Optional, 200)
assert.Equal(s.T(), uint(2), *TryGetUserID(ctx))
}
func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {
recorder := httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/", nil)
ctx.Request.Header.Set(key, value)
f()(ctx)
assert.Equal(s.T(), code, recorder.Code)
return ctx
}
type fMiddleware func() gin.HandlerFunc
================================================
FILE: auth/cors.go
================================================
package auth
import (
"regexp"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gotify/server/v2/config"
"github.com/gotify/server/v2/mode"
)
// CorsConfig generates a config to use in gin cors middleware based on server configuration.
func CorsConfig(conf *config.Configuration) cors.Config {
corsConf := cors.Config{
MaxAge: 12 * time.Hour,
AllowBrowserExtensions: true,
}
if mode.IsDev() {
corsConf.AllowAllOrigins = true
corsConf.AllowMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}
corsConf.AllowHeaders = []string{
"X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin",
"Connection", "Accept-Encoding", "Accept-Language", "Host",
}
} else {
compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins)
corsConf.AllowMethods = conf.Server.Cors.AllowMethods
corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders
corsConf.AllowOriginFunc = func(origin string) bool {
for _, compiledOrigin := range compiledOrigins {
if compiledOrigin.MatchString(strings.ToLower(origin)) {
return true
}
}
return false
}
if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 {
corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin)
}
}
return corsConf
}
func headerIgnoreCase(conf *config.Configuration, search string) (value string) {
for key, value := range conf.Server.ResponseHeaders {
if strings.ToLower(key) == search {
return value
}
}
return ""
}
func compileAllowedCORSOrigins(allowedOrigins []string) []*regexp.Regexp {
var compiledAllowedOrigins []*regexp.Regexp
for _, origin := range allowedOrigins {
compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin))
}
return compiledAllowedOrigins
}
================================================
FILE: auth/cors_test.go
================================================
package auth
import (
"testing"
"time"
"github.com/gin-contrib/cors"
"github.com/gotify/server/v2/config"
"github.com/gotify/server/v2/mode"
"github.com/stretchr/testify/assert"
)
func TestCorsConfig(t *testing.T) {
mode.Set(mode.Prod)
serverConf := config.Configuration{}
serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"}
serverConf.Server.Cors.AllowHeaders = []string{"content-type"}
serverConf.Server.Cors.AllowMethods = []string{"GET"}
actual := CorsConfig(&serverConf)
allowF := actual.AllowOriginFunc
actual.AllowOriginFunc = nil // func cannot be checked with equal
assert.Equal(t, cors.Config{
AllowAllOrigins: false,
AllowHeaders: []string{"content-type"},
AllowMethods: []string{"GET"},
MaxAge: 12 * time.Hour,
AllowBrowserExtensions: true,
}, actual)
assert.NotNil(t, allowF)
assert.True(t, allowF("http://test.com"))
assert.False(t, allowF("https://test.com"))
assert.False(t, allowF("https://other.com"))
}
func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) {
mode.Set(mode.Prod)
serverConf := config.Configuration{}
serverConf.Server.ResponseHeaders = map[string]string{"Access-control-allow-origin": "https://example.com"}
actual := CorsConfig(&serverConf)
assert.NotNil(t, actual.AllowOriginFunc)
actual.AllowOriginFunc = nil // func cannot be checked with equal
assert.Equal(t, cors.Config{
AllowAllOrigins: false,
AllowOrigins: []string{"https://example.com"},
MaxAge: 12 * time.Hour,
AllowBrowserExtensions: true,
}, actual)
}
func TestDevCorsConfig(t *testing.T) {
mode.Set(mode.Dev)
serverConf := config.Configuration{}
serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"}
serverConf.Server.Cors.AllowHeaders = []string{"content-type"}
serverConf.Server.Cors.AllowMethods = []string{"GET"}
actual := CorsConfig(&serverConf)
assert.Equal(t, cors.Config{
AllowHeaders: []string{
"X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin",
"Connection", "Accept-Encoding", "Accept-Language", "Host",
},
AllowMethods: []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"},
MaxAge: 12 * time.Hour,
AllowAllOrigins: true,
AllowBrowserExtensions: true,
}, actual)
}
================================================
FILE: auth/password/password.go
================================================
package password
import "golang.org/x/crypto/bcrypt"
// CreatePassword returns a hashed version of the given password.
func CreatePassword(pw string, strength int) []byte {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), strength)
if err != nil {
panic(err)
}
return hashedPassword
}
// ComparePassword compares a hashed password with its possible plaintext equivalent.
func ComparePassword(hashedPassword, password []byte) bool {
return bcrypt.CompareHashAndPassword(hashedPassword, password) == nil
}
================================================
FILE: auth/password/password_test.go
================================================
package password
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPasswordSuccess(t *testing.T) {
password := CreatePassword("secret", 5)
assert.Equal(t, true, ComparePassword(password, []byte("secret")))
}
func TestPasswordFailure(t *testing.T) {
password := CreatePassword("secret", 5)
assert.Equal(t, false, ComparePassword(password, []byte("secretx")))
}
func TestBCryptFailure(t *testing.T) {
assert.Panics(t, func() { CreatePassword("secret", 12312) })
}
================================================
FILE: auth/token.go
================================================
package auth
import (
"crypto/rand"
"math/big"
)
var (
tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_")
randomTokenLength = 14
applicationPrefix = "A"
clientPrefix = "C"
pluginPrefix = "P"
randReader = rand.Reader
)
func randIntn(n int) int {
max := big.NewInt(int64(n))
res, err := rand.Int(randReader, max)
if err != nil {
panic("random source is not available")
}
return int(res.Int64())
}
// GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token.
func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string {
for {
token := generateToken()
if !tokenExists(token) {
return token
}
}
}
// GenerateApplicationToken generates an application token.
func GenerateApplicationToken() string {
return generateRandomToken(applicationPrefix)
}
// GenerateClientToken generates a client token.
func GenerateClientToken() string {
return generateRandomToken(clientPrefix)
}
// GeneratePluginToken generates a plugin token.
func GeneratePluginToken() string {
return generateRandomToken(pluginPrefix)
}
// GenerateImageName generates an image name.
func GenerateImageName() string {
return generateRandomString(25)
}
func generateRandomToken(prefix string) string {
return prefix + generateRandomString(randomTokenLength)
}
func generateRandomString(length int) string {
res := make([]byte, length)
for i := range res {
index := randIntn(len(tokenCharacters))
res[i] = tokenCharacters[index]
}
return string(res)
}
func init() {
randIntn(2)
}
================================================
FILE: auth/token_test.go
================================================
package auth
import (
"crypto/rand"
"fmt"
"strings"
"testing"
"github.com/gotify/server/v2/test"
"github.com/stretchr/testify/assert"
)
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.True(t, strings.HasPrefix(GeneratePluginToken(), "P"))
assert.NotEmpty(t, GenerateImageName())
}
}
func TestGenerateNotExistingToken(t *testing.T) {
count := 5
token := GenerateNotExistingToken(func() string {
return fmt.Sprint(count)
}, func(token string) bool {
count--
return token != "0"
})
assert.Equal(t, "0", token)
}
func TestBadCryptoReaderPanics(t *testing.T) {
assert.Panics(t, func() {
randReader = test.UnreadableReader()
defer func() {
randReader = rand.Reader
}()
randIntn(2)
})
}
================================================
FILE: auth/util.go
================================================
package auth
import (
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/model"
)
// RegisterAuthentication registers the user id, user and or token.
func RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tokenID string) {
ctx.Set("user", user)
ctx.Set("userid", userID)
ctx.Set("tokenid", tokenID)
}
// GetUserID returns the user id which was previously registered by RegisterAuthentication.
func GetUserID(ctx *gin.Context) uint {
id := TryGetUserID(ctx)
if id == nil {
panic("token and user may not be null")
}
return *id
}
// TryGetUserID returns the user id or nil if one is not set.
func TryGetUserID(ctx *gin.Context) *uint {
user := ctx.MustGet("user").(*model.User)
if user == nil {
userID := ctx.MustGet("userid").(uint)
if userID == 0 {
return nil
}
return &userID
}
return &user.ID
}
// GetTokenID returns the tokenID.
func GetTokenID(ctx *gin.Context) string {
return ctx.MustGet("tokenid").(string)
}
================================================
FILE: auth/util_test.go
================================================
package auth
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
func TestUtilSuite(t *testing.T) {
suite.Run(t, new(UtilSuite))
}
type UtilSuite struct {
suite.Suite
}
func (s *UtilSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
}
func (s *UtilSuite) Test_getID() {
s.expectUserIDWith(&model.User{ID: 2}, 0, 2)
s.expectUserIDWith(nil, 5, 5)
assert.Panics(s.T(), func() {
s.expectUserIDWith(nil, 0, 0)
})
s.expectTryUserIDWith(nil, 0, nil)
}
func (s *UtilSuite) Test_getToken() {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
RegisterAuthentication(ctx, nil, 1, "asdasda")
actualID := GetTokenID(ctx)
assert.Equal(s.T(), "asdasda", actualID)
}
func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
RegisterAuthentication(ctx, user, tokenUserID, "")
actualID := GetUserID(ctx)
assert.Equal(s.T(), expectedID, actualID)
}
func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
RegisterAuthentication(ctx, user, tokenUserID, "")
actualID := TryGetUserID(ctx)
assert.Equal(s.T(), expectedID, actualID)
}
================================================
FILE: config/config.go
================================================
package config
import (
"path/filepath"
"strings"
"github.com/gotify/server/v2/mode"
"github.com/jinzhu/configor"
)
// Configuration is stuff that can be configured externally per env variables or config file (config.yml).
type Configuration struct {
Server struct {
KeepAlivePeriodSeconds int
ListenAddr string `default:""`
Port int `default:"80"`
SSL struct {
Enabled bool `default:"false"`
RedirectToHTTPS bool `default:"true"`
ListenAddr string `default:""`
Port int `default:"443"`
CertFile string `default:""`
CertKey string `default:""`
LetsEncrypt struct {
Enabled bool `default:"false"`
AcceptTOS bool `default:"false"`
Cache string `default:"data/certs"`
DirectoryURL string `default:""`
Hosts []string
}
}
ResponseHeaders map[string]string
Stream struct {
PingPeriodSeconds int `default:"45"`
AllowedOrigins []string
}
Cors struct {
AllowOrigins []string
AllowMethods []string
AllowHeaders []string
}
TrustedProxies []string
}
Database struct {
Dialect string `default:"sqlite3"`
Connection string `default:"data/gotify.db"`
}
DefaultUser struct {
Name string `default:"admin"`
Pass string `default:"admin"`
}
PassStrength int `default:"10"`
UploadedImagesDir string `default:"data/images"`
PluginsDir string `default:"data/plugins"`
Registration bool `default:"false"`
}
func configFiles() []string {
if mode.Get() == mode.TestDev {
return []string{"config.yml"}
}
return []string{"config.yml", "/etc/gotify/config.yml"}
}
// Get returns the configuration extracted from env variables or config file.
func Get() *Configuration {
conf := new(Configuration)
err := configor.New(&configor.Config{ENVPrefix: "GOTIFY", Silent: true}).Load(conf, configFiles()...)
if err != nil {
panic(err)
}
addTrailingSlashToPaths(conf)
return conf
}
func addTrailingSlashToPaths(conf *Configuration) {
if !strings.HasSuffix(conf.UploadedImagesDir, "/") && !strings.HasSuffix(conf.UploadedImagesDir, "\\") {
conf.UploadedImagesDir += string(filepath.Separator)
}
}
================================================
FILE: config/config_test.go
================================================
package config
import (
"os"
"path/filepath"
"testing"
"github.com/gotify/server/v2/mode"
"github.com/stretchr/testify/assert"
)
func TestConfigEnv(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis")
os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "- push.example.tld\n- push.other.tld")
os.Setenv("GOTIFY_SERVER_RESPONSEHEADERS",
"Access-Control-Allow-Origin: \"*\"\nAccess-Control-Allow-Methods: \"GET,POST\"",
)
os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "- \"GET\"\n- \"POST\"")
os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "- \"Authorization\"\n- \"content-type\"")
os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"")
conf := Get()
assert.Equal(t, 80, conf.Server.Port, "should use defaults")
assert.Equal(t, "jmattheis", conf.DefaultUser.Name, "should not use default but env var")
assert.Equal(t, []string{"push.example.tld", "push.other.tld"}, conf.Server.SSL.LetsEncrypt.Hosts)
assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"])
assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"])
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Cors.AllowOrigins)
assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods)
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
os.Unsetenv("GOTIFY_DEFAULTUSER_NAME")
os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS")
os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS")
os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS")
os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS")
}
func TestAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images")
conf := Get()
assert.Equal(t, "../data/images"+string(filepath.Separator), conf.UploadedImagesDir)
os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
func TestNotAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/")
conf := Get()
assert.Equal(t, "../data/", conf.UploadedImagesDir)
os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
func TestFileWithSyntaxErrors(t *testing.T) {
mode.Set(mode.TestDev)
file, err := os.Create("config.yml")
defer func() {
file.Close()
}()
assert.Nil(t, err)
_, err = file.WriteString(`
sdgsgsdfgsdfg
`)
file.Close()
assert.Nil(t, err)
assert.Panics(t, func() {
Get()
})
assert.Nil(t, os.Remove("config.yml"))
}
func TestConfigFile(t *testing.T) {
mode.Set(mode.TestDev)
file, err := os.Create("config.yml")
defer func() {
file.Close()
}()
assert.Nil(t, err)
_, err = file.WriteString(`
server:
port: 1234
ssl:
port: 3333
letsencrypt:
hosts:
- push.example.tld
responseheaders:
Access-Control-Allow-Origin: "*"
Access-Control-Allow-Methods: "GET,POST"
cors:
alloworigins:
- ".*"
- ".+"
allowmethods:
- "GET"
- "POST"
allowheaders:
- "Authorization"
- "content-type"
stream:
allowedorigins:
- ".+.example.com"
- "otherdomain.com"
database:
dialect: mysql
connection: user name
defaultuser:
name: nicories
pass: 12345
pluginsdir: data/plugins
`)
file.Close()
assert.Nil(t, err)
conf := Get()
assert.Equal(t, 1234, conf.Server.Port)
assert.Equal(t, 3333, conf.Server.SSL.Port)
assert.Equal(t, []string{"push.example.tld"}, conf.Server.SSL.LetsEncrypt.Hosts)
assert.Equal(t, "nicories", conf.DefaultUser.Name)
assert.Equal(t, "12345", conf.DefaultUser.Pass)
assert.Equal(t, "mysql", conf.Database.Dialect)
assert.Equal(t, "user name", conf.Database.Connection)
assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"])
assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"])
assert.Equal(t, []string{".*", ".+"}, conf.Server.Cors.AllowOrigins)
assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods)
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
assert.Equal(t, "data/plugins", conf.PluginsDir)
assert.Nil(t, os.Remove("config.yml"))
}
================================================
FILE: config.example.yml
================================================
# Example configuration file for the server.
# Save it to `config.yml` when edited
server:
keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing.
listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock".
port: 80 # the port the HTTP server will listen on
ssl:
enabled: false # if https should be enabled
redirecttohttps: true # redirect to https if site is accessed by http
listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock".
port: 443 # the https port
certfile: # the cert file (leave empty when using letsencrypt)
certkey: # the cert key (leave empty when using letsencrypt)
letsencrypt:
enabled: false # if the certificate should be requested from letsencrypt
accepttos: false # if you accept the tos from letsencrypt
cache: data/certs # the directory of the cache from letsencrypt
directoryurl: # override the directory url of the ACME server
# Let's Encrypt highly recommend testing against their staging environment before using their production environment.
# Staging server has high rate limits for testing and debugging, issued certificates are not valid
# example: https://acme-staging-v02.api.letsencrypt.org/directory
hosts: # the hosts for which letsencrypt should request certificates
# - mydomain.tld
# - myotherdomain.tld
responseheaders: # response headers are added to every response (default: none)
# X-Custom-Header: "custom value"
#
trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets)
# - 127.0.0.1/32
# - ::1
cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers.
alloworigins:
# - ".+.example.com"
# - "otherdomain.com"
allowmethods:
# - "GET"
# - "POST"
allowheaders:
# - "Authorization"
# - "content-type"
stream:
pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing.
allowedorigins: # allowed origins for websocket connections (same origin is always allowed)
# - ".+.example.com"
# - "otherdomain.com"
database: # for database see (configure database section)
dialect: sqlite3
connection: data/gotify.db
defaultuser: # on database creation, gotify creates an admin user
name: admin # the username of the default user
pass: admin # the password of the default user
passstrength: 10 # the bcrypt password strength (higher = better but also slower)
uploadedimagesdir: data/images # the directory for storing uploaded images
pluginsdir: data/plugins # the directory where plugin resides
registration: false # enable registrations
================================================
FILE: database/application.go
================================================
package database
import (
"database/sql"
"time"
"github.com/gotify/server/v2/fracdex"
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
// GetApplicationByToken returns the application for the given token or nil.
func (d *GormDatabase) GetApplicationByToken(token string) (*model.Application, error) {
app := new(model.Application)
err := d.DB.Where("token = ?", token).Find(app).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if app.Token == token {
return app, err
}
return nil, err
}
// GetApplicationByID returns the application for the given id or nil.
func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) {
app := new(model.Application)
err := d.DB.Where("id = ?", id).Find(app).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if app.ID == id {
return app, err
}
return nil, err
}
// CreateApplication creates an application.
func (d *GormDatabase) CreateApplication(application *model.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.
func (d *GormDatabase) DeleteApplicationByID(id uint) error {
d.DeleteMessagesByApplication(id)
return d.DB.Where("id = ?", id).Delete(&model.Application{}).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("sort_key, id ASC").Find(&apps).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return apps, err
}
// UpdateApplication updates an application.
func (d *GormDatabase) UpdateApplication(app *model.Application) error {
return d.DB.Save(app).Error
}
// UpdateApplicationTokenLastUsed updates the last used time of the application token.
func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error {
return d.DB.Model(&model.Application{}).Where("token = ?", token).Update("last_used", t).Error
}
================================================
FILE: database/application_test.go
================================================
package database
import (
"time"
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
)
func (s *DatabaseSuite) TestApplication() {
if app, err := s.db.GetApplicationByToken("asdasdf"); assert.NoError(s.T(), err) {
assert.Nil(s.T(), app, "not existing app")
}
if app, err := s.db.GetApplicationByID(uint(1)); assert.NoError(s.T(), err) {
assert.Nil(s.T(), app, "not existing app")
}
user := &model.User{Name: "test", Pass: []byte{1}}
s.db.CreateUser(user)
assert.NotEqual(s.T(), 0, user.ID)
if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Empty(s.T(), apps)
}
app := &model.Application{UserID: user.ID, Token: "C0000000000", Name: "backupserver"}
s.db.CreateApplication(app)
if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Len(s.T(), apps, 1)
assert.Contains(s.T(), apps, app)
}
newApp, err := s.db.GetApplicationByToken(app.Token)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), app, newApp)
}
newApp, err = s.db.GetApplicationByID(app.ID)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), app, newApp)
}
lastUsed := time.Now().Add(-time.Hour)
s.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed)
newApp, err = s.db.GetApplicationByID(app.ID)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix())
}
app.LastUsed = &lastUsed
newApp.Image = "asdasd"
assert.NoError(s.T(), s.db.UpdateApplication(newApp))
newApp, err = s.db.GetApplicationByID(app.ID)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), "asdasd", newApp.Image)
}
assert.NoError(s.T(), s.db.DeleteApplicationByID(app.ID))
if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Empty(s.T(), apps)
}
if app, err := s.db.GetApplicationByID(app.ID); assert.NoError(s.T(), err) {
assert.Nil(s.T(), app)
}
}
func (s *DatabaseSuite) TestDeleteAppDeletesMessages() {
assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 55, Token: "token"}))
assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 66, Token: "token2"}))
assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 12, ApplicationID: 55}))
assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 13, ApplicationID: 66}))
assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 14, ApplicationID: 55}))
assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 15, ApplicationID: 55}))
assert.NoError(s.T(), s.db.DeleteApplicationByID(55))
if msg, err := s.db.GetMessageByID(12); assert.NoError(s.T(), err) {
assert.Nil(s.T(), msg)
}
if msg, err := s.db.GetMessageByID(13); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), msg)
}
if msg, err := s.db.GetMessageByID(14); assert.NoError(s.T(), err) {
assert.Nil(s.T(), msg)
}
if msg, err := s.db.GetMessageByID(15); assert.NoError(s.T(), err) {
assert.Nil(s.T(), msg)
}
if msgs, err := s.db.GetMessagesByApplication(55); assert.NoError(s.T(), err) {
assert.Empty(s.T(), msgs)
}
if msgs, err := s.db.GetMessagesByApplication(66); assert.NoError(s.T(), err) {
assert.NotEmpty(s.T(), msgs)
}
}
================================================
FILE: database/client.go
================================================
package database
import (
"time"
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
// GetClientByID returns the client for the given id or nil.
func (d *GormDatabase) GetClientByID(id uint) (*model.Client, error) {
client := new(model.Client)
err := d.DB.Where("id = ?", id).Find(client).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if client.ID == id {
return client, err
}
return nil, err
}
// GetClientByToken returns the client for the given token or nil.
func (d *GormDatabase) GetClientByToken(token string) (*model.Client, error) {
client := new(model.Client)
err := d.DB.Where("token = ?", token).Find(client).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if client.Token == token {
return client, err
}
return nil, err
}
// CreateClient creates a client.
func (d *GormDatabase) CreateClient(client *model.Client) error {
return d.DB.Create(client).Error
}
// GetClientsByUser returns all clients from a user.
func (d *GormDatabase) GetClientsByUser(userID uint) ([]*model.Client, error) {
var clients []*model.Client
err := d.DB.Where("user_id = ?", userID).Find(&clients).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return clients, err
}
// DeleteClientByID deletes a client by its id.
func (d *GormDatabase) DeleteClientByID(id uint) error {
return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error
}
// UpdateClient updates a client.
func (d *GormDatabase) UpdateClient(client *model.Client) error {
return d.DB.Save(client).Error
}
// UpdateClientTokensLastUsed updates the last used timestamp of clients.
func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error {
return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error
}
================================================
FILE: database/client_test.go
================================================
package database
import (
"time"
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
)
func (s *DatabaseSuite) TestClient() {
if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) {
assert.Nil(s.T(), client, "not existing client")
}
if client, err := s.db.GetClientByToken("asdasd"); assert.NoError(s.T(), err) {
assert.Nil(s.T(), client, "not existing client")
}
user := &model.User{Name: "test", Pass: []byte{1}}
s.db.CreateUser(user)
assert.NotEqual(s.T(), 0, user.ID)
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Empty(s.T(), clients)
}
client := &model.Client{UserID: user.ID, Token: "C0000000000", Name: "android"}
assert.NoError(s.T(), s.db.CreateClient(client))
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Len(s.T(), clients, 1)
assert.Contains(s.T(), clients, client)
}
newClient, err := s.db.GetClientByID(client.ID)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), client, newClient)
}
if newClient, err := s.db.GetClientByToken(client.Token); assert.NoError(s.T(), err) {
assert.Equal(s.T(), client, newClient)
}
updateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: "C0000000000", Name: "new_name"}
s.db.UpdateClient(updateClient)
if updatedClient, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) {
assert.Equal(s.T(), updateClient, updatedClient)
}
lastUsed := time.Now().Add(-time.Hour)
s.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed)
newClient, err = s.db.GetClientByID(client.ID)
if assert.NoError(s.T(), err) {
assert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix())
}
s.db.DeleteClientByID(client.ID)
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {
assert.Empty(s.T(), clients)
}
if client, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) {
assert.Nil(s.T(), client)
}
}
================================================
FILE: database/database.go
================================================
package database
import (
"database/sql"
"errors"
"fmt"
"log"
"math"
"os"
"path/filepath"
"time"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/fracdex"
"github.com/gotify/server/v2/model"
"github.com/mattn/go-isatty"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var mkdirAll = os.MkdirAll
// New creates a new wrapper for the gorm database framework.
func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) {
createDirectoryIfSqlite(dialect, connection)
dbLogger := logger.New(log.New(os.Stderr, "\r\n", log.LstdFlags), logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: isatty.IsTerminal(os.Stderr.Fd()),
})
gormConfig := &gorm.Config{
Logger: dbLogger,
DisableForeignKeyConstraintWhenMigrating: true,
TranslateError: true,
}
var db *gorm.DB
err := errors.New("unsupported dialect: " + dialect)
switch dialect {
case "mysql":
db, err = gorm.Open(mysql.Open(connection), gormConfig)
case "postgres":
db, err = gorm.Open(postgres.Open(connection), gormConfig)
case "sqlite3":
db, err = gorm.Open(sqlite.Open(connection), gormConfig)
}
if err != nil {
return nil, err
}
sqldb, err := db.DB()
if err != nil {
return nil, err
}
// We normally don't need that much connections, so we limit them. F.ex. mysql complains about
// "too many connections", while load testing Gotify.
sqldb.SetMaxOpenConns(10)
if dialect == "sqlite3" {
// We use the database connection inside the handlers from the http
// framework, therefore concurrent access occurs. Sqlite cannot handle
// concurrent writes, so we limit sqlite to one connection.
// see https://github.com/mattn/go-sqlite3/issues/274
sqldb.SetMaxOpenConns(1)
}
if dialect == "mysql" {
// Mysql has a setting called wait_timeout, which defines the duration
// after which a connection may not be used anymore.
// The default for this setting on mariadb is 10 minutes.
// See https://github.com/docker-library/mariadb/issues/113
sqldb.SetConnMaxLifetime(9 * time.Minute)
}
if err := db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf)); err != nil {
return nil, err
}
userCount := int64(0)
db.Find(new(model.User)).Count(&userCount)
if createDefaultUserIfNotExist && userCount == 0 {
db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true})
}
if err := db.Transaction(fillMissingSortKeys, &sql.TxOptions{Isolation: sql.LevelSerializable}); err != nil {
return nil, err
}
return &GormDatabase{DB: db}, nil
}
func fillMissingSortKeys(db *gorm.DB) error {
missingSort := int64(0)
if err := db.Model(new(model.Application)).Where("sort_key IS NULL OR sort_key = ''").Count(&missingSort).Error; err != nil {
return err
}
if missingSort == 0 {
return nil
}
var apps []*model.Application
if err := db.Order("user_id, sort_key, id ASC").Find(&apps).Error; err != nil && err != gorm.ErrRecordNotFound {
return err
}
fmt.Println("Migrating", len(apps), "application sort keys")
sortKey := ""
currentUser := uint(math.MaxUint)
var err error
for _, app := range apps {
if currentUser != app.UserID {
sortKey = ""
currentUser = app.UserID
}
sortKey, err = fracdex.KeyBetween(sortKey, "")
if err != nil {
return err
}
app.SortKey = sortKey
}
return db.Save(apps).Error
}
func createDirectoryIfSqlite(dialect, connection string) {
if dialect == "sqlite3" {
if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) {
if err := mkdirAll(filepath.Dir(connection), 0o777); err != nil {
panic(err)
}
}
}
}
// GormDatabase is a wrapper for the gorm framework.
type GormDatabase struct {
DB *gorm.DB
}
// Close closes the gorm database connection.
func (d *GormDatabase) Close() {
sqldb, err := d.DB.DB()
if err != nil {
return
}
sqldb.Close()
}
================================================
FILE: database/database_test.go
================================================
package database
import (
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/gorm"
)
func TestDatabaseSuite(t *testing.T) {
suite.Run(t, new(DatabaseSuite))
}
type DatabaseSuite struct {
suite.Suite
db *GormDatabase
tmpDir test.TmpDir
}
func (s *DatabaseSuite) BeforeTest(suiteName, testName string) {
s.tmpDir = test.NewTmpDir("gotify_databasesuite")
db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true)
assert.Nil(s.T(), err)
s.db = db
}
func (s *DatabaseSuite) AfterTest(suiteName, testName string) {
s.db.Close()
assert.Nil(s.T(), s.tmpDir.Clean())
}
func TestInvalidDialect(t *testing.T) {
tmpDir := test.NewTmpDir("gotify_testinvaliddialect")
defer tmpDir.Clean()
_, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true)
assert.Error(t, err)
}
func TestCreateSqliteFolder(t *testing.T) {
tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder")
defer tmpDir.Clean()
db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true)
assert.Nil(t, err)
assert.DirExists(t, tmpDir.Path("somepath"))
db.Close()
}
func TestWithAlreadyExistingSqliteFolder(t *testing.T) {
tmpDir := test.NewTmpDir("gotify_testwithexistingfolder")
defer tmpDir.Clean()
db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true)
assert.Nil(t, err)
assert.DirExists(t, tmpDir.Path("somepath"))
db.Close()
}
func TestPanicsOnMkdirError(t *testing.T) {
tmpDir := test.NewTmpDir("gotify_testpanicsonmkdirerror")
defer tmpDir.Clean()
mkdirAll = func(path string, perm os.FileMode) error {
return errors.New("ERROR")
}
assert.Panics(t, func() {
New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true)
})
}
func TestMigrateSortKey(t *testing.T) {
db, err := New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true)
assert.Nil(t, err)
assert.NotNil(t, db)
err = db.CreateApplication(&model.Application{Name: "one", Token: "one", UserID: 1})
assert.NoError(t, err)
err = db.CreateApplication(&model.Application{Name: "two", Token: "two", UserID: 1})
assert.NoError(t, err)
err = db.CreateApplication(&model.Application{Name: "three", Token: "three", UserID: 1})
assert.NoError(t, err)
err = db.CreateApplication(&model.Application{Name: "one-other", Token: "one-other", UserID: 2})
assert.NoError(t, err)
err = db.DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(new(model.Application)).UpdateColumn("sort_key", nil).Error
assert.NoError(t, err)
err = fillMissingSortKeys(db.DB)
assert.NoError(t, err)
apps, err := db.GetApplicationsByUser(1)
assert.NoError(t, err)
assert.Len(t, apps, 3)
assert.Equal(t, apps[0].Name, "one")
assert.Equal(t, apps[0].SortKey, "a0")
assert.Equal(t, apps[1].Name, "two")
assert.Equal(t, apps[1].SortKey, "a1")
assert.Equal(t, apps[2].Name, "three")
assert.Equal(t, apps[2].SortKey, "a2")
apps, err = db.GetApplicationsByUser(2)
assert.NoError(t, err)
assert.Len(t, apps, 1)
assert.Equal(t, apps[0].Name, "one-other")
assert.Equal(t, apps[0].SortKey, "a0")
}
================================================
FILE: database/message.go
================================================
package database
import (
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
// GetMessageByID returns the messages for the given id or nil.
func (d *GormDatabase) GetMessageByID(id uint) (*model.Message, error) {
msg := new(model.Message)
err := d.DB.Find(msg, id).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if msg.ID == id {
return msg, err
}
return nil, err
}
// CreateMessage creates a message.
func (d *GormDatabase) CreateMessage(message *model.Message) error {
return d.DB.Create(message).Error
}
// GetMessagesByUser returns all messages from a user.
func (d *GormDatabase) GetMessagesByUser(userID uint) ([]*model.Message, error) {
var messages []*model.Message
err := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID).
Where("messages.application_id = applications.id").Order("messages.id desc").Find(&messages).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return messages, err
}
// GetMessagesByUserSince returns limited messages from a user.
// If since is 0 it will be ignored.
func (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error) {
var messages []*model.Message
db := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID).
Where("messages.application_id = applications.id").Order("messages.id desc").Limit(limit)
if since != 0 {
db = db.Where("messages.id < ?", since)
}
err := db.Find(&messages).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return messages, err
}
// GetMessagesByApplication returns all messages from an application.
func (d *GormDatabase) GetMessagesByApplication(tokenID uint) ([]*model.Message, error) {
var messages []*model.Message
err := d.DB.Where("application_id = ?", tokenID).Order("messages.id desc").Find(&messages).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return messages, err
}
// GetMessagesByApplicationSince returns limited messages from an application.
// If since is 0 it will be ignored.
func (d *GormDatabase) GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error) {
var messages []*model.Message
db := d.DB.Where("application_id = ?", appID).Order("messages.id desc").Limit(limit)
if since != 0 {
db = db.Where("messages.id < ?", since)
}
err := db.Find(&messages).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return messages, err
}
// DeleteMessageByID deletes a message by its id.
func (d *GormDatabase) DeleteMessageByID(id uint) error {
return d.DB.Where("id = ?", id).Delete(&model.Message{}).Error
}
// DeleteMessagesByApplication deletes all messages from an application.
func (d *GormDatabase) DeleteMessagesByApplication(applicationID uint) error {
return d.DB.Where("application_id = ?", applicationID).Delete(&model.Message{}).Error
}
// DeleteMessagesByUser deletes all messages from a user.
func (d *GormDatabase) DeleteMessagesByUser(userID uint) error {
app, _ := d.GetApplicationsByUser(userID)
for _, app := range app {
d.DeleteMessagesByApplication(app.ID)
}
return nil
}
================================================
FILE: database/message_test.go
================================================
package database
import (
"testing"
"time"
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *DatabaseSuite) TestMessage() {
messages, err := s.db.GetMessageByID(5)
require.NoError(s.T(), err)
assert.Nil(s.T(), messages, "not existing message")
user := &model.User{Name: "test", Pass: []byte{1}}
s.db.CreateUser(user)
assert.NotEqual(s.T(), 0, user.ID)
backupServer := &model.Application{UserID: user.ID, Token: "A0000000000", Name: "backupserver"}
s.db.CreateApplication(backupServer)
assert.NotEqual(s.T(), 0, backupServer.ID)
msgs, err := s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
msgs, err = s.db.GetMessagesByApplication(backupServer.ID)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
backupdone := &model.Message{ApplicationID: backupServer.ID, Message: "backup done", Title: "backup", Priority: 1, Date: time.Now()}
require.NoError(s.T(), s.db.CreateMessage(backupdone))
assert.NotEqual(s.T(), 0, backupdone.ID)
messages, err = s.db.GetMessageByID(backupdone.ID)
require.NoError(s.T(), err)
assertEquals(s.T(), messages, backupdone)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
msgs, err = s.db.GetMessagesByApplication(backupServer.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
loginServer := &model.Application{UserID: user.ID, Token: "A0000000001", Name: "loginserver"}
require.NoError(s.T(), s.db.CreateApplication(loginServer))
assert.NotEqual(s.T(), 0, loginServer.ID)
logindone := &model.Message{ApplicationID: loginServer.ID, Message: "login done", Title: "login", Priority: 1, Date: time.Now()}
require.NoError(s.T(), s.db.CreateMessage(logindone))
assert.NotEqual(s.T(), 0, logindone.ID)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], logindone)
assertEquals(s.T(), msgs[1], backupdone)
msgs, err = s.db.GetMessagesByApplication(backupServer.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
loginfailed := &model.Message{ApplicationID: loginServer.ID, Message: "login failed", Title: "login", Priority: 1, Date: time.Now()}
require.NoError(s.T(), s.db.CreateMessage(loginfailed))
assert.NotEqual(s.T(), 0, loginfailed.ID)
msgs, err = s.db.GetMessagesByApplication(backupServer.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
msgs, err = s.db.GetMessagesByApplication(loginServer.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], loginfailed)
assertEquals(s.T(), msgs[1], logindone)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 3)
assertEquals(s.T(), msgs[0], loginfailed)
assertEquals(s.T(), msgs[1], logindone)
assertEquals(s.T(), msgs[2], backupdone)
backupfailed := &model.Message{ApplicationID: backupServer.ID, Message: "backup failed", Title: "backup", Priority: 1, Date: time.Now()}
require.NoError(s.T(), s.db.CreateMessage(backupfailed))
assert.NotEqual(s.T(), 0, backupfailed.ID)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 4)
assertEquals(s.T(), msgs[0], backupfailed)
assertEquals(s.T(), msgs[1], loginfailed)
assertEquals(s.T(), msgs[2], logindone)
assertEquals(s.T(), msgs[3], backupdone)
msgs, err = s.db.GetMessagesByApplication(loginServer.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], loginfailed)
assertEquals(s.T(), msgs[1], logindone)
require.NoError(s.T(), s.db.DeleteMessagesByApplication(loginServer.ID))
msgs, err = s.db.GetMessagesByApplication(loginServer.ID)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], backupfailed)
assertEquals(s.T(), msgs[1], backupdone)
logindone = &model.Message{ApplicationID: loginServer.ID, Message: "login done", Title: "login", Priority: 1, Date: time.Now()}
require.NoError(s.T(), s.db.CreateMessage(logindone))
assert.NotEqual(s.T(), 0, logindone.ID)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 3)
assertEquals(s.T(), msgs[0], logindone)
assertEquals(s.T(), msgs[1], backupfailed)
assertEquals(s.T(), msgs[2], backupdone)
s.db.DeleteMessagesByUser(user.ID)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
logout := &model.Message{ApplicationID: loginServer.ID, Message: "logout success", Title: "logout", Priority: 1, Date: time.Now()}
s.db.CreateMessage(logout)
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], logout)
require.NoError(s.T(), s.db.DeleteMessageByID(logout.ID))
msgs, err = s.db.GetMessagesByUser(user.ID)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
}
func (s *DatabaseSuite) TestGetMessagesSince() {
user := &model.User{Name: "test", Pass: []byte{1}}
require.NoError(s.T(), s.db.CreateUser(user))
app := &model.Application{UserID: user.ID, Token: "A0000000000"}
app2 := &model.Application{UserID: user.ID, Token: "A0000000001"}
require.NoError(s.T(), s.db.CreateApplication(app))
require.NoError(s.T(), s.db.CreateApplication(app2))
curDate := time.Now()
for i := 1; i <= 500; i++ {
s.db.CreateMessage(&model.Message{ApplicationID: app.ID, Message: "abc", Date: curDate.Add(time.Duration(i) * time.Second)})
s.db.CreateMessage(&model.Message{ApplicationID: app2.ID, Message: "abc", Date: curDate.Add(time.Duration(i) * time.Second)})
}
actual, err := s.db.GetMessagesByUserSince(user.ID, 50, 0)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 1000, 951, 1)
actual, err = s.db.GetMessagesByUserSince(user.ID, 50, 951)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 950, 901, 1)
actual, err = s.db.GetMessagesByUserSince(user.ID, 100, 951)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 100)
hasIDInclusiveBetween(s.T(), actual, 950, 851, 1)
actual, err = s.db.GetMessagesByUserSince(user.ID, 100, 51)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 50, 1, 1)
actual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 0)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 999, 901, 2)
actual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 901)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 899, 801, 2)
actual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 666)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 100)
hasIDInclusiveBetween(s.T(), actual, 665, 467, 2)
actual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 101)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 99, 1, 2)
actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 0)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 1000, 902, 2)
actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 902)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 900, 802, 2)
actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 667)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 100)
hasIDInclusiveBetween(s.T(), actual, 666, 468, 2)
actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 102)
require.NoError(s.T(), err)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 100, 2, 2)
}
func hasIDInclusiveBetween(t *testing.T, msgs []*model.Message, from, to, decrement int) {
index := 0
for expectedID := from; expectedID >= to; expectedID -= decrement {
if !assert.Equal(t, uint(expectedID), msgs[index].ID) {
break
}
index++
}
assert.Equal(t, index, len(msgs), "not all entries inside msgs were checked")
}
// assertEquals compares messages and correctly check dates.
func assertEquals(t *testing.T, left, right *model.Message) {
assert.Equal(t, left.Date.Unix(), right.Date.Unix())
left.Date = right.Date
assert.Equal(t, left, right)
}
================================================
FILE: database/migration_test.go
================================================
package database
import (
"testing"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigration(t *testing.T) {
suite.Run(t, &MigrationSuite{})
}
type MigrationSuite struct {
suite.Suite
tmpDir test.TmpDir
}
func (s *MigrationSuite) BeforeTest(suiteName, testName string) {
s.tmpDir = test.NewTmpDir("gotify_migrationsuite")
db, err := gorm.Open(sqlite.Open(s.tmpDir.Path("test_obsolete.db")), &gorm.Config{})
assert.NoError(s.T(), err)
sqlDB, err := db.DB()
assert.NoError(s.T(), err)
defer sqlDB.Close()
assert.Nil(s.T(), db.Migrator().CreateTable(new(model.User)))
assert.Nil(s.T(), db.Create(&model.User{
Name: "test_user",
Admin: true,
}).Error)
// we should not be able to create applications by now
assert.False(s.T(), db.Migrator().HasTable(new(model.Application)))
}
func (s *MigrationSuite) AfterTest(suiteName, testName string) {
assert.Nil(s.T(), s.tmpDir.Clean())
}
func (s *MigrationSuite) TestMigration() {
db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true)
assert.Nil(s.T(), err)
defer db.Close()
assert.True(s.T(), db.DB.Migrator().HasTable(new(model.Application)))
// a user already exist, not adding a new user
if user, err := db.GetUserByName("admin"); assert.NoError(s.T(), err) {
assert.Nil(s.T(), user)
}
// the old user should persist
if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) {
assert.Equal(s.T(), true, user.Admin)
}
// we should be able to create applications
if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) {
assert.Nil(s.T(), db.CreateApplication(&model.Application{
Token: "A1234",
UserID: user.ID,
Description: "this is a test application",
Name: "test application",
}))
}
if app, err := db.GetApplicationByToken("A1234"); assert.NoError(s.T(), err) {
assert.Equal(s.T(), "test application", app.Name)
}
}
================================================
FILE: database/ping.go
================================================
package database
// Ping pings the database to verify the connection.
func (d *GormDatabase) Ping() error {
sqldb, err := d.DB.DB()
if err != nil {
return err
}
return sqldb.Ping()
}
================================================
FILE: database/ping_test.go
================================================
package database
import (
"github.com/stretchr/testify/assert"
)
func (s *DatabaseSuite) TestPing_onValidDB() {
err := s.db.Ping()
assert.NoError(s.T(), err)
}
func (s *DatabaseSuite) TestPing_onClosedDB() {
s.db.Close()
err := s.db.Ping()
assert.Error(s.T(), err)
}
================================================
FILE: database/plugin.go
================================================
package database
import (
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
// GetPluginConfByUser gets plugin configurations from a user.
func (d *GormDatabase) GetPluginConfByUser(userid uint) ([]*model.PluginConf, error) {
var plugins []*model.PluginConf
err := d.DB.Where("user_id = ?", userid).Find(&plugins).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
return plugins, err
}
// GetPluginConfByUserAndPath gets plugin configuration by user and file name.
func (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error) {
plugin := new(model.PluginConf)
err := d.DB.Where("user_id = ? AND module_path = ?", userid, path).First(plugin).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if plugin.ModulePath == path {
return plugin, err
}
return nil, err
}
// GetPluginConfByApplicationID gets plugin configuration by its internal appid.
func (d *GormDatabase) GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error) {
plugin := new(model.PluginConf)
err := d.DB.Where("application_id = ?", appid).First(plugin).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if plugin.ApplicationID == appid {
return plugin, err
}
return nil, err
}
// CreatePluginConf creates a new plugin configuration.
func (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error {
return d.DB.Create(p).Error
}
// GetPluginConfByToken gets plugin configuration by plugin token.
func (d *GormDatabase) GetPluginConfByToken(token string) (*model.PluginConf, error) {
plugin := new(model.PluginConf)
err := d.DB.Where("token = ?", token).First(plugin).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if plugin.Token == token {
return plugin, err
}
return nil, err
}
// GetPluginConfByID gets plugin configuration by plugin ID.
func (d *GormDatabase) GetPluginConfByID(id uint) (*model.PluginConf, error) {
plugin := new(model.PluginConf)
err := d.DB.Where("id = ?", id).First(plugin).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if plugin.ID == id {
return plugin, err
}
return nil, err
}
// UpdatePluginConf updates plugin configuration.
func (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error {
return d.DB.Save(p).Error
}
// DeletePluginConfByID deletes a plugin configuration by its id.
func (d *GormDatabase) DeletePluginConfByID(id uint) error {
return d.DB.Where("id = ?", id).Delete(&model.PluginConf{}).Error
}
================================================
FILE: database/plugin_test.go
================================================
package database
import (
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *DatabaseSuite) TestPluginConf() {
plugin := model.PluginConf{
ModulePath: "github.com/gotify/example-plugin",
Token: "Pabc",
UserID: 1,
Enabled: true,
Config: nil,
ApplicationID: 2,
}
assert.Nil(s.T(), s.db.CreatePluginConf(&plugin))
assert.Equal(s.T(), uint(1), plugin.ID)
pluginConf, err := s.db.GetPluginConfByUserAndPath(1, "github.com/gotify/example-plugin")
require.NoError(s.T(), err)
assert.Equal(s.T(), "Pabc", pluginConf.Token)
pluginConf, err = s.db.GetPluginConfByToken("Pabc")
require.NoError(s.T(), err)
assert.Equal(s.T(), true, pluginConf.Enabled)
pluginConf, err = s.db.GetPluginConfByApplicationID(2)
require.NoError(s.T(), err)
assert.Equal(s.T(), "Pabc", pluginConf.Token)
pluginConf, err = s.db.GetPluginConfByID(1)
require.NoError(s.T(), err)
assert.Equal(s.T(), "github.com/gotify/example-plugin", pluginConf.ModulePath)
pluginConf, err = s.db.GetPluginConfByToken("Pnotexist")
require.NoError(s.T(), err)
assert.Nil(s.T(), pluginConf)
pluginConf, err = s.db.GetPluginConfByID(12)
require.NoError(s.T(), err)
assert.Nil(s.T(), pluginConf)
pluginConf, err = s.db.GetPluginConfByUserAndPath(1, "not/exist")
require.NoError(s.T(), err)
assert.Nil(s.T(), pluginConf)
pluginConf, err = s.db.GetPluginConfByApplicationID(99)
require.NoError(s.T(), err)
assert.Nil(s.T(), pluginConf)
pluginConfs, err := s.db.GetPluginConfByUser(1)
require.NoError(s.T(), err)
assert.Len(s.T(), pluginConfs, 1)
pluginConfs, err = s.db.GetPluginConfByUser(0)
require.NoError(s.T(), err)
assert.Len(s.T(), pluginConfs, 0)
testConf := `{"test_config_key":"hello"}`
plugin.Enabled = false
plugin.Config = []byte(testConf)
assert.Nil(s.T(), s.db.UpdatePluginConf(&plugin))
pluginConf, err = s.db.GetPluginConfByToken("Pabc")
require.NoError(s.T(), err)
assert.Equal(s.T(), false, pluginConf.Enabled)
assert.Equal(s.T(), testConf, string(pluginConf.Config))
}
================================================
FILE: database/user.go
================================================
package database
import (
"github.com/gotify/server/v2/model"
"gorm.io/gorm"
)
// GetUserByName returns the user by the given name or nil.
func (d *GormDatabase) GetUserByName(name string) (*model.User, error) {
user := new(model.User)
err := d.DB.Where("name = ?", name).Find(user).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if user.Name == name {
return user, err
}
return nil, err
}
// GetUserByID returns the user by the given id or nil.
func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) {
user := new(model.User)
err := d.DB.Find(user, id).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
if user.ID == id {
return user, err
}
return nil, err
}
// CountUser returns the user count which satisfies the given condition.
func (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) {
c := int64(-1)
handle := d.DB.Model(new(model.User))
if len(condition) == 1 {
handle = handle.Where(condition[0])
} else if len(condition) > 1 {
handle = handle.Where(condition[0], condition[1:]...)
}
err := handle.Count(&c).Error
return c, err
}
// GetUsers returns all users.
func (d *GormDatabase) GetUsers() ([]*model.User, error) {
var users []*model.User
err := d.DB.Find(&users).Error
return users, err
}
// DeleteUserByID deletes a user by its id.
func (d *GormDatabase) DeleteUserByID(id uint) error {
apps, _ := d.GetApplicationsByUser(id)
for _, app := range apps {
d.DeleteApplicationByID(app.ID)
}
clients, _ := d.GetClientsByUser(id)
for _, client := range clients {
d.DeleteClientByID(client.ID)
}
pluginConfs, _ := d.GetPluginConfByUser(id)
for _, conf := range pluginConfs {
d.DeletePluginConfByID(conf.ID)
}
return d.DB.Where("id = ?", id).Delete(&model.User{}).Error
}
// UpdateUser updates a user.
func (d *GormDatabase) UpdateUser(user *model.User) error {
return d.DB.Save(user).Error
}
// CreateUser creates a user.
func (d *GormDatabase) CreateUser(user *model.User) error {
return d.DB.Create(user).Error
}
================================================
FILE: database/user_test.go
================================================
package database
import (
"github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *DatabaseSuite) TestUser() {
user, err := s.db.GetUserByID(55)
require.NoError(s.T(), err)
assert.Nil(s.T(), user, "not existing user")
user, err = s.db.GetUserByName("nicories")
require.NoError(s.T(), err)
assert.Nil(s.T(), user, "not existing user")
jmattheis, err := s.db.GetUserByID(1)
require.NoError(s.T(), err)
assert.NotNil(s.T(), jmattheis, "on bootup the first user should be automatically created")
adminCount, err := s.db.CountUser("admin = ?", true)
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(1), adminCount, "there is initially one admin")
users, err := s.db.GetUsers()
require.NoError(s.T(), err)
assert.Len(s.T(), users, 1)
assert.Contains(s.T(), users, jmattheis)
nicories := &model.User{Name: "nicories", Pass: []byte{1, 2, 3, 4}, Admin: false}
s.db.CreateUser(nicories)
assert.NotEqual(s.T(), 0, nicories.ID, "on create user a new id should be assigned")
userCount, err := s.db.CountUser()
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(2), userCount, "two users should exist")
user, err = s.db.GetUserByName("nicories")
require.NoError(s.T(), err)
assert.Equal(s.T(), nicories, user)
users, err = s.db.GetUsers()
require.NoError(s.T(), err)
assert.Len(s.T(), users, 2)
assert.Contains(s.T(), users, jmattheis)
assert.Contains(s.T(), users, nicories)
nicories.Name = "tom"
nicories.Pass = []byte{12}
nicories.Admin = true
require.NoError(s.T(), s.db.UpdateUser(nicories))
tom, err := s.db.GetUserByID(nicories.ID)
require.NoError(s.T(), err)
assert.Equal(s.T(), &model.User{ID: nicories.ID, Name: "tom", Pass: []byte{12}, Admin: true}, tom)
users, err = s.db.GetUsers()
require.NoError(s.T(), err)
assert.Len(s.T(), users, 2)
adminCount, err = s.db.CountUser(&model.User{Admin: true})
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(2), adminCount, "two admins exist")
require.NoError(s.T(), s.db.DeleteUserByID(tom.ID))
users, err = s.db.GetUsers()
require.NoError(s.T(), err)
assert.Len(s.T(), users, 1)
assert.Contains(s.T(), users, jmattheis)
s.db.DeleteUserByID(jmattheis.ID)
users, err = s.db.GetUsers()
require.NoError(s.T(), err)
assert.Empty(s.T(), users)
}
func (s *DatabaseSuite) TestUserPlugins() {
assert.NoError(s.T(), s.db.CreateUser(&model.User{Name: "geek", ID: 16}))
if geekUser, err := s.db.GetUserByName("geek"); assert.NoError(s.T(), err) {
s.db.CreatePluginConf(&model.PluginConf{
UserID: geekUser.ID,
ModulePath: "github.com/gotify/example-plugin",
Token: "P1234",
Enabled: true,
})
s.db.CreatePluginConf(&model.PluginConf{
UserID: geekUser.ID,
ModulePath: "github.com/gotify/example-plugin/v2",
Token: "P5678",
Enabled: true,
})
}
if geekUser, err := s.db.GetUserByName("geek"); assert.NoError(s.T(), err) {
if pluginConfs, err := s.db.GetPluginConfByUser(geekUser.ID); assert.NoError(s.T(), err) {
assert.Len(s.T(), pluginConfs, 2)
}
}
if pluginConf, err := s.db.GetPluginConfByToken("P1234"); assert.NoError(s.T(), err) {
assert.Equal(s.T(), "github.com/gotify/example-plugin", pluginConf.ModulePath)
}
}
func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClientsAndPluginConfs() {
require.NoError(s.T(), s.db.CreateUser(&model.User{Name: "nicories", ID: 10}))
require.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 100, Token: "apptoken", UserID: 10}))
require.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100}))
require.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 10000, Token: "clienttoken", UserID: 10}))
require.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 1000, Token: "plugintoken", UserID: 10}))
require.NoError(s.T(), s.db.CreateUser(&model.User{Name: "nicories2", ID: 20}))
require.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 200, Token: "apptoken2", UserID: 20}))
require.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200}))
require.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 20000, Token: "clienttoken2", UserID: 20}))
require.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 2000, Token: "plugintoken2", UserID: 20}))
require.NoError(s.T(), s.db.DeleteUserByID(10))
app, err := s.db.GetApplicationByToken("apptoken")
require.NoError(s.T(), err)
assert.Nil(s.T(), app)
client, err := s.db.GetClientByToken("clienttoken")
require.NoError(s.T(), err)
assert.Nil(s.T(), client)
clients, err := s.db.GetClientsByUser(10)
require.NoError(s.T(), err)
assert.Empty(s.T(), clients)
apps, err := s.db.GetApplicationsByUser(10)
require.NoError(s.T(), err)
assert.Empty(s.T(), apps)
msgs, err := s.db.GetMessagesByApplication(100)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
msgs, err = s.db.GetMessagesByUser(10)
require.NoError(s.T(), err)
assert.Empty(s.T(), msgs)
pluginConfs, err := s.db.GetPluginConfByUser(10)
require.NoError(s.T(), err)
assert.Empty(s.T(), pluginConfs)
msg, err := s.db.GetMessageByID(1000)
require.NoError(s.T(), err)
assert.Nil(s.T(), msg)
app, err = s.db.GetApplicationByToken("apptoken2")
require.NoError(s.T(), err)
assert.NotNil(s.T(), app)
client, err = s.db.GetClientByToken("clienttoken2")
require.NoError(s.T(), err)
assert.NotNil(s.T(), client)
clients, err = s.db.GetClientsByUser(20)
require.NoError(s.T(), err)
assert.NotEmpty(s.T(), clients)
apps, err = s.db.GetApplicationsByUser(20)
require.NoError(s.T(), err)
assert.NotEmpty(s.T(), apps)
pluginConf, err := s.db.GetPluginConfByUser(20)
require.NoError(s.T(), err)
assert.NotEmpty(s.T(), pluginConf)
msgs, err = s.db.GetMessagesByApplication(200)
require.NoError(s.T(), err)
assert.NotEmpty(s.T(), msgs)
msgs, err = s.db.GetMessagesByUser(20)
require.NoError(s.T(), err)
assert.NotEmpty(s.T(), msgs)
msg, err = s.db.GetMessageByID(2000)
require.NoError(s.T(), err)
assert.NotNil(s.T(), msg)
}
================================================
FILE: docker/Dockerfile
================================================
ARG BUILDKIT_SBOM_SCAN_CONTEXT=true
# Suppress warning about invalid variable expansion
ARG GO_VERSION=PLEASE_PROVIDE_GO_VERSION
ARG DEBIAN=sid-slim
# Hack to normalize platform to match the chosed build image
# Get the gotify/build image tag
ARG __TARGETPLATFORM_DASHES=${TARGETPLATFORM/\//-}
ARG __TARGETPLATFORM_GO_NOTATION=${__TARGETPLATFORM_DASHES/arm\/v7/arm-7}
# --- JS Builder ---
FROM --platform=${BUILDPLATFORM} node:24 AS js-builder
ARG BUILD_JS=0
COPY ./Makefile /src/gotify/Makefile
COPY ./ui /src/gotify/ui
RUN if [ "$BUILD_JS" = "1" ]; then \
(cd /src/gotify/ui && yarn install) && \
(cd /src/gotify && make build-js) \
else \
mkdir -p /src/gotify/ui/build; \
fi
# --- Go Builder ---
FROM --platform=${BUILDPLATFORM} gotify/build:${GO_VERSION}-${__TARGETPLATFORM_GO_NOTATION} AS builder
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG BUILD_JS=0
ARG RUN_TESTS=0 # 0=never, 1=native only
ARG LD_FLAGS=""
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -yq --no-install-recommends \
ca-certificates \
git
COPY . /src/gotify
COPY --from=js-builder /src/gotify/ui/build /ui-build
RUN if [ "$BUILD_JS" = "1" ]; then \
cp -r --update /ui-build /src/gotify/ui/build; \
fi
RUN cd /src/gotify && \
if [ "$RUN_TESTS" = "1" ] && [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \
go test -v ./...; \
fi && \
LD_FLAGS=${LD_FLAGS} make OUTPUT=/target/app/gotify-app _build_within_docker
FROM debian:${DEBIAN}
LABEL org.opencontainers.image.documentation=https://gotify.net/
LABEL org.opencontainers.image.source=https://github.com/gotify/server
# Build-time configurables
ARG GOTIFY_SERVER_EXPOSE=80
ENV GOTIFY_SERVER_PORT=$GOTIFY_SERVER_EXPOSE
WORKDIR /app
RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -yq --no-install-recommends \
tzdata \
curl \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD curl --fail http://localhost:$GOTIFY_SERVER_PORT/health || exit 1
EXPOSE $GOTIFY_SERVER_EXPOSE
COPY --from=builder /target /
ENTRYPOINT ["./gotify-app"]
================================================
FILE: docs/package.go
================================================
// Package docs Gotify REST-API.
//
// This is the documentation of the Gotify REST-API.
//
// # Authentication
// In Gotify there are two token types:
// __clientToken__: a client is something that receives message and manages stuff like creating new tokens or delete messages. (f.ex this token should be used for an android app)
// __appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script)
//
// The token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or
// through a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`).
// There is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken.
//
// \---
//
// Found a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues)
//
// Schemes: http, https
// Host: localhost
// Version: 2.0.2
// License: MIT https://github.com/gotify/server/blob/master/LICENSE
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// SecurityDefinitions:
// appTokenQuery:
// type: apiKey
// name: token
// in: query
// clientTokenQuery:
// type: apiKey
// name: token
// in: query
// appTokenHeader:
// type: apiKey
// name: X-Gotify-Key
// in: header
// clientTokenHeader:
// type: apiKey
// name: X-Gotify-Key
// in: header
// appTokenAuthorizationHeader:
// type: apiKey
// name: Authorization
// in: header
// description: >-
// Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`.
// clientTokenAuthorizationHeader:
// type: apiKey
// name: Authorization
// in: header
// description: >-
// Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`.
// basicAuth:
// type: basic
//
// swagger:meta
package docs
================================================
FILE: docs/spec.json
================================================
{
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"swagger": "2.0",
"info": {
"description": "This is the documentation of the Gotify REST-API.\n\n# Authentication\nIn Gotify there are two token types:\n__clientToken__: a client is something that receives message and manages stuff like creating new tokens or delete messages. (f.ex this token should be used for an android app)\n__appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script)\n\nThe token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or\nthrough a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`).\nThere is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken.\n\n\\---\n\nFound a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues)",
"title": "Gotify REST-API.",
"license": {
"name": "MIT",
"url": "https://github.com/gotify/server/blob/master/LICENSE"
},
"version": "2.0.2"
},
"host": "localhost",
"paths": {
"/application": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Return all applications.",
"operationId": "getApps",
"responses": {
"200": {
"description": "Ok",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Application"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Create an application.",
"operationId": "createApp",
"parameters": [
{
"description": "the application to add",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ApplicationParams"
}
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Application"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/application/{id}": {
"put": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Update an application.",
"operationId": "updateApplication",
"parameters": [
{
"description": "the application to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ApplicationParams"
}
},
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Application"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Delete an application.",
"operationId": "deleteApp",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/application/{id}/image": {
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Upload an image for an application.",
"operationId": "uploadAppImage",
"parameters": [
{
"type": "file",
"description": "the application image",
"name": "file",
"in": "formData",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Application"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"application"
],
"summary": "Deletes an image of an application.",
"operationId": "removeAppImage",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/application/{id}/message": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Return all messages from a specific application.",
"operationId": "getAppMessages",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
},
{
"maximum": 200,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "the maximal amount of messages to return",
"name": "limit",
"in": "query"
},
{
"minimum": 0,
"type": "integer",
"format": "int64",
"description": "return all messages with an ID less than this value",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/PagedMessages"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Delete all messages from a specific application.",
"operationId": "deleteAppMessages",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the application id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/client": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"client"
],
"summary": "Return all clients.",
"operationId": "getClients",
"responses": {
"200": {
"description": "Ok",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Client"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"client"
],
"summary": "Create a client.",
"operationId": "createClient",
"parameters": [
{
"description": "the client to add",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ClientParams"
}
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Client"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/client/{id}": {
"put": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"client"
],
"summary": "Update a client.",
"operationId": "updateClient",
"parameters": [
{
"description": "the client to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ClientParams"
}
},
{
"type": "integer",
"format": "int64",
"description": "the client id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Client"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"client"
],
"summary": "Delete a client.",
"operationId": "deleteClient",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the client id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/current/user": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Return the current user.",
"operationId": "currentUser",
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/User"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/current/user/password": {
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Update the password of the current user.",
"operationId": "updateCurrentUser",
"parameters": [
{
"description": "the user",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/UserPass"
}
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/health": {
"get": {
"produces": [
"application/json"
],
"tags": [
"health"
],
"summary": "Get health information.",
"operationId": "getHealth",
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Health"
}
},
"500": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Health"
}
}
}
}
},
"/message": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Return all messages.",
"operationId": "getMessages",
"parameters": [
{
"maximum": 200,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "the maximal amount of messages to return",
"name": "limit",
"in": "query"
},
{
"minimum": 0,
"type": "integer",
"format": "int64",
"description": "return all messages with an ID less than this value",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/PagedMessages"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"appTokenAuthorizationHeader": []
},
{
"appTokenHeader": []
},
{
"appTokenQuery": []
}
],
"description": "__NOTE__: This API ONLY accepts an application token as authentication.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Create a message.",
"operationId": "createMessage",
"parameters": [
{
"description": "the message to add",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Message"
}
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Message"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Delete all messages.",
"operationId": "deleteMessages",
"responses": {
"200": {
"description": "Ok"
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/message/{id}": {
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Deletes a message with an id.",
"operationId": "deleteMessage",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the message id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/plugin": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"plugin"
],
"summary": "Return all plugins.",
"operationId": "getPlugins",
"responses": {
"200": {
"description": "Ok",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/PluginConf"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/plugin/{id}/config": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/x-yaml"
],
"tags": [
"plugin"
],
"summary": "Get YAML configuration for Configurer plugin.",
"operationId": "getPluginConfig",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the plugin id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"description": "plugin configuration",
"type": "object"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/x-yaml"
],
"produces": [
"application/json"
],
"tags": [
"plugin"
],
"summary": "Update YAML configuration for Configurer plugin.",
"operationId": "updatePluginConfig",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the plugin id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/plugin/{id}/disable": {
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"plugin"
],
"summary": "Disable a plugin.",
"operationId": "disablePlugin",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the plugin id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/plugin/{id}/display": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"plugin"
],
"summary": "Get display info for a Displayer plugin.",
"operationId": "getPluginDisplay",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the plugin id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/plugin/{id}/enable": {
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"plugin"
],
"summary": "Enable a plugin.",
"operationId": "enablePlugin",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the plugin id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/stream": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Websocket, return newly created messages.",
"operationId": "streamMessages",
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Message"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"500": {
"description": "Server Error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/user": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Return all users.",
"operationId": "getUsers",
"responses": {
"200": {
"description": "Ok",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/User"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Create a user.",
"operationId": "createUser",
"parameters": [
{
"description": "the user to add",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/CreateUserExternal"
}
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/user/{id}": {
"get": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Get a user.",
"operationId": "getUser",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the user id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Update a user.",
"operationId": "updateUser",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the user id",
"name": "id",
"in": "path",
"required": true
},
{
"description": "the updated user",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/UpdateUserExternal"
}
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"security": [
{
"clientTokenAuthorizationHeader": []
},
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Deletes a user.",
"operationId": "deleteUser",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "the user id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/version": {
"get": {
"produces": [
"application/json"
],
"tags": [
"version"
],
"summary": "Get version information.",
"operationId": "getVersion",
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/VersionInfo"
}
}
}
}
}
},
"definitions": {
"Application": {
"description": "The Application holds information about an app which can send notifications.",
"type": "object",
"title": "Application Model",
"required": [
"id",
"token",
"name",
"description",
"internal",
"image",
"sortKey"
],
"properties": {
"defaultPriority": {
"description": "The default priority of messages sent by this application. Defaults to 0.",
"type": "integer",
"format": "int64",
"x-go-name": "DefaultPriority",
"example": 4
},
"description": {
"description": "The description of the application.",
"type": "string",
"x-go-name": "Description",
"example": "Backup server for the interwebs"
},
"id": {
"description": "The application id.",
"type": "integer",
"format": "int64",
"x-go-name": "ID",
"readOnly": true,
"example": 5
},
"image": {
"description": "The image of the application.",
"type": "string",
"x-go-name": "Image",
"readOnly": true,
"example": "image/image.jpeg"
},
"internal": {
"description": "Whether the application is an internal application. Internal applications should not be deleted.",
"type": "boolean",
"x-go-name": "Internal",
"readOnly": true,
"example": false
},
"lastUsed": {
"description": "The last time the application token was used.",
"type": "string",
"format": "date-time",
"x-go-name": "LastUsed",
"readOnly": true,
"example": "2019-01-01T00:00:00Z"
},
"name": {
"description": "The application name. This is how the application should be displayed to the user.",
"type": "string",
"x-go-name": "Name",
"example": "Backup Server"
},
"sortKey": {
"description": "The sort key of this application. Uses fractional indexing.",
"type": "string",
"x-go-name": "SortKey",
"example": "a1"
},
"token": {
"description": "The application token. Can be used as `appToken`. See Authentication.",
"type": "string",
"x-go-name": "Token",
"readOnly": true,
"example": "AWH0wZ5r0Mbac.r"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"ApplicationParams": {
"description": "Params allowed to create or update Applications.",
"type": "object",
"title": "Application Params Model",
"required": [
"name"
],
"properties": {
"defaultPriority": {
"description": "The default priority of messages sent by this application. Defaults to 0.",
"type": "integer",
"format": "int64",
"x-go-name": "DefaultPriority",
"example": 5
},
"description": {
"description": "The description of the application.",
"type": "string",
"x-go-name": "Description",
"example": "Backup server for the interwebs"
},
"name": {
"description": "The application name. This is how the application should be displayed to the user.",
"type": "string",
"x-go-name": "Name",
"example": "Backup Server"
},
"sortKey": {
"description": "The sortKey for the application. Uses fractional indexing.",
"type": "string",
"x-go-name": "SortKey",
"example": "a1"
}
},
"x-go-package": "github.com/gotify/server/v2/api"
},
"Client": {
"description": "The Client holds information about a device which can receive notifications (and other stuff).",
"type": "object",
"title": "Client Model",
"required": [
"id",
"token",
"name"
],
"properties": {
"id": {
"description": "The client id.",
"type": "integer",
"format": "int64",
"x-go-name": "ID",
"readOnly": true,
"example": 5
},
"lastUsed": {
"description": "The last time the client token was used.",
"type": "string",
"format": "date-time",
"x-go-name": "LastUsed",
"readOnly": true,
"example": "2019-01-01T00:00:00Z"
},
"name": {
"description": "The client name. This is how the client should be displayed to the user.",
"type": "string",
"x-go-name": "Name",
"example": "Android Phone"
},
"token": {
"description": "The client token. Can be used as `clientToken`. See Authentication.",
"type": "string",
"x-go-name": "Token",
"readOnly": true,
"example": "CWH0wZ5r0Mbac.r"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"ClientParams": {
"description": "Params allowed to create or update Clients.",
"type": "object",
"title": "Client Params Model",
"required": [
"name"
],
"properties": {
"name": {
"description": "The client name",
"type": "string",
"x-go-name": "Name",
"example": "My Client"
}
},
"x-go-package": "github.com/gotify/server/v2/api"
},
"CreateUserExternal": {
"description": "Used for user creation.",
"type": "object",
"title": "CreateUserExternal Model",
"required": [
"name",
"admin",
"pass"
],
"properties": {
"admin": {
"description": "If the user is an administrator.",
"type": "boolean",
"x-go-name": "Admin",
"example": true
},
"name": {
"description": "The user name. For login.",
"type": "string",
"x-go-name": "Name",
"example": "unicorn"
},
"pass": {
"description": "The user password. For login.",
"type": "string",
"x-go-name": "Pass",
"example": "nrocinu"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"Error": {
"description": "The Error contains error relevant information.",
"type": "object",
"title": "Error Model",
"required": [
"error",
"errorCode",
"errorDescription"
],
"properties": {
"error": {
"description": "The general error message",
"type": "string",
"x-go-name": "Error",
"example": "Unauthorized"
},
"errorCode": {
"description": "The http error code.",
"type": "integer",
"format": "int64",
"x-go-name": "ErrorCode",
"example": 401
},
"errorDescription": {
"description": "The http error code.",
"type": "string",
"x-go-name": "ErrorDescription",
"example": "you need to provide a valid access token or user credentials to access this api"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"Health": {
"description": "Health represents how healthy the application is.",
"type": "object",
"title": "Health Model",
"required": [
"health",
"database"
],
"properties": {
"database": {
"description": "The health of the database connection.",
"type": "string",
"x-go-name": "Database",
"example": "green"
},
"health": {
"description": "The health of the overall application.",
"type": "string",
"x-go-name": "Health",
"example": "green"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"Message": {
"description": "The MessageExternal holds information about a message which was sent by an Application.",
"type": "object",
"title": "MessageExternal Model",
"required": [
"id",
"appid",
"message",
"date"
],
"properties": {
"appid": {
"description": "The application id that send this message.",
"type": "integer",
"format": "int64",
"x-go-name": "ApplicationID",
"readOnly": true,
"example": 5
},
"date": {
"description": "The date the message was created.",
"type": "string",
"format": "date-time",
"x-go-name": "Date",
"readOnly": true,
"example": "2018-02-27T19:36:10.5045044+01:00"
},
"extras": {
"description": "The extra data sent along the message.\n\nThe extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type.\n\nThe keys should be in the following format: \u0026lt;top-namespace\u0026gt;::[\u0026lt;sub-namespace\u0026gt;::]\u0026lt;action\u0026gt;\n\nThese namespaces are reserved and might be used in the official clients: gotify android ios web server client. Do not use them for other purposes.",
"type": "object",
"additionalProperties": {},
"x-go-name": "Extras",
"example": {
"home::appliances::lighting::on": {
"brightness": 15
},
"home::appliances::thermostat::change_temperature": {
"temperature": 23
}
}
},
"id": {
"description": "The message id.",
"type": "integer",
"format": "int64",
"x-go-name": "ID",
"readOnly": true,
"example": 25
},
"message": {
"description": "The message. Markdown (excluding html) is allowed.",
"type": "string",
"x-go-name": "Message",
"example": "**Backup** was successfully finished."
},
"priority": {
"description": "The priority of the message. If unset, then the default priority of the\napplication will be used.",
"type": "integer",
"format": "int64",
"x-go-name": "Priority",
"example": 2
},
"title": {
"description": "The title of the message.",
"type": "string",
"x-go-name": "Title",
"example": "Backup"
}
},
"x-go-name": "MessageExternal",
"x-go-package": "github.com/gotify/server/v2/model"
},
"PagedMessages": {
"description": "Wrapper for the paging and the messages.",
"type": "object",
"title": "PagedMessages Model",
"required": [
"paging",
"messages"
],
"properties": {
"messages": {
"description": "The messages.",
"type": "array",
"items": {
"$ref": "#/definitions/Message"
},
"x-go-name": "Messages",
"readOnly": true
},
"paging": {
"$ref": "#/definitions/Paging"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"Paging": {
"description": "The Paging holds information about the limit and making requests to the next page.",
"type": "object",
"title": "Paging Model",
"required": [
"size",
"since",
"limit"
],
"properties": {
"limit": {
"description": "The limit of the messages for the current request.",
"type": "integer",
"format": "int64",
"maximum": 200,
"minimum": 1,
"x-go-name": "Limit",
"readOnly": true,
"example": 123
},
"next": {
"description": "The request url for the next page. Empty/Null when no next page is available.",
"type": "string",
"x-go-name": "Next",
"readOnly": true,
"example": "http://example.com/message?limit=50\u0026since=123456"
},
"since": {
"description": "The ID of the last message returned in the current request. Use this as alternative to the next link.",
"type": "integer",
"format": "int64",
"minimum": 0,
"x-go-name": "Since",
"readOnly": true,
"example": 5
},
"size": {
"description": "The amount of messages that got returned in the current request.",
"type": "integer",
"format": "int64",
"x-go-name": "Size",
"readOnly": true,
"example": 5
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"PluginConf": {
"description": "Holds information about a plugin instance for one user.",
"type": "object",
"title": "PluginConfExternal Model",
"required": [
"id",
"name",
"token",
"modulePath",
"enabled",
"capabilities"
],
"properties": {
"author": {
"description": "The author of the plugin.",
"type": "string",
"x-go-name": "Author",
"readOnly": true,
"example": "jmattheis"
},
"capabilities": {
"description": "Capabilities the plugin provides",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Capabilities",
"example": [
"webhook",
"display"
]
},
"enabled": {
"description": "Whether the plugin instance is enabled.",
"type": "boolean",
"x-go-name": "Enabled",
"example": true
},
"id": {
"description": "The plugin id.",
"type": "integer",
"format": "int64",
"x-go-name": "ID",
"readOnly": true,
"example": 25
},
"license": {
"description": "The license of the plugin.",
"type": "string",
"x-go-name": "License",
"readOnly": true,
"example": "MIT"
},
"modulePath": {
"description": "The module path of the plugin.",
"type": "string",
"x-go-name": "ModulePath",
"readOnly": true,
"example": "github.com/gotify/server/plugin/example/echo"
},
"name": {
"description": "The plugin name.",
"type": "string",
"x-go-name": "Name",
"readOnly": true,
"example": "RSS poller"
},
"token": {
"description": "The user name. For login.",
"type": "string",
"x-go-name": "Token",
"example": "P1234"
},
"website": {
"description": "The website of the plugin.",
"type": "string",
"x-go-name": "Website",
"readOnly": true,
"example": "gotify.net"
}
},
"x-go-name": "PluginConfExternal",
"x-go-package": "github.com/gotify/server/v2/model"
},
"UpdateUserExternal": {
"description": "Used for updating a user.",
"type": "object",
"title": "UpdateUserExternal Model",
"required": [
"name",
"admin"
],
"properties": {
"admin": {
"description": "If the user is an administrator.",
"type": "boolean",
"x-go-name": "Admin",
"example": true
},
"name": {
"description": "The user name. For login.",
"type": "string",
"x-go-name": "Name",
"example": "unicorn"
},
"pass": {
"description": "The user password. For login. Empty for using old password",
"type": "string",
"x-go-name": "Pass",
"example": "nrocinu"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
},
"User": {
"description": "The User holds information about permission and other stuff.",
"type": "object",
"title": "UserExternal Model",
"required": [
"id",
"name",
"admin"
],
"properties": {
"admin": {
"description": "If the user is an administrator.",
"type": "boolean",
"x-go-name": "Admin",
"example": true
},
"id": {
"description": "The user id.",
"type": "integer",
"format": "int64",
"x-go-name": "ID",
"readOnly": true,
"example": 25
},
"name": {
"description": "The user name. For login.",
"type": "string",
"x-go-name": "Name",
"example": "unicorn"
}
},
"x-go-name": "UserExternal",
"x-go-package": "github.com/gotify/server/v2/model"
},
"UserPass": {
"description": "The Password for updating the user.",
"type": "object",
"title": "UserExternalPass Model",
"required": [
"pass"
],
"properties": {
"pass": {
"description": "The user password. For login.",
"type": "string",
"x-go-name": "Pass",
"example": "nrocinu"
}
},
"x-go-name": "UserExternalPass",
"x-go-package": "github.com/gotify/server/v2/model"
},
"VersionInfo": {
"description": "VersionInfo Model",
"type": "object",
"required": [
"version",
"commit",
"buildDate"
],
"properties": {
"buildDate": {
"description": "The date on which this binary was built.",
"type": "string",
"x-go-name": "BuildDate",
"example": "2018-02-27T19:36:10.5045044+01:00"
},
"commit": {
"description": "The git commit hash on which this binary was built.",
"type": "string",
"x-go-name": "Commit",
"example": "ae9512b6b6feea56a110d59a3353ea3b9c293864"
},
"version": {
"description": "The current version.",
"type": "string",
"x-go-name": "Version",
"example": "5.2.6"
}
},
"x-go-package": "github.com/gotify/server/v2/model"
}
},
"securityDefinitions": {
"appTokenAuthorizationHeader": {
"description": "Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"appTokenHeader": {
"type": "apiKey",
"name": "X-Gotify-Key",
"in": "header"
},
"appTokenQuery": {
"type": "apiKey",
"name": "token",
"in": "query"
},
"basicAuth": {
"type": "basic"
},
"clientTokenAuthorizationHeader": {
"description": "Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"clientTokenHeader": {
"type": "apiKey",
"name": "X-Gotify-Key",
"in": "header"
},
"clientTokenQuery": {
"type": "apiKey",
"name": "token",
"in": "query"
}
}
}
================================================
FILE: docs/swagger.go
================================================
package docs
import (
_ "embed"
"strings"
"github.com/gin-gonic/gin"
"github.com/gotify/location"
)
//go:embed spec.json
var spec string
// Serve serves the documentation.
func Serve(ctx *gin.Context) {
base := location.Get(ctx).Host
if basePathFromQuery := ctx.Query("base"); basePathFromQuery != "" {
base = basePathFromQuery
}
ctx.Writer.WriteString(getSwaggerJSON(base))
}
func getSwaggerJSON(base string) string {
return strings.Replace(spec, "localhost", base, 1)
}
================================================
FILE: docs/swagger_test.go
================================================
package docs
import (
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/mode"
"github.com/stretchr/testify/assert"
)
func TestServe(t *testing.T) {
mode.Set(mode.TestDev)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
withURL(ctx, "http", "example.com")
ctx.Request = httptest.NewRequest("GET", "/swagger?base="+url.QueryEscape("127.0.0.1/proxy/"), nil)
Serve(ctx)
content := recorder.Body.String()
assert.NotEmpty(t, content)
assert.Contains(t, content, "127.0.0.1/proxy/")
}
func withURL(ctx *gin.Context, scheme, host string) {
ctx.Set("location", &url.URL{Scheme: scheme, Host: host})
}
================================================
FILE: docs/ui.go
================================================
package docs
import "github.com/gin-gonic/gin"
var ui = `