├── .dockerignore ├── errcheck_excludes.txt ├── .github ├── logo.png └── workflows │ ├── test.yml │ ├── documentation.yml │ └── publish.yml ├── postgres-debug.env ├── internal ├── database │ ├── health.go │ ├── interfaces.go │ ├── application.go │ ├── user.go │ └── database.go ├── assert │ └── assert.go ├── pberrors │ └── errors.go ├── log │ ├── log.go │ └── ginlogrus.go ├── runner │ └── runner.go ├── authentication │ ├── credentials │ │ ├── hibp_test.go │ │ ├── credentials.go │ │ ├── password.go │ │ └── hibp.go │ ├── context.go │ ├── token_test.go │ ├── token.go │ └── authentication.go ├── api │ ├── health.go │ ├── health_test.go │ ├── util.go │ ├── middleware.go │ ├── interfaces.go │ ├── context.go │ ├── alertmanager │ │ └── handler.go │ ├── util_test.go │ ├── api_test.go │ ├── notification.go │ ├── notification_test.go │ ├── context_test.go │ ├── user_test.go │ ├── application.go │ ├── user.go │ └── application_test.go ├── model │ ├── application.go │ ├── notification.go │ ├── alertmanager.go │ └── user.go ├── dispatcher │ ├── dispatcher.go │ ├── application.go │ └── notification.go ├── router │ └── router.go └── configuration │ ├── configuration.go │ └── configuration_test.go ├── .editorconfig ├── .goreleaser.yml ├── SECURITY.md ├── tests ├── mockups │ ├── helper.go │ ├── application.go │ ├── config.go │ ├── user.go │ ├── dispatcher.go │ └── database.go └── request.go ├── .vscode ├── launch.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── Dockerfile ├── Makefile ├── config.example.yml ├── cmd └── pushbits │ └── main.go ├── go.mod ├── .gitignore ├── README.md ├── CODE_OF_CONDUCT.md └── go.sum /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /errcheck_excludes.txt: -------------------------------------------------------------------------------- 1 | (*github.com/gin-gonic/gin.Context).AbortWithError 2 | -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pushbits/server/HEAD/.github/logo.png -------------------------------------------------------------------------------- /postgres-debug.env: -------------------------------------------------------------------------------- 1 | POSTGRES_PASSWORD=pushbits 2 | POSTGRES_USER=pushbits 3 | POSTGRES_DB=pushbits 4 | -------------------------------------------------------------------------------- /internal/database/health.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | // Health reports the status of the database connection. 4 | func (d *Database) Health() error { 5 | return d.sqldb.Ping() 6 | } 7 | -------------------------------------------------------------------------------- /internal/assert/assert.go: -------------------------------------------------------------------------------- 1 | // Package assert provides convenience function to make assertions at runtime. 2 | package assert 3 | 4 | import ( 5 | "errors" 6 | ) 7 | 8 | // Assert panics if condition is false. 9 | func Assert(condition bool) { 10 | if !condition { 11 | panic(errors.New("assertion failed")) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = tab 7 | insert_final_newline = true 8 | max_line_length = 120 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: pushbits 3 | main: ./cmd/pushbits 4 | goos: 5 | - linux 6 | goarch: 7 | - amd64 8 | - arm64 9 | ldflags: 10 | - -s -w -X main.version=v{{.Version}} 11 | 12 | checksum: 13 | algorithm: sha256 14 | 15 | archives: 16 | - id: pushbits 17 | builds: 18 | - pushbits 19 | format: tar.gz 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Because we are a small team, only the latest version is supported. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please report security-related issues to [eikendev](https://www.eiken.dev/), who will follow-up with you as soon as possible. 10 | After confirming your findings, we will work on a patch and release it timely. 11 | -------------------------------------------------------------------------------- /tests/mockups/helper.go: -------------------------------------------------------------------------------- 1 | package mockups 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | func randStr(length int) string { 9 | buff := make([]byte, length) 10 | 11 | _, err := rand.Read(buff) 12 | if err != nil { 13 | panic("cannot read random data") 14 | } 15 | 16 | str := base64.StdEncoding.EncodeToString(buff) 17 | 18 | // Base 64 can be longer than len 19 | return str[:length] 20 | } 21 | -------------------------------------------------------------------------------- /internal/pberrors/errors.go: -------------------------------------------------------------------------------- 1 | // Package pberrors defines errors specific to PushBits 2 | package pberrors 3 | 4 | import "errors" 5 | 6 | // ErrMessageNotFound indicates that a message does not exist 7 | var ErrMessageNotFound = errors.New("message not found") 8 | 9 | // ErrConfigTLSFilesInconsistent indicates that either just a certfile or a keyfile was provided 10 | var ErrConfigTLSFilesInconsistent = errors.New("TLS certfile and keyfile must either both be provided or omitted") 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/pushbits", 13 | "cwd": "${workspaceFolder}" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides functionality to configure the logger. 2 | package log 3 | 4 | import ( 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // L is the global logger instance for PushBits. 11 | var L *log.Logger 12 | 13 | func init() { 14 | L = log.New() 15 | L.SetOutput(os.Stderr) 16 | L.SetLevel(log.InfoLevel) 17 | L.SetFormatter(&log.TextFormatter{ 18 | DisableTimestamp: true, 19 | }) 20 | } 21 | 22 | // SetDebug sets the logger to output debug information. 23 | func SetDebug() { 24 | L.SetLevel(log.DebugLevel) 25 | } 26 | -------------------------------------------------------------------------------- /internal/database/interfaces.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/pushbits/server/internal/configuration" 5 | "github.com/pushbits/server/internal/model" 6 | ) 7 | 8 | // The Dispatcher interface for constructing and destructing channels. 9 | type Dispatcher interface { 10 | DeregisterApplication(a *model.Application, u *model.User) error 11 | UpdateApplication(a *model.Application, behavior *configuration.RepairBehavior) error 12 | IsOrphan(a *model.Application, u *model.User) (bool, error) 13 | RepairApplication(a *model.Application, u *model.User) error 14 | } 15 | -------------------------------------------------------------------------------- /internal/runner/runner.go: -------------------------------------------------------------------------------- 1 | // Package runner provides functions to run the web server. 2 | package runner 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/pushbits/server/internal/configuration" 10 | ) 11 | 12 | // Run starts the Gin engine. 13 | func Run(engine *gin.Engine, c *configuration.Configuration) error { 14 | var err error 15 | address := fmt.Sprintf("%s:%d", c.HTTP.ListenAddress, c.HTTP.Port) 16 | 17 | if c.HTTP.CertFile != "" && c.HTTP.KeyFile != "" { 18 | err = engine.RunTLS(address, c.HTTP.CertFile, c.HTTP.KeyFile) 19 | } else { 20 | err = engine.Run(address) 21 | } 22 | 23 | return err 24 | } 25 | -------------------------------------------------------------------------------- /internal/authentication/credentials/hibp_test.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import "testing" 4 | 5 | type isPasswordPwnedTest struct { 6 | arg string 7 | exp1 bool 8 | exp2 error 9 | } 10 | 11 | var isPasswordPwnedTests = []isPasswordPwnedTest{ 12 | {"", true, nil}, 13 | {"password", true, nil}, 14 | {"2y6bWMETuHpNP08HCZq00QAAzE6nmwEb", false, nil}, 15 | } 16 | 17 | func TestIsPasswordPwned(t *testing.T) { 18 | for _, test := range isPasswordPwnedTests { 19 | if out1, out2 := IsPasswordPwned(test.arg); out1 != test.exp1 || out2 != test.exp2 { 20 | t.Errorf("Output (%t,%q) not equal to expected (%t,%q)", out1, out2, test.exp1, test.exp2) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "make build", 8 | "presentation": { 9 | "panel": "shared", 10 | "reveal": "always", 11 | "focus": true 12 | }, 13 | "problemMatcher": [], 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | }, 19 | { 20 | "label": "test", 21 | "type": "shell", 22 | "command": "make test", 23 | "presentation": { 24 | "panel": "shared", 25 | "reveal": "always", 26 | "focus": true 27 | }, 28 | "problemMatcher": [], 29 | "group": { 30 | "kind": "test", 31 | "isDefault": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /internal/api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // HealthHandler holds information for processing requests about the server's health. 10 | type HealthHandler struct { 11 | DB Database 12 | } 13 | 14 | // Health godoc 15 | // @Summary Health of the application 16 | // @ID get-health 17 | // @Tags Health 18 | // @Accept json,mpfd 19 | // @Produce json 20 | // @Success 200 "" 21 | // @Failure 500 "" 22 | // @Router /health [get] 23 | func (h *HealthHandler) Health(ctx *gin.Context) { 24 | if err := h.DB.Health(); err != nil { 25 | ctx.AbortWithError(http.StatusInternalServerError, err) 26 | return 27 | } 28 | 29 | ctx.JSON(http.StatusOK, gin.H{}) 30 | } 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you for considering contributing to PushBits! 4 | When contributing to this repository, please first discuss the change you wish to make by opening an issue on GitHub. 5 | Please also read our code of conduct; you should follow it in all your interactions with the project. 6 | 7 | ## Pull Requests 8 | 9 | 1. Fork this repository and make your changes. For a minimum consistent coding style, please adhere to our 10 | [EditorConfig](https://editorconfig.org/) style. 11 | 1. Open a pull request to request a merge of your changes into the original repository. 12 | 1. We will review your changes and give you feedback. 13 | 1. Once everything is good to go, we merge your pull request. 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | env: 12 | GO_VERSION: '1.24.3' 13 | PB_BUILD_VERSION: pipeline-${{ github.sha }} 14 | 15 | jobs: 16 | test_build: 17 | name: Test and build 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 10 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v3 23 | 24 | - name: Export GOBIN 25 | uses: actions/setup-go@v4 26 | with: 27 | go-version: '${{env.GO_VERSION}}' 28 | 29 | - name: Install dependencies 30 | run: make setup 31 | 32 | - name: Run tests 33 | run: make test 34 | 35 | - name: Build 36 | run: make build 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright 2020 eikendev 4 | Copyright 2021-2022 The PushBits Developers 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 9 | -------------------------------------------------------------------------------- /internal/api/health_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/pushbits/server/tests" 9 | ) 10 | 11 | func TestApi_Health(t *testing.T) { 12 | ctx := GetTestContext(t) 13 | 14 | assert := assert.New(t) 15 | handler := HealthHandler{ 16 | DB: ctx.Database, 17 | } 18 | 19 | testCases := make([]tests.Request, 0) 20 | testCases = append(testCases, tests.Request{Name: "-", Method: "GET", Endpoint: "/health", Data: "", ShouldStatus: 200}) 21 | 22 | for _, req := range testCases { 23 | w, c, err := req.GetRequest() 24 | if err != nil { 25 | t.Fatal(err.Error()) 26 | } 27 | handler.Health(c) 28 | 29 | assert.Equalf(w.Code, req.ShouldStatus, "Health should result in status code %d but code is %d", req.ShouldStatus, w.Code) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/mockups/application.go: -------------------------------------------------------------------------------- 1 | // Package mockups contains mockup objects and functions for tests. 2 | package mockups 3 | 4 | import "github.com/pushbits/server/internal/model" 5 | 6 | // GetApplication1 returns an application with id 1 7 | func GetApplication1() *model.Application { 8 | return &model.Application{ 9 | ID: 1, 10 | Token: "1234567890abcdefghijklmn", 11 | UserID: 1, 12 | Name: "App1", 13 | } 14 | } 15 | 16 | // GetApplication2 returns an application with id 2 17 | func GetApplication2() *model.Application { 18 | return &model.Application{ 19 | ID: 2, 20 | Token: "0987654321xyzabcdefghij", 21 | UserID: 1, 22 | Name: "App2", 23 | } 24 | } 25 | 26 | // GetAllApplications returns all mock-applications as a list 27 | func GetAllApplications() []*model.Application { 28 | applications := make([]*model.Application, 0) 29 | applications = append(applications, GetApplication1()) 30 | applications = append(applications, GetApplication2()) 31 | 32 | return applications 33 | } 34 | -------------------------------------------------------------------------------- /internal/api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/pushbits/server/internal/authentication" 8 | "github.com/pushbits/server/internal/pberrors" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // SuccessOrAbort is a convenience function to write a HTTP status code based on a given error. 14 | func SuccessOrAbort(ctx *gin.Context, code int, err error) bool { 15 | if err != nil { 16 | // If we know the error force error code 17 | switch err { 18 | case pberrors.ErrMessageNotFound: 19 | ctx.AbortWithError(http.StatusNotFound, err) 20 | default: 21 | ctx.AbortWithError(code, err) 22 | } 23 | } 24 | 25 | return err == nil 26 | } 27 | 28 | func isCurrentUser(ctx *gin.Context, id uint) bool { 29 | user := authentication.GetUser(ctx) 30 | if user == nil { 31 | return false 32 | } 33 | 34 | if user.ID != id { 35 | ctx.AbortWithError(http.StatusForbidden, errors.New("only owner can delete application")) 36 | return false 37 | } 38 | 39 | return true 40 | } 41 | -------------------------------------------------------------------------------- /internal/authentication/credentials/credentials.go: -------------------------------------------------------------------------------- 1 | // Package credentials provides definitions and functionality related to credential management. 2 | package credentials 3 | 4 | import ( 5 | "github.com/pushbits/server/internal/configuration" 6 | "github.com/pushbits/server/internal/log" 7 | 8 | "github.com/alexedwards/argon2id" 9 | ) 10 | 11 | // Manager holds information for managing credentials. 12 | type Manager struct { 13 | checkHIBP bool 14 | argon2Params *argon2id.Params 15 | } 16 | 17 | // CreateManager instanciates a credential manager. 18 | func CreateManager(checkHIBP bool, c configuration.CryptoConfig) *Manager { 19 | log.L.Println("Setting up credential manager.") 20 | 21 | argon2Params := &argon2id.Params{ 22 | Memory: c.Argon2.Memory, 23 | Iterations: c.Argon2.Iterations, 24 | Parallelism: c.Argon2.Parallelism, 25 | SaltLength: c.Argon2.SaltLength, 26 | KeyLength: c.Argon2.KeyLength, 27 | } 28 | 29 | return &Manager{ 30 | checkHIBP: checkHIBP, 31 | argon2Params: argon2Params, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/authentication/context.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/pushbits/server/internal/model" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // GetApplication returns the application which was previously registered by the authentication middleware. 13 | func GetApplication(ctx *gin.Context) *model.Application { 14 | app, ok := ctx.MustGet("app").(*model.Application) 15 | if app == nil || !ok { 16 | ctx.AbortWithError(http.StatusInternalServerError, errors.New("an error occurred while retrieving application from context")) 17 | return nil 18 | } 19 | 20 | return app 21 | } 22 | 23 | // GetUser returns the user which was previously registered by the authentication middleware. 24 | func GetUser(ctx *gin.Context) *model.User { 25 | user, ok := ctx.MustGet("user").(*model.User) 26 | if user == nil || !ok { 27 | ctx.AbortWithError(http.StatusInternalServerError, errors.New("an error occurred while retrieving user from context")) 28 | return nil 29 | } 30 | 31 | return user 32 | } 33 | -------------------------------------------------------------------------------- /tests/mockups/config.go: -------------------------------------------------------------------------------- 1 | package mockups 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/pushbits/server/internal/configuration" 8 | "github.com/pushbits/server/internal/log" 9 | ) 10 | 11 | // ReadConfig copies the given filename to the current folder and parses it as a config file. RemoveFile indicates whether to remove the copied file or not 12 | func ReadConfig(filename string, removeFile bool) (config *configuration.Configuration, err error) { 13 | defer func() { 14 | if r := recover(); r != nil { 15 | log.L.Println(r) 16 | err = errors.New("paniced while reading config") 17 | } 18 | }() 19 | 20 | if filename == "" { 21 | return nil, errors.New("empty filename") 22 | } 23 | 24 | file, err := os.ReadFile(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | err = os.WriteFile("config.yml", file, 0o644) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | config = configuration.Get() 35 | 36 | if removeFile { 37 | err = os.Remove("config.yml") 38 | if err != nil { 39 | return nil, err 40 | } 41 | } 42 | 43 | return config, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | // IDInURI is used to retrieve an ID from a context. 8 | type IDInURI struct { 9 | ID uint `uri:"id" binding:"required"` 10 | } 11 | 12 | // messageIDInURI is used to retrieve an message ID from a context. 13 | type messageIDInURI struct { 14 | MessageID string `uri:"messageid" binding:"required"` 15 | } 16 | 17 | // RequireIDInURI returns a Gin middleware which requires an ID to be supplied in the URI of the request. 18 | func RequireIDInURI() gin.HandlerFunc { 19 | return func(ctx *gin.Context) { 20 | var requestModel IDInURI 21 | 22 | if err := ctx.BindUri(&requestModel); err != nil { 23 | return 24 | } 25 | 26 | ctx.Set("id", requestModel.ID) 27 | } 28 | } 29 | 30 | // RequireMessageIDInURI returns a Gin middleware which requires an messageID to be supplied in the URI of the request. 31 | func RequireMessageIDInURI() gin.HandlerFunc { 32 | return func(ctx *gin.Context) { 33 | var requestModel messageIDInURI 34 | 35 | if err := ctx.BindUri(&requestModel); err != nil { 36 | return 37 | } 38 | 39 | ctx.Set("messageid", requestModel.MessageID) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/authentication/credentials/password.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/alexedwards/argon2id" 7 | 8 | "github.com/pushbits/server/internal/log" 9 | ) 10 | 11 | // CreatePasswordHash returns a hashed version of the given password. 12 | func (m *Manager) CreatePasswordHash(password string) ([]byte, error) { 13 | if m.checkHIBP { 14 | pwned, err := IsPasswordPwned(password) 15 | if err != nil { 16 | return []byte{}, errors.New("HIBP is not available, please wait until service is available again") 17 | } else if pwned { 18 | return []byte{}, errors.New("password is pwned, please choose another one") 19 | } 20 | } 21 | 22 | hash, err := argon2id.CreateHash(password, m.argon2Params) 23 | if err != nil { 24 | log.L.Fatal(err) 25 | panic(err) 26 | } 27 | 28 | return []byte(hash), nil 29 | } 30 | 31 | // ComparePassword compares a hashed password with its possible plaintext equivalent. 32 | func ComparePassword(hash, password []byte) bool { 33 | match, err := argon2id.ComparePasswordAndHash(string(password), string(hash)) 34 | if err != nil { 35 | log.L.Fatal(err) 36 | return false 37 | } 38 | 39 | return match 40 | } 41 | -------------------------------------------------------------------------------- /internal/model/application.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Application holds information like the name, the token, and the associated user of an application. 4 | type Application struct { 5 | ID uint `gorm:"AUTO_INCREMENT;primary_key" json:"id"` 6 | Token string `gorm:"type:string;size:64;unique" json:"token"` 7 | UserID uint `json:"-"` 8 | Name string `gorm:"type:string" json:"name"` 9 | MatrixID string `gorm:"type:string" json:"-"` 10 | } 11 | 12 | // CreateApplication is used to process queries for creating applications. 13 | type CreateApplication struct { 14 | Name string `form:"name" query:"name" json:"name" binding:"required"` 15 | StrictCompatibility bool `form:"strict_compatibility" query:"strict_compatibility" json:"strict_compatibility"` 16 | } 17 | 18 | // UpdateApplication is used to process queries for updating applications. 19 | type UpdateApplication struct { 20 | Name *string `form:"new_name" query:"new_name" json:"new_name"` 21 | RefreshToken *bool `form:"refresh_token" query:"refresh_token" json:"refresh_token"` 22 | StrictCompatibility *bool `form:"strict_compatibility" query:"strict_compatibility" json:"strict_compatibility"` 23 | } 24 | -------------------------------------------------------------------------------- /tests/mockups/user.go: -------------------------------------------------------------------------------- 1 | package mockups 2 | 3 | import ( 4 | "github.com/pushbits/server/internal/authentication/credentials" 5 | "github.com/pushbits/server/internal/configuration" 6 | "github.com/pushbits/server/internal/model" 7 | ) 8 | 9 | // GetAdminUser returns an admin user 10 | func GetAdminUser(c *configuration.Configuration) *model.User { 11 | credentialsManager := credentials.CreateManager(false, c.Crypto) 12 | hash, _ := credentialsManager.CreatePasswordHash(c.Admin.Password) 13 | 14 | return &model.User{ 15 | ID: 1, 16 | Name: c.Admin.Name, 17 | PasswordHash: hash, 18 | IsAdmin: true, 19 | MatrixID: c.Admin.MatrixID, 20 | } 21 | } 22 | 23 | // GetUser returns an user 24 | func GetUser(c *configuration.Configuration) *model.User { 25 | credentialsManager := credentials.CreateManager(false, c.Crypto) 26 | hash, _ := credentialsManager.CreatePasswordHash(c.Admin.Password) 27 | 28 | return &model.User{ 29 | ID: 2, 30 | Name: c.Admin.Name + "-normalo", 31 | PasswordHash: hash, 32 | IsAdmin: false, 33 | MatrixID: c.Admin.MatrixID, 34 | } 35 | } 36 | 37 | // GetUsers returns a list of users 38 | func GetUsers(c *configuration.Configuration) []*model.User { 39 | var users []*model.User 40 | users = append(users, GetAdminUser(c)) 41 | users = append(users, GetUser(c)) 42 | return users 43 | } 44 | -------------------------------------------------------------------------------- /tests/request.go: -------------------------------------------------------------------------------- 1 | // Package tests provides definitions and functionality related to unit and integration tests. 2 | package tests 3 | 4 | import ( 5 | "encoding/json" 6 | "io" 7 | "net/http/httptest" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // Request holds information for a HTTP request 14 | type Request struct { 15 | Name string 16 | Method string 17 | Endpoint string 18 | Data interface{} 19 | Headers map[string]string 20 | ShouldStatus int 21 | ShouldReturn interface{} 22 | } 23 | 24 | // GetRequest returns a ResponseRecorder and gin context according to the data set in the Request. 25 | // String data is passed as is, all other data types are marshaled before. 26 | func (r *Request) GetRequest() (w *httptest.ResponseRecorder, c *gin.Context, err error) { 27 | w = httptest.NewRecorder() 28 | var body io.Reader 29 | 30 | switch data := r.Data.(type) { 31 | case string: 32 | body = strings.NewReader(data) 33 | default: 34 | dataMarshaled, err := json.Marshal(data) 35 | if err != nil { 36 | return nil, nil, err 37 | } 38 | body = strings.NewReader(string(dataMarshaled)) 39 | } 40 | 41 | c, _ = gin.CreateTestContext(w) 42 | c.Request = httptest.NewRequest(r.Method, r.Endpoint, body) 43 | 44 | for name, value := range r.Headers { 45 | c.Request.Header.Set(name, value) 46 | } 47 | 48 | return w, c, nil 49 | } 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:alpine as builder 2 | 3 | ARG CLI_VERSION=0.0.6 4 | ARG CLI_PLATFORM=linux_amd64 5 | 6 | WORKDIR /build 7 | 8 | COPY . . 9 | 10 | RUN set -ex \ 11 | && apk add --no-cache build-base ca-certificates curl \ 12 | && go mod download \ 13 | && go mod verify \ 14 | && make build \ 15 | && chmod +x /build/out/pushbits \ 16 | && curl -q -s -S -L -o /tmp/pbcli_${CLI_VERSION}.tar.gz https://github.com/pushbits/cli/releases/download/v${CLI_VERSION}/pbcli_${CLI_VERSION}_${CLI_PLATFORM}.tar.gz \ 17 | && tar -C /usr/local/bin -xvf /tmp/pbcli_${CLI_VERSION}.tar.gz pbcli \ 18 | && chown root:root /usr/local/bin/pbcli \ 19 | && chmod +x /usr/local/bin/pbcli 20 | 21 | FROM docker.io/library/alpine 22 | 23 | ARG USER_ID=1000 24 | 25 | ENV PUSHBITS_HTTP_PORT="8080" 26 | 27 | EXPOSE 8080 28 | 29 | WORKDIR /app 30 | 31 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 32 | COPY --from=builder /build/out/pushbits ./run 33 | COPY --from=builder /usr/local/bin/pbcli /usr/local/bin/pbcli 34 | 35 | RUN set -ex \ 36 | && apk add --no-cache ca-certificates curl \ 37 | && update-ca-certificates \ 38 | && mkdir -p /data \ 39 | && ln -s /data/pushbits.db /app/pushbits.db \ 40 | && ln -s /data/config.yml /app/config.yml 41 | 42 | USER ${USER_ID} 43 | 44 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD curl --fail http://localhost:$PUSHBITS_HTTP_PORT/health || exit 1 45 | 46 | ENTRYPOINT ["./run"] 47 | -------------------------------------------------------------------------------- /tests/mockups/dispatcher.go: -------------------------------------------------------------------------------- 1 | package mockups 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pushbits/server/internal/configuration" 7 | "github.com/pushbits/server/internal/model" 8 | ) 9 | 10 | // MockDispatcher is a dispatcher used for testing - it does not need any storage interface 11 | type MockDispatcher struct{} 12 | 13 | // RegisterApplication mocks a functions to create a channel for an application. 14 | func (*MockDispatcher) RegisterApplication(id uint, name, _ string) (string, error) { 15 | return fmt.Sprintf("%d-%s", id, name), nil 16 | } 17 | 18 | // DeregisterApplication mocks a function to delete a channel for an application. 19 | func (*MockDispatcher) DeregisterApplication(_ *model.Application, _ *model.User) error { 20 | return nil 21 | } 22 | 23 | // UpdateApplication mocks a function to update a channel for an application. 24 | func (*MockDispatcher) UpdateApplication(_ *model.Application, _ *configuration.RepairBehavior) error { 25 | return nil 26 | } 27 | 28 | // SendNotification mocks a function to send a notification to a given user. 29 | func (*MockDispatcher) SendNotification(_ *model.Application, _ *model.Notification) (id string, err error) { 30 | return randStr(15), nil 31 | } 32 | 33 | // DeleteNotification mocks a function to send a notification to a given user that another notification is deleted 34 | func (*MockDispatcher) DeleteNotification(_ *model.Application, _ *model.DeleteNotification) error { 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/authentication/token_test.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | const ( 11 | minRandomChars = 14 12 | ) 13 | 14 | func isGoodToken(assert *assert.Assertions, _ *require.Assertions, token string, compat bool) { 15 | tokenLength := len(token) 16 | 17 | if compat { 18 | assert.Equal(tokenLength, compatTokenLength, "Unexpected compatibility token length") 19 | } else { 20 | assert.Equal(tokenLength, regularTokenLength, "Unexpected regular token length") 21 | } 22 | 23 | randomChars := tokenLength - len(applicationTokenPrefix) 24 | assert.GreaterOrEqual(randomChars, minRandomChars, "Token is too short to give sufficient entropy") 25 | 26 | prefix := token[0:len(applicationTokenPrefix)] 27 | assert.Equal(prefix, applicationTokenPrefix, "Invalid token prefix") 28 | 29 | for _, c := range []byte(token) { 30 | assert.Contains(tokenCharacters, c, "Unexpected character in token") 31 | } 32 | } 33 | 34 | func TestAuthentication_GenerateApplicationToken(t *testing.T) { 35 | assert := assert.New(t) 36 | require := require.New(t) 37 | 38 | for i := 0; i < 64; i++ { 39 | token := GenerateApplicationToken(false) 40 | 41 | isGoodToken(assert, require, token, false) 42 | } 43 | 44 | for i := 0; i < 64; i++ { 45 | token := GenerateApplicationToken(true) 46 | 47 | isGoodToken(assert, require, token, true) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/interfaces.go: -------------------------------------------------------------------------------- 1 | // Package api provides definitions and functionality related to the API. 2 | package api 3 | 4 | import ( 5 | "github.com/pushbits/server/internal/configuration" 6 | "github.com/pushbits/server/internal/model" 7 | ) 8 | 9 | // The Database interface for encapsulating database access. 10 | type Database interface { 11 | Health() error 12 | 13 | CreateApplication(application *model.Application) error 14 | DeleteApplication(application *model.Application) error 15 | GetApplicationByID(ID uint) (*model.Application, error) 16 | GetApplicationByToken(token string) (*model.Application, error) 17 | UpdateApplication(application *model.Application) error 18 | 19 | AdminUserCount() (int64, error) 20 | CreateUser(user model.CreateUser) (*model.User, error) 21 | DeleteUser(user *model.User) error 22 | GetApplications(user *model.User) ([]model.Application, error) 23 | GetUserByID(ID uint) (*model.User, error) 24 | GetUserByName(name string) (*model.User, error) 25 | GetUsers() ([]model.User, error) 26 | UpdateUser(user *model.User) error 27 | } 28 | 29 | // The Dispatcher interface for relaying notifications. 30 | type Dispatcher interface { 31 | RegisterApplication(id uint, name, user string) (string, error) 32 | DeregisterApplication(a *model.Application, u *model.User) error 33 | UpdateApplication(a *model.Application, behavior *configuration.RepairBehavior) error 34 | } 35 | 36 | // The CredentialsManager interface for updating credentials. 37 | type CredentialsManager interface { 38 | CreatePasswordHash(password string) ([]byte, error) 39 | } 40 | -------------------------------------------------------------------------------- /internal/model/notification.go: -------------------------------------------------------------------------------- 1 | // Package model contains structs used in the PushBits API and across the application. 2 | package model 3 | 4 | import ( 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // Notification holds information like the message, the title, and the priority of a notification. 10 | type Notification struct { 11 | ID string `json:"id"` 12 | URLEncodedID string `json:"id_url_encoded"` 13 | ApplicationID uint `json:"appid"` 14 | Message string `json:"message" form:"message" query:"message" binding:"required"` 15 | Title string `json:"title" form:"title" query:"title"` 16 | Priority int `json:"priority" form:"priority" query:"priority"` 17 | Extras map[string]interface{} `json:"extras,omitempty" form:"-" query:"-"` 18 | Date time.Time `json:"date"` 19 | } 20 | 21 | // Sanitize sets explicit defaults for a notification. 22 | func (n *Notification) Sanitize(application *Application) { 23 | n.ID = "" 24 | n.URLEncodedID = "" 25 | n.ApplicationID = application.ID 26 | if strings.TrimSpace(n.Title) == "" { 27 | n.Title = application.Name 28 | } 29 | n.Date = time.Now() 30 | } 31 | 32 | // DeleteNotification holds information like the message ID of a deletion notification. 33 | type DeleteNotification struct { 34 | ID string `json:"id" form:"id"` 35 | Date time.Time `json:"date"` 36 | } 37 | 38 | // NotificationExtras is need to document Notification.Extras in a format that the tool can read. 39 | type NotificationExtras map[string]interface{} 40 | -------------------------------------------------------------------------------- /internal/database/application.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/pushbits/server/internal/assert" 7 | "github.com/pushbits/server/internal/model" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | // CreateApplication creates an application. 13 | func (d *Database) CreateApplication(application *model.Application) error { 14 | return d.gormdb.Create(application).Error 15 | } 16 | 17 | // DeleteApplication deletes an application. 18 | func (d *Database) DeleteApplication(application *model.Application) error { 19 | return d.gormdb.Delete(application).Error 20 | } 21 | 22 | // UpdateApplication updates an application. 23 | func (d *Database) UpdateApplication(application *model.Application) error { 24 | return d.gormdb.Save(application).Error 25 | } 26 | 27 | // GetApplicationByID returns the application with the given ID or nil. 28 | func (d *Database) GetApplicationByID(id uint) (*model.Application, error) { 29 | var application model.Application 30 | 31 | err := d.gormdb.First(&application, id).Error 32 | 33 | if errors.Is(err, gorm.ErrRecordNotFound) { 34 | return nil, err 35 | } 36 | 37 | assert.Assert(application.ID == id) 38 | 39 | return &application, err 40 | } 41 | 42 | // GetApplicationByToken returns the application with the given token or nil. 43 | func (d *Database) GetApplicationByToken(token string) (*model.Application, error) { 44 | var application model.Application 45 | 46 | err := d.gormdb.Where("token = ?", token).First(&application).Error 47 | 48 | if errors.Is(err, gorm.ErrRecordNotFound) { 49 | return nil, err 50 | } 51 | 52 | assert.Assert(application.Token == token) 53 | 54 | return &application, err 55 | } 56 | -------------------------------------------------------------------------------- /internal/api/context.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/pushbits/server/internal/model" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func getID(ctx *gin.Context) (uint, error) { 13 | id, ok := ctx.MustGet("id").(uint) 14 | if !ok { 15 | err := errors.New("an error occurred while retrieving ID from context") 16 | ctx.AbortWithError(http.StatusInternalServerError, err) 17 | return 0, err 18 | } 19 | 20 | return id, nil 21 | } 22 | 23 | func getMessageID(ctx *gin.Context) (string, error) { 24 | id, ok := ctx.MustGet("messageid").(string) 25 | if !ok { 26 | err := errors.New("an error occurred while retrieving messageID from context") 27 | ctx.AbortWithError(http.StatusInternalServerError, err) 28 | return "", err 29 | } 30 | 31 | return id, nil 32 | } 33 | 34 | func getApplication(ctx *gin.Context, db Database) (*model.Application, error) { 35 | id, err := getID(ctx) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | application, err := db.GetApplicationByID(id) 41 | if success := SuccessOrAbort(ctx, http.StatusNotFound, err); !success { 42 | return nil, err 43 | } 44 | if application == nil { 45 | err := errors.New("application not found") 46 | ctx.AbortWithError(http.StatusNotFound, err) 47 | return nil, err 48 | } 49 | 50 | return application, nil 51 | } 52 | 53 | func getUser(ctx *gin.Context, db Database) (*model.User, error) { 54 | id, err := getID(ctx) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | user, err := db.GetUserByID(id) 60 | if success := SuccessOrAbort(ctx, http.StatusNotFound, err); !success { 61 | return nil, err 62 | } 63 | 64 | return user, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/authentication/token.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | var ( 9 | tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 10 | regularTokenLength = 64 // This length includes the prefix (one character). 11 | compatTokenLength = 15 // This length includes the prefix (one character). 12 | applicationTokenPrefix = "A" 13 | ) 14 | 15 | func randIntn(n int) int { 16 | maxValue := big.NewInt(int64(n)) 17 | 18 | res, err := rand.Int(rand.Reader, maxValue) 19 | if err != nil { 20 | panic("random source is not available") 21 | } 22 | 23 | return int(res.Int64()) 24 | } 25 | 26 | // GenerateNotExistingToken receives a token generation function and a function to check whether the token exists, returns a unique token. 27 | func GenerateNotExistingToken(generateToken func(bool) string, compat bool, tokenExists func(token string) bool) string { 28 | for { 29 | token := generateToken(compat) 30 | 31 | if !tokenExists(token) { 32 | return token 33 | } 34 | } 35 | } 36 | 37 | func generateRandomString(length int) string { 38 | res := make([]byte, length) 39 | 40 | for i := range res { 41 | index := randIntn(len(tokenCharacters)) 42 | res[i] = tokenCharacters[index] 43 | } 44 | 45 | return string(res) 46 | } 47 | 48 | // GenerateApplicationToken generates a token for an application. 49 | func GenerateApplicationToken(compat bool) string { 50 | tokenLength := regularTokenLength 51 | 52 | if compat { 53 | tokenLength = compatTokenLength 54 | } 55 | 56 | tokenLength -= len(applicationTokenPrefix) 57 | 58 | return applicationTokenPrefix + generateRandomString(tokenLength) 59 | } 60 | -------------------------------------------------------------------------------- /tests/mockups/database.go: -------------------------------------------------------------------------------- 1 | package mockups 2 | 3 | import ( 4 | "github.com/pushbits/server/internal/authentication/credentials" 5 | "github.com/pushbits/server/internal/configuration" 6 | "github.com/pushbits/server/internal/database" 7 | "github.com/pushbits/server/internal/model" 8 | ) 9 | 10 | // GetEmptyDatabase returns an empty sqlite database object 11 | func GetEmptyDatabase(confCrypto configuration.CryptoConfig) (*database.Database, error) { 12 | cm := credentials.CreateManager(false, confCrypto) 13 | return database.Create(cm, "sqlite3", "pushbits-test.db") 14 | } 15 | 16 | // AddApplicationsToDb inserts the applications apps into the database db 17 | func AddApplicationsToDb(db *database.Database, apps []*model.Application) error { 18 | for _, app := range apps { 19 | err := db.CreateApplication(app) 20 | if err != nil { 21 | return err 22 | } 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // AddUsersToDb adds the users to the database and sets their username as a password, returns list of added users 29 | func AddUsersToDb(db *database.Database, users []*model.User) ([]*model.User, error) { 30 | addedUsers := make([]*model.User, 0) 31 | 32 | for _, user := range users { 33 | extUser := model.ExternalUser{ 34 | ID: user.ID, 35 | Name: user.Name, 36 | IsAdmin: user.IsAdmin, 37 | MatrixID: user.MatrixID, 38 | } 39 | credentials := model.UserCredentials{ 40 | Password: user.Name, 41 | } 42 | createUser := model.CreateUser{ExternalUser: extUser, UserCredentials: credentials} 43 | 44 | newUser, err := db.CreateUser(createUser) 45 | addedUsers = append(addedUsers, newUser) 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | return addedUsers, nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/dispatcher/dispatcher.go: -------------------------------------------------------------------------------- 1 | // Package dispatcher provides definitions and functionality related to executing Matrix requests. 2 | package dispatcher 3 | 4 | import ( 5 | "context" 6 | 7 | "maunium.net/go/mautrix" 8 | "maunium.net/go/mautrix/id" 9 | 10 | "github.com/pushbits/server/internal/configuration" 11 | "github.com/pushbits/server/internal/log" 12 | ) 13 | 14 | // Dispatcher holds information for sending notifications to clients. 15 | type Dispatcher struct { 16 | mautrixClient *mautrix.Client 17 | formatting configuration.Formatting 18 | } 19 | 20 | // Create instanciates a dispatcher connection. 21 | func Create(homeserver, username, password string, formatting configuration.Formatting) (*Dispatcher, error) { 22 | log.L.Println("Setting up dispatcher.") 23 | 24 | matrixClient, err := mautrix.NewClient(homeserver, "", "") 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | _, err = matrixClient.Login(context.Background(), &mautrix.ReqLogin{ 30 | Type: mautrix.AuthTypePassword, 31 | Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: username}, 32 | Password: password, 33 | DeviceID: id.DeviceID("PushBits"), 34 | StoreCredentials: true, 35 | }) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return &Dispatcher{formatting: formatting, mautrixClient: matrixClient}, nil 41 | } 42 | 43 | // Close closes the dispatcher connection. 44 | func (d *Dispatcher) Close() { 45 | log.L.Printf("Logging out.") 46 | 47 | _, err := d.mautrixClient.Logout(context.Background()) 48 | if err != nil { 49 | log.L.Printf("Error while logging out: %s", err) 50 | } 51 | 52 | d.mautrixClient.ClearCredentials() 53 | 54 | log.L.Printf("Successfully logged out.") 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | env: 9 | GO_VERSION: '1.24.3' 10 | PB_BUILD_VERSION: unknown # Needed for using Make targets. 11 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 12 | 13 | jobs: 14 | build_documentation: 15 | name: Build documentation 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Export GOBIN 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: '${{env.GO_VERSION}}' 26 | 27 | - name: Install dependencies 28 | run: make setup 29 | 30 | - name: Install redoc 31 | run: sudo apt install npm && sudo npm install redoc 32 | 33 | - name: Build the API documentation 34 | run: make swag 35 | 36 | - name: Build static HTML 37 | run: npx redoc-cli build docs/swagger.yaml --output index.html 38 | 39 | - name: Setup SSH keys and known_hosts 40 | run: | 41 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 42 | ssh-add - <<< "${{ secrets.WEBSITE_DEPLOY_KEY }}" 43 | 44 | - name: Checkout website 45 | run: mkdir website && git clone git@github.com:pushbits/website.git website 46 | 47 | - name: Copy index.html 48 | run: cp index.html website/static/api/index.html 49 | 50 | - name: Set Git config 51 | run: git config --global user.email "pipeline@pushbits.io" && git config --global user.name "PushBits Pipeline" 52 | 53 | - name: Commit and push 54 | run: | 55 | cd website 56 | git diff --quiet || ( git add . && git commit -m "Update documentation to ${{ github.sha }}" && git push ) 57 | -------------------------------------------------------------------------------- /internal/authentication/credentials/hibp.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | "crypto/sha1" //#nosec G505 -- False positive, see the use below. 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/pushbits/server/internal/log" 11 | ) 12 | 13 | const ( 14 | base = "https://api.pwnedpasswords.com" 15 | pwnedHashesEndpoint = "/range" 16 | pwnedHashesURL = base + pwnedHashesEndpoint + "/" 17 | ) 18 | 19 | // IsPasswordPwned determines whether or not the password is weak. 20 | func IsPasswordPwned(password string) (bool, error) { 21 | if len(password) == 0 { 22 | return true, nil 23 | } 24 | 25 | hash := sha1.Sum([]byte(password)) //#nosec G401 -- False positive, only the first 5 bytes are transmitted. 26 | hashStr := fmt.Sprintf("%X", hash) 27 | lookup := hashStr[0:5] 28 | match := hashStr[5:] 29 | 30 | log.L.Printf("Checking HIBP for hashes starting with '%s'.", lookup) 31 | 32 | resp, err := http.Get(pwnedHashesURL + lookup) 33 | if err != nil { 34 | return false, err 35 | } 36 | if resp == nil { 37 | return false, fmt.Errorf("received nil response from http request") 38 | } 39 | 40 | if resp.StatusCode != http.StatusOK { 41 | log.L.Fatalf("Request failed with HTTP %s.", resp.Status) 42 | } 43 | 44 | bodyText, err := io.ReadAll(resp.Body) 45 | if err != nil { 46 | log.L.Fatal(err) 47 | } 48 | 49 | err = resp.Body.Close() 50 | if err != nil { 51 | log.L.Warnf("Failed to close file: %s.", err) 52 | } 53 | 54 | bodyStr := string(bodyText) 55 | lines := strings.Split(bodyStr, "\n") 56 | 57 | for _, line := range lines { 58 | separated := strings.Split(line, ":") 59 | if len(separated) != 2 { 60 | return false, fmt.Errorf("HIPB API returned malformed response: %s", line) 61 | } 62 | 63 | if separated[0] == match { 64 | return true, nil 65 | } 66 | } 67 | 68 | return false, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/log/ginlogrus.go: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/toorop/gin-logrus 2 | 3 | // Package log provides a connector between gin and logrus. 4 | package log 5 | 6 | import ( 7 | "fmt" 8 | "math" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // GinLogger integrates logrus with gin 18 | func GinLogger(logger logrus.FieldLogger, notLogged ...string) gin.HandlerFunc { 19 | hostname, err := os.Hostname() 20 | if err != nil { 21 | hostname = "unknow" 22 | } 23 | 24 | var skip map[string]struct{} 25 | 26 | if length := len(notLogged); length > 0 { 27 | skip = make(map[string]struct{}, length) 28 | 29 | for _, p := range notLogged { 30 | skip[p] = struct{}{} 31 | } 32 | } 33 | 34 | return func(c *gin.Context) { 35 | path := c.Request.URL.Path 36 | start := time.Now() 37 | c.Next() 38 | stop := time.Since(start) 39 | latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1000000.0)) 40 | statusCode := c.Writer.Status() 41 | clientIP := c.ClientIP() 42 | clientUserAgent := c.Request.UserAgent() 43 | referer := c.Request.Referer() 44 | dataLength := c.Writer.Size() 45 | if dataLength < 0 { 46 | dataLength = 0 47 | } 48 | 49 | if _, ok := skip[path]; ok { 50 | return 51 | } 52 | 53 | entry := logger.WithFields(logrus.Fields{ 54 | "hostname": hostname, 55 | "statusCode": statusCode, 56 | "latency": latency, 57 | "clientIP": clientIP, 58 | "method": c.Request.Method, 59 | "path": path, 60 | "referer": referer, 61 | "dataLength": dataLength, 62 | "userAgent": clientUserAgent, 63 | }) 64 | 65 | if len(c.Errors) > 0 { 66 | entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) 67 | } else { 68 | msg := fmt.Sprintf("%s [%d] %s %s", clientIP, statusCode, c.Request.Method, path) 69 | if statusCode >= http.StatusInternalServerError { 70 | entry.Error(msg) 71 | } else if statusCode >= http.StatusBadRequest { 72 | entry.Warn(msg) 73 | } else { 74 | entry.Info(msg) 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCS_DIR := ./docs 2 | OUT_DIR := ./out 3 | TESTS_DIR := ./tests 4 | 5 | GO_FILES := $(shell find . -type f \( -iname '*.go' \)) 6 | GO_MODULE := github.com/pushbits/server 7 | 8 | .PHONY: build 9 | build: 10 | mkdir -p $(OUT_DIR) 11 | go build -ldflags="-w -s" -o $(OUT_DIR)/pushbits ./cmd/pushbits 12 | 13 | .PHONY: clean 14 | clean: 15 | rm -rf $(DOCS_DIR) 16 | rm -rf $(OUT_DIR) 17 | 18 | .PHONY: test 19 | test: 20 | if [ -n "$$(gofumpt -l $(GO_FILES))" ]; then echo "Code is not properly formatted"; exit 1; fi 21 | if [ -n "$$(goimports -l -local $(GO_MODULE) $(GO_FILES))" ]; then echo "Imports are not properly formatted"; exit 1; fi 22 | go vet ./... 23 | misspell -error $(GO_FILES) 24 | gocyclo -over 10 $(GO_FILES) 25 | staticcheck ./... 26 | errcheck -ignoregenerated -exclude errcheck_excludes.txt ./... 27 | gocritic check -disable='#experimental,#opinionated' -@ifElseChain.minThreshold 3 ./... 28 | revive -set_exit_status -exclude ./docs ./... 29 | nilaway ./... 30 | go test -v -cover ./... 31 | gosec -exclude-generated -exclude-dir=tests ./... 32 | govulncheck ./... 33 | @printf '\n%s\n' "> Test successful" 34 | 35 | .PHONY: setup 36 | setup: 37 | go install github.com/client9/misspell/cmd/misspell@latest 38 | go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 39 | go install github.com/go-critic/go-critic/cmd/gocritic@latest 40 | go install github.com/kisielk/errcheck@latest 41 | go install github.com/mgechev/revive@latest 42 | go install github.com/securego/gosec/v2/cmd/gosec@latest 43 | go install github.com/swaggo/swag/cmd/swag@latest 44 | go install go.uber.org/nilaway/cmd/nilaway@latest 45 | go install golang.org/x/tools/cmd/goimports@latest 46 | go install golang.org/x/vuln/cmd/govulncheck@latest 47 | go install honnef.co/go/tools/cmd/staticcheck@latest 48 | go install mvdan.cc/gofumpt@latest 49 | 50 | .PHONY: fmt 51 | fmt: 52 | gofumpt -l -w $(GO_FILES) 53 | 54 | .PHONY: swag 55 | swag: build 56 | swag init --parseDependency=true --exclude $(TESTS_DIR) -g cmd/pushbits/main.go 57 | 58 | .PHONY: docker_build_dev 59 | docker_build_dev: 60 | podman build \ 61 | -t local/pushbits . 62 | 63 | .PHONY: run_postgres_debug 64 | podman run \ 65 | --rm \ 66 | --name=postgres \ 67 | --network=host \ 68 | --env-file \ 69 | postgres-debug.env docker.io/library/postgres:15 70 | -------------------------------------------------------------------------------- /internal/api/alertmanager/handler.go: -------------------------------------------------------------------------------- 1 | // Package alertmanager provides definitions and functionality related to Alertmanager notifications. 2 | package alertmanager 3 | 4 | import ( 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/pushbits/server/internal/api" 11 | "github.com/pushbits/server/internal/authentication" 12 | "github.com/pushbits/server/internal/log" 13 | "github.com/pushbits/server/internal/model" 14 | ) 15 | 16 | // Handler holds information for processing alerts received via Alertmanager. 17 | type Handler struct { 18 | DP api.NotificationDispatcher 19 | Settings HandlerSettings 20 | } 21 | 22 | // HandlerSettings represents the settings for processing alerts received via Alertmanager. 23 | type HandlerSettings struct { 24 | TitleAnnotation string 25 | MessageAnnotation string 26 | } 27 | 28 | // CreateAlert godoc 29 | // @Summary Create an Alert 30 | // @Description Creates an alert that is send to the channel as a notification. This endpoint is compatible with alertmanager webhooks. 31 | // @ID post-alert 32 | // @Tags Alertmanager 33 | // @Accept json 34 | // @Produce json 35 | // @Param token query string true "Channels token, can also be provieded in the header" 36 | // @Param data body model.AlertmanagerWebhook true "alertmanager webhook call" 37 | // @Success 200 {object} []model.Notification 38 | // @Failure 500,404,403 "" 39 | // @Router /alert [post] 40 | func (h *Handler) CreateAlert(ctx *gin.Context) { 41 | application := authentication.GetApplication(ctx) 42 | if application == nil { 43 | return 44 | } 45 | 46 | log.L.Printf("Sending alert notification for application %s.", application.Name) 47 | 48 | var hook model.AlertmanagerWebhook 49 | if err := ctx.Bind(&hook); err != nil { 50 | return 51 | } 52 | 53 | notifications := make([]model.Notification, len(hook.Alerts)) 54 | for i, alert := range hook.Alerts { 55 | notification := alert.ToNotification(h.Settings.TitleAnnotation, h.Settings.MessageAnnotation) 56 | notification.Sanitize(application) 57 | messageID, err := h.DP.SendNotification(application, ¬ification) 58 | if success := api.SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 59 | return 60 | } 61 | 62 | notification.ID = messageID 63 | notification.URLEncodedID = url.QueryEscape(messageID) 64 | notifications[i] = notification 65 | } 66 | ctx.JSON(http.StatusOK, ¬ifications) 67 | } 68 | -------------------------------------------------------------------------------- /internal/database/user.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/pushbits/server/internal/assert" 7 | "github.com/pushbits/server/internal/model" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | // CreateUser creates a user. 13 | func (d *Database) CreateUser(createUser model.CreateUser) (*model.User, error) { 14 | user, err := createUser.IntoInternalUser(d.credentialsManager) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return user, d.gormdb.Create(user).Error 20 | } 21 | 22 | // DeleteUser deletes a user. 23 | func (d *Database) DeleteUser(user *model.User) error { 24 | if err := d.gormdb.Where("user_id = ?", user.ID).Delete(model.Application{}).Error; err != nil { 25 | return err 26 | } 27 | 28 | return d.gormdb.Delete(user).Error 29 | } 30 | 31 | // UpdateUser updates a user. 32 | func (d *Database) UpdateUser(user *model.User) error { 33 | return d.gormdb.Save(user).Error 34 | } 35 | 36 | // GetUserByID returns the user with the given ID or nil. 37 | func (d *Database) GetUserByID(id uint) (*model.User, error) { 38 | var user model.User 39 | 40 | err := d.gormdb.First(&user, id).Error 41 | 42 | if errors.Is(err, gorm.ErrRecordNotFound) { 43 | return nil, err 44 | } 45 | 46 | assert.Assert(user.ID == id) 47 | 48 | return &user, err 49 | } 50 | 51 | // GetUserByName returns the user with the given name or nil. 52 | func (d *Database) GetUserByName(name string) (*model.User, error) { 53 | var user model.User 54 | 55 | err := d.gormdb.Where("name = ?", name).First(&user).Error 56 | 57 | if errors.Is(err, gorm.ErrRecordNotFound) { 58 | return nil, err 59 | } 60 | 61 | assert.Assert(user.Name == name) 62 | 63 | return &user, err 64 | } 65 | 66 | // GetApplications returns the applications associated with a given user. 67 | func (d *Database) GetApplications(user *model.User) ([]model.Application, error) { 68 | var applications []model.Application 69 | 70 | err := d.gormdb.Model(user).Association("Applications").Find(&applications) 71 | 72 | return applications, err 73 | } 74 | 75 | // GetUsers returns all users. 76 | func (d *Database) GetUsers() ([]model.User, error) { 77 | var users []model.User 78 | 79 | err := d.gormdb.Find(&users).Error 80 | 81 | return users, err 82 | } 83 | 84 | // AdminUserCount returns the number of admins or an error. 85 | func (d *Database) AdminUserCount() (int64, error) { 86 | var users []model.User 87 | 88 | query := d.gormdb.Where("is_admin = ?", true).Find(&users) 89 | 90 | return query.RowsAffected, query.Error 91 | } 92 | -------------------------------------------------------------------------------- /internal/model/alertmanager.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "strings" 4 | 5 | // AlertmanagerWebhook is used to pass notifications over webhook pushes. 6 | type AlertmanagerWebhook struct { 7 | Version string `json:"version"` 8 | GroupKey string `json:"groupKey"` 9 | Receiver string `json:"receiver"` 10 | GroupLabels map[string]string `json:"groupLabels"` 11 | CommonLabels map[string]string `json:"commonLabels"` 12 | CommonAnnotations map[string]string `json:"commonAnnotations"` 13 | ExternalURL string `json:"externalURL"` 14 | Alerts []AlertmanagerAlert `json:"alerts"` 15 | } 16 | 17 | // AlertmanagerAlert holds information related to a single alert in a notification. 18 | type AlertmanagerAlert struct { 19 | Labels map[string]string `json:"labels"` 20 | Annotations map[string]string `json:"annotations"` 21 | StartsAt string `json:"startsAt"` 22 | EndsAt string `json:"endsAt"` 23 | Status string `json:"status"` 24 | } 25 | 26 | // ToNotification converts an Alertmanager alert into a Notification 27 | func (alert *AlertmanagerAlert) ToNotification(titleAnnotation, messageAnnotation string) Notification { 28 | title := strings.Builder{} 29 | message := strings.Builder{} 30 | 31 | switch alert.Status { 32 | case "firing": 33 | title.WriteString("[FIR] ") 34 | case "resolved": 35 | title.WriteString("[RES] ") 36 | } 37 | message.WriteString("STATUS: ") 38 | message.WriteString(alert.Status) 39 | message.WriteString("\n\n") 40 | 41 | if titleString, ok := alert.Annotations[titleAnnotation]; ok { 42 | title.WriteString(titleString) 43 | } else if titleString, ok := alert.Labels[titleAnnotation]; ok { 44 | title.WriteString(titleString) 45 | } else { 46 | title.WriteString("Unknown Title") 47 | } 48 | 49 | if messageString, ok := alert.Annotations[messageAnnotation]; ok { 50 | message.WriteString(messageString) 51 | } else if messageString, ok := alert.Labels[messageAnnotation]; ok { 52 | message.WriteString(messageString) 53 | } else { 54 | message.WriteString("Unknown Message") 55 | } 56 | 57 | message.WriteString("\n") 58 | 59 | for labelName, labelValue := range alert.Labels { 60 | message.WriteString("\n") 61 | message.WriteString(labelName) 62 | message.WriteString(": ") 63 | message.WriteString(labelValue) 64 | } 65 | 66 | return Notification{ 67 | Message: message.String(), 68 | Title: title.String(), 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/pushbits/server/internal/authentication/credentials" 5 | "github.com/pushbits/server/internal/log" 6 | ) 7 | 8 | // User holds information like the name, the secret, and the applications of a user. 9 | type User struct { 10 | ID uint `gorm:"AUTO_INCREMENT;primary_key"` 11 | Name string `gorm:"type:string;size:128;unique"` 12 | PasswordHash []byte 13 | IsAdmin bool 14 | MatrixID string `gorm:"type:string"` 15 | Applications []Application 16 | } 17 | 18 | // ExternalUser represents a user for external purposes. 19 | type ExternalUser struct { 20 | ID uint `json:"id"` 21 | Name string `json:"name" form:"name" query:"name" binding:"required"` 22 | IsAdmin bool `json:"is_admin" form:"is_admin" query:"is_admin"` 23 | MatrixID string `json:"matrix_id" form:"matrix_id" query:"matrix_id" binding:"required"` 24 | } 25 | 26 | // UserCredentials holds information for authenticating a user. 27 | type UserCredentials struct { 28 | Password string `json:"password,omitempty" form:"password" query:"password" binding:"required"` 29 | } 30 | 31 | // CreateUser is used to process queries for creating users. 32 | type CreateUser struct { 33 | ExternalUser 34 | UserCredentials 35 | } 36 | 37 | // NewUser creates a new user. 38 | func NewUser(cm *credentials.Manager, name, password string, isAdmin bool, matrixID string) (*User, error) { 39 | log.L.Printf("Creating user %s.", name) 40 | 41 | passwordHash, err := cm.CreatePasswordHash(password) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &User{ 47 | Name: name, 48 | PasswordHash: passwordHash, 49 | IsAdmin: isAdmin, 50 | MatrixID: matrixID, 51 | }, nil 52 | } 53 | 54 | // IntoInternalUser converts a CreateUser into a User. 55 | func (u *CreateUser) IntoInternalUser(cm *credentials.Manager) (*User, error) { 56 | passwordHash, err := cm.CreatePasswordHash(u.Password) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &User{ 62 | Name: u.Name, 63 | PasswordHash: passwordHash, 64 | IsAdmin: u.IsAdmin, 65 | MatrixID: u.MatrixID, 66 | }, nil 67 | } 68 | 69 | // IntoExternalUser converts a User into a ExternalUser. 70 | func (u *User) IntoExternalUser() *ExternalUser { 71 | return &ExternalUser{ 72 | ID: u.ID, 73 | Name: u.Name, 74 | IsAdmin: u.IsAdmin, 75 | MatrixID: u.MatrixID, 76 | } 77 | } 78 | 79 | // UpdateUser is used to process queries for updating users. 80 | type UpdateUser struct { 81 | Name *string `form:"name" query:"name" json:"name"` 82 | Password *string `form:"password" query:"password" json:"password"` 83 | IsAdmin *bool `form:"is_admin" query:"is_admin" json:"is_admin"` 84 | MatrixID *string `form:"matrix_id" query:"matrix_id" json:"matrix_id"` 85 | } 86 | -------------------------------------------------------------------------------- /internal/api/util_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/pushbits/server/tests" 12 | ) 13 | 14 | func TestApi_SuccessOrAbort(t *testing.T) { 15 | GetTestContext(t) 16 | 17 | assert := assert.New(t) 18 | require := require.New(t) 19 | 20 | testCases := make(map[error]tests.Request) 21 | testCases[errors.New("")] = tests.Request{Name: "Empty Error - 500", Endpoint: "/", ShouldStatus: 500} 22 | testCases[errors.New("this is an error")] = tests.Request{Name: "Error - 500", Endpoint: "/", ShouldStatus: 500} 23 | testCases[errors.New("this is an error")] = tests.Request{Name: "Error - 200", Endpoint: "/", ShouldStatus: 200} 24 | testCases[errors.New("this is an error")] = tests.Request{Name: "Error - 404", Endpoint: "/", ShouldStatus: 404} 25 | testCases[nil] = tests.Request{Name: "No Error - 200", Endpoint: "/", ShouldStatus: 200} 26 | testCases[nil] = tests.Request{Name: "No Error - 404", Endpoint: "/", ShouldStatus: 404} 27 | 28 | for forcedErr, testCase := range testCases { 29 | w, c, err := testCase.GetRequest() 30 | require.NoErrorf(err, "(Test case %s) Could not make request", testCase.Name) 31 | 32 | aborted := SuccessOrAbort(c, testCase.ShouldStatus, forcedErr) 33 | 34 | if forcedErr != nil { 35 | assert.Equalf(testCase.ShouldStatus, w.Code, "(Test case %s) Expected status code %d but have %d", testCase.Name, testCase.ShouldStatus, w.Code) 36 | } 37 | 38 | assert.Equalf(forcedErr == nil, aborted, "(Test case %s) Expected %v but have %v", testCase.Name, forcedErr == nil, aborted) 39 | } 40 | } 41 | 42 | func TestApi_IsCurrentUser(t *testing.T) { 43 | ctx := GetTestContext(t) 44 | 45 | assert := assert.New(t) 46 | require := require.New(t) 47 | 48 | for _, user := range ctx.Users { 49 | testCases := make(map[uint]tests.Request) 50 | 51 | testCases[user.ID] = tests.Request{Name: fmt.Sprintf("User %s - success", user.Name), Endpoint: "/", ShouldStatus: 200} 52 | testCases[uint(49786749859)] = tests.Request{Name: fmt.Sprintf("User %s - fail", user.Name), Endpoint: "/", ShouldStatus: 403} 53 | 54 | for id, testCase := range testCases { 55 | w, c, err := testCase.GetRequest() 56 | require.NoErrorf(err, "(Test case %s) Could not make request", testCase.Name) 57 | 58 | c.Set("user", user) 59 | isCurrentUser := isCurrentUser(c, id) 60 | 61 | if testCase.ShouldStatus == 200 { 62 | assert.Truef(isCurrentUser, "(Test case %s) Should be true but is false", testCase.Name) 63 | } else { 64 | assert.Falsef(isCurrentUser, "(Test case %s) Should be false but is true", testCase.Name) 65 | assert.Equalf(w.Code, testCase.ShouldStatus, "(Test case %s) Expected status code %d but have %d", testCase.Name, testCase.ShouldStatus, w.Code) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | # A sample configuration for PushBits. 2 | 3 | # Populated fields contain their default value. 4 | 5 | # Required fields are marked with [required]. 6 | 7 | debug: false 8 | 9 | http: 10 | # The address to listen on. If empty, listens on all available IP addresses of the system. 11 | listenaddress: '' 12 | 13 | # The port to listen on. 14 | port: 8080 15 | 16 | # What proxies to trust. 17 | trustedproxies: [] 18 | 19 | # Filename of the TLS certificate. 20 | certfile: '' 21 | 22 | # Filename of the TLS private key. 23 | keyfile: '' 24 | 25 | database: 26 | # Currently sqlite3, mysql, and postgres are supported. 27 | dialect: 'sqlite3' 28 | 29 | # - For sqlite3, specify the database file. 30 | # - For mysql specify the connection string. See details at https://github.com/go-sql-driver/mysql#dsn-data-source-name 31 | # - For postgres, see https://github.com/jackc/pgx. 32 | # Also consider the canonical docs at https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING. 33 | connection: 'pushbits.db' 34 | 35 | admin: 36 | # The username of the initial admin. 37 | name: 'admin' 38 | 39 | # The password of the initial admin. 40 | password: 'admin' 41 | 42 | # The Matrix ID of the initial admin, where notifications for that admin are sent to. 43 | # [required] 44 | matrixid: '' 45 | 46 | matrix: 47 | # The Matrix server to use for sending notifications. 48 | homeserver: 'https://matrix.org' 49 | 50 | # The username of the Matrix account to send notifications from. 51 | # [required] 52 | username: '' 53 | 54 | # The password of the Matrix account to send notifications from. 55 | # [required] 56 | password: '' 57 | 58 | security: 59 | # Wether or not to check for weak passwords using HIBP. 60 | checkhibp: false 61 | 62 | crypto: 63 | # Configuration of the KDF for password storage. Do not change unless you know what you are doing! 64 | argon2: 65 | memory: 131072 66 | iterations: 4 67 | parallelism: 4 68 | saltlength: 16 69 | keylength: 32 70 | 71 | formatting: 72 | # Whether to use colored titles based on the message priority (<0: grey, 0-3: default, 4-10: yellow, 10-20: orange, >20: red). 73 | coloredtitle: false 74 | 75 | # This settings are only relevant if you want to use PushBits with alertmanager 76 | alertmanager: 77 | # The name of the entry in the alerts annotations or lables that should be used for the title 78 | annotationtitle: title 79 | # The name of the entry in the alerts annotations or labels that should be used for the message 80 | annotationmessage: message 81 | 82 | repairbehavior: 83 | # Reset the room's name to what was initially set by PushBits. 84 | resetroomname: true 85 | # Reset the room's topic to what was initially set by PushBits. 86 | resetroomtopic: true 87 | -------------------------------------------------------------------------------- /internal/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/pushbits/server/internal/authentication/credentials" 11 | "github.com/pushbits/server/internal/configuration" 12 | "github.com/pushbits/server/internal/database" 13 | "github.com/pushbits/server/internal/log" 14 | "github.com/pushbits/server/internal/model" 15 | "github.com/pushbits/server/tests/mockups" 16 | ) 17 | 18 | // TestContext holds all test-related objects 19 | type TestContext struct { 20 | ApplicationHandler *ApplicationHandler 21 | Users []*model.User 22 | Database *database.Database 23 | NotificationHandler *NotificationHandler 24 | UserHandler *UserHandler 25 | Config *configuration.Configuration 26 | } 27 | 28 | var GlobalTestContext *TestContext 29 | 30 | func cleanup() { 31 | err := os.Remove("pushbits-test.db") 32 | if err != nil { 33 | log.L.Warnln("Cannot delete test database: ", err) 34 | } 35 | } 36 | 37 | func TestMain(m *testing.M) { 38 | cleanup() 39 | 40 | gin.SetMode(gin.TestMode) 41 | 42 | GlobalTestContext = CreateTestContext(nil) 43 | 44 | m.Run() 45 | 46 | cleanup() 47 | } 48 | 49 | // GetTestContext initializes and verifies all required test components 50 | func GetTestContext(_ *testing.T) *TestContext { 51 | if GlobalTestContext == nil { 52 | GlobalTestContext = CreateTestContext(nil) 53 | } 54 | 55 | return GlobalTestContext 56 | } 57 | 58 | // CreateTestContext initializes and verifies all required test components 59 | func CreateTestContext(_ *testing.T) *TestContext { 60 | ctx := &TestContext{} 61 | 62 | config := configuration.Configuration{} 63 | config.Database.Connection = "pushbits-test.db" 64 | config.Database.Dialect = "sqlite3" 65 | config.Crypto.Argon2.Iterations = 4 66 | config.Crypto.Argon2.Parallelism = 4 67 | config.Crypto.Argon2.Memory = 131072 68 | config.Crypto.Argon2.SaltLength = 16 69 | config.Crypto.Argon2.KeyLength = 32 70 | config.Admin.Name = "user" 71 | config.Admin.Password = "pushbits" 72 | ctx.Config = &config 73 | 74 | db, err := mockups.GetEmptyDatabase(ctx.Config.Crypto) 75 | if err != nil { 76 | cleanup() 77 | panic(fmt.Errorf("cannot set up database: %w", err)) 78 | } 79 | ctx.Database = db 80 | 81 | ctx.ApplicationHandler = &ApplicationHandler{ 82 | DB: ctx.Database, 83 | DP: &mockups.MockDispatcher{}, 84 | } 85 | 86 | ctx.Users = mockups.GetUsers(ctx.Config) 87 | 88 | ctx.NotificationHandler = &NotificationHandler{ 89 | DB: ctx.Database, 90 | DP: &mockups.MockDispatcher{}, 91 | } 92 | 93 | ctx.UserHandler = &UserHandler{ 94 | AH: ctx.ApplicationHandler, 95 | CM: credentials.CreateManager(false, ctx.Config.Crypto), 96 | DB: ctx.Database, 97 | DP: &mockups.MockDispatcher{}, 98 | } 99 | 100 | return ctx 101 | } 102 | -------------------------------------------------------------------------------- /cmd/pushbits/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the main function as a starting point of this tool. 2 | package main 3 | 4 | import ( 5 | "os" 6 | "os/signal" 7 | "runtime/debug" 8 | "syscall" 9 | 10 | "github.com/pushbits/server/internal/authentication/credentials" 11 | "github.com/pushbits/server/internal/configuration" 12 | "github.com/pushbits/server/internal/database" 13 | "github.com/pushbits/server/internal/dispatcher" 14 | "github.com/pushbits/server/internal/log" 15 | "github.com/pushbits/server/internal/router" 16 | "github.com/pushbits/server/internal/runner" 17 | ) 18 | 19 | func setupCleanup(db *database.Database, dp *dispatcher.Dispatcher) { 20 | c := make(chan os.Signal, 2) 21 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 22 | 23 | go func() { 24 | <-c 25 | dp.Close() 26 | db.Close() 27 | os.Exit(1) 28 | }() 29 | } 30 | 31 | func printStarupMessage() { 32 | buildInfo, ok := debug.ReadBuildInfo() 33 | if !ok { 34 | log.L.Fatalln("Build info not available") 35 | return 36 | } 37 | 38 | log.L.Printf("Starting PushBits %s", buildInfo.Main.Version) 39 | } 40 | 41 | // @title PushBits Server API Documentation 42 | // @version 0.10.5 43 | // @description Documentation for the PushBits server API. 44 | 45 | // @contact.name The PushBits Developers 46 | // @contact.url https://www.pushbits.io 47 | 48 | // @license.name ISC 49 | // @license.url https://github.com/pushbits/server/blob/master/LICENSE 50 | 51 | // @BasePath / 52 | // @query.collection.format multi 53 | // @schemes http https 54 | 55 | // @securityDefinitions.basic BasicAuth 56 | func main() { 57 | printStarupMessage() 58 | 59 | c := configuration.Get() 60 | 61 | if c.Debug { 62 | log.SetDebug() 63 | log.L.Printf("%+v", c) 64 | } 65 | 66 | cm := credentials.CreateManager(c.Security.CheckHIBP, c.Crypto) 67 | 68 | db, err := database.Create(cm, c.Database.Dialect, c.Database.Connection) 69 | if err != nil { 70 | log.L.Fatal(err) 71 | return 72 | } 73 | if db == nil { 74 | log.L.Fatal("db is nil but error was nil") 75 | return 76 | } 77 | defer db.Close() 78 | 79 | if err := db.Populate(c.Admin.Name, c.Admin.Password, c.Admin.MatrixID); err != nil { 80 | log.L.Fatal(err) 81 | } 82 | 83 | dp, err := dispatcher.Create(c.Matrix.Homeserver, c.Matrix.Username, c.Matrix.Password, c.Formatting) 84 | if err != nil { 85 | log.L.Fatal(err) 86 | return 87 | } 88 | if dp == nil { 89 | log.L.Fatal("dp is nil but error was nil") 90 | return 91 | } 92 | defer dp.Close() 93 | 94 | setupCleanup(db, dp) 95 | 96 | err = db.RepairChannels(dp, &c.RepairBehavior) 97 | if err != nil { 98 | log.L.Fatal(err) 99 | return 100 | } 101 | 102 | engine, err := router.Create(c.Debug, c.HTTP.TrustedProxies, cm, db, dp, &c.Alertmanager) 103 | if err != nil { 104 | log.L.Fatal(err) 105 | return 106 | } 107 | 108 | err = runner.Run(engine, c) 109 | if err != nil { 110 | log.L.Fatal(err) 111 | return 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | env: 9 | GO_VERSION: '1.24.3' 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 # Needed to describe git ref during build. 21 | 22 | - name: Export GOBIN 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: '${{env.GO_VERSION}}' 26 | 27 | - name: Install dependencies 28 | run: make setup 29 | 30 | - name: Run tests 31 | run: make test 32 | 33 | publish_docker_image: 34 | name: Publish Docker image 35 | needs: test 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 20 38 | permissions: 39 | packages: write 40 | env: 41 | REGISTRY: ghcr.io 42 | IMAGE_NAME: ${{ github.repository }} 43 | steps: 44 | - name: Set up QEMU 45 | uses: docker/setup-qemu-action@v1 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v1 49 | 50 | - name: Log in to the Container registry 51 | uses: docker/login-action@v1 52 | with: 53 | registry: ${{ env.REGISTRY }} 54 | username: ${{ github.actor }} 55 | password: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: Extract metadata (tags, labels) for Docker 58 | id: meta 59 | uses: docker/metadata-action@v3 60 | with: 61 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 62 | tags: | 63 | type=semver,pattern={{major}} 64 | type=semver,pattern={{major}}.{{minor}} 65 | type=semver,pattern={{version}} 66 | 67 | - name: Build and push Docker image 68 | uses: docker/build-push-action@v2 69 | with: 70 | push: true 71 | build-args: PB_BUILD_VERSION=${{ github.ref_name }} 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels }} 74 | platforms: linux/amd64,linux/arm64 75 | cache-from: type=gha 76 | cache-to: type=gha,mode=max 77 | 78 | publish_github_release: 79 | name: Publish GitHub Release 80 | needs: test 81 | runs-on: ubuntu-latest 82 | timeout-minutes: 10 83 | permissions: 84 | contents: write 85 | steps: 86 | - name: Checkout code 87 | uses: actions/checkout@v3 88 | 89 | - name: Export GOBIN 90 | uses: actions/setup-go@v4 91 | with: 92 | go-version: '${{env.GO_VERSION}}' 93 | 94 | - name: Run GoReleaser 95 | uses: goreleaser/goreleaser-action@v4 96 | with: 97 | distribution: goreleaser 98 | version: latest 99 | args: release --clean 100 | env: 101 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 102 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pushbits/server 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/alexedwards/argon2id v1.0.0 7 | github.com/gin-contrib/location v1.0.3 8 | github.com/gin-gonic/gin v1.10.1 9 | github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e 10 | github.com/jinzhu/configor v1.2.2 11 | github.com/sirupsen/logrus v1.9.3 12 | github.com/stretchr/testify v1.10.0 13 | gopkg.in/yaml.v2 v2.4.0 14 | gorm.io/driver/mysql v1.5.7 15 | gorm.io/driver/postgres v1.6.0 16 | gorm.io/driver/sqlite v1.5.7 17 | gorm.io/gorm v1.25.12 18 | maunium.net/go/mautrix v0.24.0 19 | ) 20 | 21 | require ( 22 | filippo.io/edwards25519 v1.1.0 // indirect 23 | github.com/BurntSushi/toml v1.4.0 // indirect 24 | github.com/bytedance/sonic v1.13.2 // indirect 25 | github.com/bytedance/sonic/loader v0.2.4 // indirect 26 | github.com/cloudwego/base64x v0.1.5 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 29 | github.com/gin-contrib/sse v1.0.0 // indirect 30 | github.com/go-playground/locales v0.14.1 // indirect 31 | github.com/go-playground/universal-translator v0.18.1 // indirect 32 | github.com/go-playground/validator/v10 v10.26.0 // indirect 33 | github.com/go-sql-driver/mysql v1.8.1 // indirect 34 | github.com/goccy/go-json v0.10.5 // indirect 35 | github.com/jackc/pgpassfile v1.0.0 // indirect 36 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 37 | github.com/jackc/pgx/v5 v5.7.2 // indirect 38 | github.com/jackc/puddle/v2 v2.2.2 // indirect 39 | github.com/jinzhu/inflection v1.0.0 // indirect 40 | github.com/jinzhu/now v1.1.5 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 43 | github.com/kr/pretty v0.3.1 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/mattn/go-colorable v0.1.14 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 51 | github.com/pmezard/go-difflib v1.0.0 // indirect 52 | github.com/rogpeppe/go-internal v1.10.0 // indirect 53 | github.com/rs/zerolog v1.34.0 // indirect 54 | github.com/tidwall/gjson v1.18.0 // indirect 55 | github.com/tidwall/match v1.1.1 // indirect 56 | github.com/tidwall/pretty v1.2.1 // indirect 57 | github.com/tidwall/sjson v1.2.5 // indirect 58 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 59 | github.com/ugorji/go/codec v1.2.12 // indirect 60 | go.mau.fi/util v0.8.7 // indirect 61 | golang.org/x/arch v0.15.0 // indirect 62 | golang.org/x/crypto v0.38.0 // indirect 63 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 64 | golang.org/x/net v0.40.0 // indirect 65 | golang.org/x/sync v0.14.0 // indirect 66 | golang.org/x/sys v0.33.0 // indirect 67 | golang.org/x/text v0.25.0 // indirect 68 | google.golang.org/protobuf v1.36.6 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /internal/router/router.go: -------------------------------------------------------------------------------- 1 | // Package router provides functions to configure the web server. 2 | package router 3 | 4 | import ( 5 | "github.com/gin-contrib/location" 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/pushbits/server/internal/api" 9 | "github.com/pushbits/server/internal/api/alertmanager" 10 | "github.com/pushbits/server/internal/authentication" 11 | "github.com/pushbits/server/internal/authentication/credentials" 12 | "github.com/pushbits/server/internal/configuration" 13 | "github.com/pushbits/server/internal/database" 14 | "github.com/pushbits/server/internal/dispatcher" 15 | "github.com/pushbits/server/internal/log" 16 | ) 17 | 18 | // Create a Gin engine and setup all routes. 19 | func Create(debug bool, trustedProxies []string, cm *credentials.Manager, db *database.Database, dp *dispatcher.Dispatcher, alertmanagerConfig *configuration.Alertmanager) (*gin.Engine, error) { 20 | log.L.Println("Setting up HTTP routes.") 21 | 22 | if !debug { 23 | gin.SetMode(gin.ReleaseMode) 24 | } 25 | 26 | auth := authentication.Authenticator{DB: db} 27 | 28 | applicationHandler := api.ApplicationHandler{DB: db, DP: dp} 29 | healthHandler := api.HealthHandler{DB: db} 30 | notificationHandler := api.NotificationHandler{DB: db, DP: dp} 31 | userHandler := api.UserHandler{AH: &applicationHandler, CM: cm, DB: db, DP: dp} 32 | alertmanagerHandler := alertmanager.Handler{DP: dp, Settings: alertmanager.HandlerSettings{ 33 | TitleAnnotation: alertmanagerConfig.AnnotationTitle, 34 | MessageAnnotation: alertmanagerConfig.AnnotationMessage, 35 | }} 36 | 37 | r := gin.New() 38 | r.Use(log.GinLogger(log.L), gin.Recovery()) 39 | 40 | var err error 41 | if len(trustedProxies) > 0 { 42 | err = r.SetTrustedProxies(trustedProxies) 43 | } else { 44 | err = r.SetTrustedProxies(nil) 45 | } 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | r.Use(location.Default()) 51 | 52 | applicationGroup := r.Group("/application") 53 | applicationGroup.Use(auth.RequireUser()) 54 | { 55 | applicationGroup.POST("", applicationHandler.CreateApplication) 56 | applicationGroup.GET("", applicationHandler.GetApplications) 57 | 58 | applicationGroup.GET("/:id", api.RequireIDInURI(), applicationHandler.GetApplication) 59 | applicationGroup.DELETE("/:id", api.RequireIDInURI(), applicationHandler.DeleteApplication) 60 | applicationGroup.PUT("/:id", api.RequireIDInURI(), applicationHandler.UpdateApplication) 61 | } 62 | 63 | r.GET("/health", healthHandler.Health) 64 | 65 | r.POST("/message", auth.RequireApplicationToken(), notificationHandler.CreateNotification) 66 | r.DELETE("/message/:messageid", api.RequireMessageIDInURI(), auth.RequireApplicationToken(), notificationHandler.DeleteNotification) 67 | 68 | userGroup := r.Group("/user") 69 | userGroup.Use(auth.RequireAdmin()) 70 | { 71 | userGroup.POST("", userHandler.CreateUser) 72 | userGroup.GET("", userHandler.GetUsers) 73 | 74 | userGroup.GET("/:id", api.RequireIDInURI(), userHandler.GetUser) 75 | userGroup.DELETE("/:id", api.RequireIDInURI(), userHandler.DeleteUser) 76 | userGroup.PUT("/:id", api.RequireIDInURI(), userHandler.UpdateUser) 77 | } 78 | 79 | r.POST("/alert", auth.RequireApplicationToken(), alertmanagerHandler.CreateAlert) 80 | 81 | return r, nil 82 | } 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | *.db 3 | config.yml 4 | docs/ 5 | 6 | ### Go 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | ### Python 24 | # Byte-compiled / optimized / DLL files 25 | __pycache__/ 26 | *.py[cod] 27 | *$py.class 28 | 29 | # C extensions 30 | *.so 31 | 32 | # Distribution / packaging 33 | .Python 34 | build/ 35 | develop-eggs/ 36 | dist/ 37 | downloads/ 38 | eggs/ 39 | .eggs/ 40 | lib/ 41 | lib64/ 42 | parts/ 43 | sdist/ 44 | var/ 45 | wheels/ 46 | share/python-wheels/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | MANIFEST 51 | 52 | # PyInstaller 53 | # Usually these files are written by a python script from a template 54 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 55 | *.manifest 56 | *.spec 57 | 58 | # Installer logs 59 | pip-log.txt 60 | pip-delete-this-directory.txt 61 | 62 | # Unit test / coverage reports 63 | htmlcov/ 64 | .tox/ 65 | .nox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *.cover 72 | *.py,cover 73 | .hypothesis/ 74 | .pytest_cache/ 75 | cover/ 76 | 77 | # Translations 78 | *.mo 79 | *.pot 80 | 81 | # Django stuff: 82 | *.log 83 | local_settings.py 84 | db.sqlite3 85 | db.sqlite3-journal 86 | 87 | # Flask stuff: 88 | instance/ 89 | .webassets-cache 90 | 91 | # Scrapy stuff: 92 | .scrapy 93 | 94 | # Sphinx documentation 95 | docs/_build/ 96 | 97 | # PyBuilder 98 | .pybuilder/ 99 | target/ 100 | 101 | # Jupyter Notebook 102 | .ipynb_checkpoints 103 | 104 | # IPython 105 | profile_default/ 106 | ipython_config.py 107 | 108 | # pyenv 109 | # For a library or package, you might want to ignore these files since the code is 110 | # intended to run in multiple environments; otherwise, check them in: 111 | # .python-version 112 | 113 | # pipenv 114 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 115 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 116 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 117 | # install all needed dependencies. 118 | #Pipfile.lock 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | ### VisualStudioCode 164 | .vscode/* 165 | !.vscode/settings.json 166 | !.vscode/tasks.json 167 | !.vscode/launch.json 168 | !.vscode/extensions.json 169 | *.code-workspace 170 | 171 | # Local History for Visual Studio Code 172 | .history/ 173 | 174 | -------------------------------------------------------------------------------- /internal/authentication/authentication.go: -------------------------------------------------------------------------------- 1 | // Package authentication provides definitions and functionality related to user authentication. 2 | package authentication 3 | 4 | import ( 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/pushbits/server/internal/authentication/credentials" 9 | "github.com/pushbits/server/internal/model" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | const ( 15 | headerName = "X-Gotify-Key" 16 | ) 17 | 18 | // The Database interface for encapsulating database access. 19 | type Database interface { 20 | GetApplicationByToken(token string) (*model.Application, error) 21 | GetUserByName(name string) (*model.User, error) 22 | } 23 | 24 | // Authenticator is the provider for authentication middleware. 25 | type Authenticator struct { 26 | DB Database 27 | } 28 | 29 | type hasUserProperty func(user *model.User) bool 30 | 31 | func (a *Authenticator) userFromBasicAuth(ctx *gin.Context) (*model.User, error) { 32 | if name, password, ok := ctx.Request.BasicAuth(); ok { 33 | if user, err := a.DB.GetUserByName(name); err != nil { 34 | return nil, err 35 | } else if user != nil && credentials.ComparePassword(user.PasswordHash, []byte(password)) { 36 | return user, nil 37 | } 38 | 39 | return nil, errors.New("credentials were invalid") 40 | } 41 | 42 | return nil, errors.New("no credentials were supplied") 43 | } 44 | 45 | func (a *Authenticator) requireUserProperty(has hasUserProperty) gin.HandlerFunc { 46 | return func(ctx *gin.Context) { 47 | user, err := a.userFromBasicAuth(ctx) 48 | if err != nil { 49 | ctx.AbortWithError(http.StatusForbidden, err) 50 | return 51 | } 52 | 53 | if !has(user) { 54 | ctx.AbortWithError(http.StatusForbidden, errors.New("authentication failed")) 55 | return 56 | } 57 | 58 | ctx.Set("user", user) 59 | } 60 | } 61 | 62 | // RequireUser returns a Gin middleware which requires valid user credentials to be supplied with the request. 63 | func (a *Authenticator) RequireUser() gin.HandlerFunc { 64 | return a.requireUserProperty(func(_ *model.User) bool { 65 | return true 66 | }) 67 | } 68 | 69 | // RequireAdmin returns a Gin middleware which requires valid admin credentials to be supplied with the request. 70 | func (a *Authenticator) RequireAdmin() gin.HandlerFunc { 71 | return a.requireUserProperty(func(user *model.User) bool { 72 | return user.IsAdmin 73 | }) 74 | } 75 | 76 | func (a *Authenticator) tokenFromQueryOrHeader(ctx *gin.Context) string { 77 | if token := a.tokenFromQuery(ctx); token != "" { 78 | return token 79 | } else if token := a.tokenFromHeader(ctx); token != "" { 80 | return token 81 | } 82 | 83 | return "" 84 | } 85 | 86 | func (a *Authenticator) tokenFromQuery(ctx *gin.Context) string { 87 | return ctx.Request.URL.Query().Get("token") 88 | } 89 | 90 | func (a *Authenticator) tokenFromHeader(ctx *gin.Context) string { 91 | return ctx.Request.Header.Get(headerName) 92 | } 93 | 94 | // RequireApplicationToken returns a Gin middleware which requires an application token to be supplied with the request. 95 | func (a *Authenticator) RequireApplicationToken() gin.HandlerFunc { 96 | return func(ctx *gin.Context) { 97 | token := a.tokenFromQueryOrHeader(ctx) 98 | 99 | app, err := a.DB.GetApplicationByToken(token) 100 | if err != nil { 101 | ctx.AbortWithError(http.StatusForbidden, err) 102 | return 103 | } 104 | 105 | ctx.Set("app", app) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/api/notification.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/pushbits/server/internal/authentication" 9 | "github.com/pushbits/server/internal/log" 10 | "github.com/pushbits/server/internal/model" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // The NotificationDatabase interface for encapsulating database access. 16 | type NotificationDatabase interface{} 17 | 18 | // The NotificationDispatcher interface for relaying notifications. 19 | type NotificationDispatcher interface { 20 | SendNotification(a *model.Application, n *model.Notification) (id string, err error) 21 | DeleteNotification(a *model.Application, n *model.DeleteNotification) error 22 | } 23 | 24 | // NotificationHandler holds information for processing requests about notifications. 25 | type NotificationHandler struct { 26 | DB NotificationDatabase 27 | DP NotificationDispatcher 28 | } 29 | 30 | // CreateNotification godoc 31 | // @Summary Create a Notification 32 | // @Description Creates a new notification for the given channel 33 | // @ID post-message 34 | // @Tags Application 35 | // @Accept json,mpfd 36 | // @Produce json 37 | // @Param message query string true "The message to send" 38 | // @Param title query string false "The title to send" 39 | // @Param priority query integer false "The notifications priority" 40 | // @Param extras query model.NotificationExtras false "JSON object with additional information" 41 | // @Param token query string true "Channels token, can also be provieded in the header" 42 | // @Success 200 {object} model.Notification 43 | // @Failure 500,404,403 "" 44 | // @Router /message [post] 45 | func (h *NotificationHandler) CreateNotification(ctx *gin.Context) { 46 | application := authentication.GetApplication(ctx) 47 | if application == nil { 48 | return 49 | } 50 | 51 | log.L.Printf("Sending notification for application %s.", application.Name) 52 | 53 | var notification model.Notification 54 | if err := ctx.Bind(¬ification); err != nil { 55 | return 56 | } 57 | 58 | notification.Sanitize(application) 59 | 60 | messageID, err := h.DP.SendNotification(application, ¬ification) 61 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 62 | return 63 | } 64 | 65 | notification.ID = messageID 66 | notification.URLEncodedID = url.QueryEscape(messageID) 67 | 68 | ctx.JSON(http.StatusOK, ¬ification) 69 | } 70 | 71 | // DeleteNotification godoc 72 | // @Summary Delete a Notification 73 | // @Description Informs the channel that the notification is deleted 74 | // @ID deöete-message-id 75 | // @Tags Application 76 | // @Accept json,mpfd 77 | // @Produce json 78 | // @Param message_id path string true "ID of the message to delete" 79 | // @Param token query string true "Channels token, can also be provieded in the header" 80 | // @Success 200 "" 81 | // @Failure 500,404,403 "" 82 | // @Router /message/{message_id} [DELETE] 83 | func (h *NotificationHandler) DeleteNotification(ctx *gin.Context) { 84 | application := authentication.GetApplication(ctx) 85 | if application == nil { 86 | return 87 | } 88 | 89 | log.L.Printf("Deleting notification for application %s.", application.Name) 90 | 91 | id, err := getMessageID(ctx) 92 | if success := SuccessOrAbort(ctx, http.StatusUnprocessableEntity, err); !success { 93 | return 94 | } 95 | 96 | n := model.DeleteNotification{ 97 | ID: id, 98 | Date: time.Now(), 99 | } 100 | 101 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, h.DP.DeleteNotification(application, &n)); !success { 102 | return 103 | } 104 | 105 | ctx.Status(http.StatusOK) 106 | } 107 | -------------------------------------------------------------------------------- /internal/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | // Package configuration provides definitions and functionality related to the configuration. 2 | package configuration 3 | 4 | import ( 5 | "github.com/jinzhu/configor" 6 | 7 | "github.com/pushbits/server/internal/log" 8 | "github.com/pushbits/server/internal/pberrors" 9 | ) 10 | 11 | // testMode indicates if the package is run in test mode 12 | var testMode bool 13 | 14 | // Argon2Config holds the parameters used for creating hashes with Argon2. 15 | type Argon2Config struct { 16 | Memory uint32 `default:"131072"` 17 | Iterations uint32 `default:"4"` 18 | Parallelism uint8 `default:"4"` 19 | SaltLength uint32 `default:"16"` 20 | KeyLength uint32 `default:"32"` 21 | } 22 | 23 | // CryptoConfig holds the parameters used for creating hashes. 24 | type CryptoConfig struct { 25 | Argon2 Argon2Config 26 | } 27 | 28 | // Formatting holds additional parameters used for formatting messages 29 | type Formatting struct { 30 | ColoredTitle bool `default:"false"` 31 | } 32 | 33 | // Matrix holds credentials for a matrix account 34 | type Matrix struct { 35 | Homeserver string `default:"https://matrix.org"` 36 | Username string `required:"true"` 37 | Password string `required:"true"` 38 | } 39 | 40 | // Alertmanager holds information on how to parse alertmanager calls 41 | type Alertmanager struct { 42 | AnnotationTitle string `default:"title"` 43 | AnnotationMessage string `default:"message"` 44 | } 45 | 46 | // RepairBehavior holds information on how repair applications. 47 | type RepairBehavior struct { 48 | ResetRoomName bool `default:"true"` 49 | ResetRoomTopic bool `default:"true"` 50 | } 51 | 52 | // Configuration holds values that can be configured by the user. 53 | type Configuration struct { 54 | Debug bool `default:"false"` 55 | HTTP struct { 56 | ListenAddress string `default:""` 57 | Port int `default:"8080"` 58 | TrustedProxies []string `default:"[]"` 59 | CertFile string `default:""` 60 | KeyFile string `default:""` 61 | } 62 | Database struct { 63 | Dialect string `default:"sqlite3"` 64 | Connection string `default:"pushbits.db"` 65 | } 66 | Admin struct { 67 | Name string `default:"admin"` 68 | Password string `default:"admin"` 69 | MatrixID string `required:"true"` 70 | } 71 | Matrix Matrix 72 | Security struct { 73 | CheckHIBP bool `default:"false"` 74 | } 75 | Crypto CryptoConfig 76 | Formatting Formatting 77 | Alertmanager Alertmanager 78 | RepairBehavior RepairBehavior 79 | } 80 | 81 | func configFiles() []string { 82 | if testMode { 83 | return []string{"config_unittest.yml"} 84 | } 85 | return []string{"config.yml"} 86 | } 87 | 88 | func validateHTTPConfiguration(c *Configuration) error { 89 | certAndKeyEmpty := (c.HTTP.CertFile == "" && c.HTTP.KeyFile == "") 90 | certAndKeyPopulated := (c.HTTP.CertFile != "" && c.HTTP.KeyFile != "") 91 | 92 | if !certAndKeyEmpty && !certAndKeyPopulated { 93 | return pberrors.ErrConfigTLSFilesInconsistent 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func validateConfiguration(c *Configuration) error { 100 | return validateHTTPConfiguration(c) 101 | } 102 | 103 | // Get returns the configuration extracted from env variables or config file. 104 | func Get() *Configuration { 105 | config := &Configuration{} 106 | 107 | err := configor.New(&configor.Config{ 108 | Environment: "production", 109 | ENVPrefix: "PUSHBITS", 110 | ErrorOnUnmatchedKeys: true, 111 | }).Load(config, configFiles()...) 112 | if err != nil { 113 | panic(err) 114 | } 115 | 116 | if err := validateConfiguration(config); err != nil { 117 | log.L.Fatal(err) 118 | } 119 | 120 | return config 121 | } 122 | -------------------------------------------------------------------------------- /internal/dispatcher/application.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/pushbits/server/internal/configuration" 8 | "github.com/pushbits/server/internal/log" 9 | "github.com/pushbits/server/internal/model" 10 | 11 | "maunium.net/go/mautrix" 12 | "maunium.net/go/mautrix/event" 13 | mId "maunium.net/go/mautrix/id" 14 | ) 15 | 16 | func buildRoomTopic(id uint) string { 17 | return fmt.Sprintf("Application %d", id) 18 | } 19 | 20 | // RegisterApplication creates a channel for an application. 21 | func (d *Dispatcher) RegisterApplication(id uint, name, user string) (string, error) { 22 | log.L.Printf("Registering application %s, notifications will be relayed to user %s.\n", name, user) 23 | 24 | resp, err := d.mautrixClient.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{ 25 | Visibility: "private", 26 | Invite: []mId.UserID{mId.UserID(user)}, 27 | IsDirect: true, 28 | Name: name, 29 | Preset: "private_chat", 30 | Topic: buildRoomTopic(id), 31 | }) 32 | if err != nil { 33 | log.L.Print(err) 34 | return "", err 35 | } 36 | 37 | log.L.Printf("Application %s is now relayed to room with ID %s.\n", name, resp.RoomID.String()) 38 | 39 | return resp.RoomID.String(), err 40 | } 41 | 42 | // DeregisterApplication deletes a channel for an application. 43 | func (d *Dispatcher) DeregisterApplication(a *model.Application, u *model.User) error { 44 | log.L.Printf("Deregistering application %s (ID %d) with Matrix ID %s.\n", a.Name, a.ID, a.MatrixID) 45 | 46 | // The user might have left the channel, but we can still try to remove them. 47 | 48 | if _, err := d.mautrixClient.KickUser(context.Background(), mId.RoomID(a.MatrixID), &mautrix.ReqKickUser{ 49 | Reason: "This application was deleted", 50 | UserID: mId.UserID(u.MatrixID), 51 | }); err != nil { 52 | log.L.Print(err) 53 | return err 54 | } 55 | 56 | if _, err := d.mautrixClient.LeaveRoom(context.Background(), mId.RoomID(a.MatrixID)); err != nil { 57 | log.L.Print(err) 58 | return err 59 | } 60 | 61 | if _, err := d.mautrixClient.ForgetRoom(context.Background(), mId.RoomID(a.MatrixID)); err != nil { 62 | log.L.Print(err) 63 | return err 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func (d *Dispatcher) sendRoomEvent(roomID, eventType string, content interface{}) error { 70 | if _, err := d.mautrixClient.SendStateEvent(context.Background(), mId.RoomID(roomID), event.NewEventType(eventType), "", content); err != nil { 71 | log.L.Print(err) 72 | return err 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // UpdateApplication updates a channel for an application. 79 | func (d *Dispatcher) UpdateApplication(a *model.Application, behavior *configuration.RepairBehavior) error { 80 | log.L.Printf("Updating application %s (ID %d) with Matrix ID %s.\n", a.Name, a.ID, a.MatrixID) 81 | 82 | if behavior.ResetRoomName { 83 | content := map[string]interface{}{ 84 | "name": a.Name, 85 | } 86 | 87 | if err := d.sendRoomEvent(a.MatrixID, "m.room.name", content); err != nil { 88 | return err 89 | } 90 | } else { 91 | log.L.Debugf("Not reseting room name as per configuration.\n") 92 | } 93 | 94 | if behavior.ResetRoomTopic { 95 | content := map[string]interface{}{ 96 | "topic": buildRoomTopic(a.ID), 97 | } 98 | 99 | if err := d.sendRoomEvent(a.MatrixID, "m.room.topic", content); err != nil { 100 | return err 101 | } 102 | } else { 103 | log.L.Debugf("Not reseting room topic as per configuration.\n") 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // IsOrphan checks if the user is still connected to the channel. 110 | func (d *Dispatcher) IsOrphan(a *model.Application, u *model.User) (bool, error) { 111 | resp, err := d.mautrixClient.JoinedMembers(context.Background(), mId.RoomID(a.MatrixID)) 112 | if err != nil { 113 | return false, err 114 | } 115 | 116 | found := false 117 | 118 | for userID := range resp.Joined { 119 | found = found || (userID.String() == u.MatrixID) 120 | } 121 | 122 | return !found, nil 123 | } 124 | 125 | // RepairApplication re-invites the user to the channel. 126 | func (d *Dispatcher) RepairApplication(a *model.Application, u *model.User) error { 127 | _, err := d.mautrixClient.InviteUser(context.Background(), mId.RoomID(a.MatrixID), &mautrix.ReqInviteUser{ 128 | UserID: mId.UserID(u.MatrixID), 129 | }) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | | :exclamation: **This software is currently in alpha phase.** | 2 | |-----------------------------------------------------------------| 3 | 4 |
5 | 6 | 7 | 8 |
9 | 10 |
11 |

PushBits

12 |

Receive your important notifications immediately, over Matrix.

13 |

PushBits enables you to send push notifications via a simple web API, and delivers them to your users.

14 |
15 | 16 |

17 | Build status  18 | Documentation  19 | API Documentation  20 | Matrix  21 | 22 | License 23 |

24 | 25 | ## 💡 About 26 | 27 | PushBits is a relay server for push notifications. 28 | It enables you to send notifications via a simple web API, and delivers them to you through [Matrix](https://matrix.org/). 29 | This is similar to what [Pushover](https://pushover.net/) and [Gotify](https://gotify.net/) offer, but it does not require an additional app. 30 | 31 | The vision is to have compatibility with Gotify on the sending side, while on the receiving side an established service is used. 32 | This has the advantages that we need to maintain neither plugins (like those for [Watchtower](https://containrrr.dev/watchtower/) and [Jellyfin](https://jellyfin.org/)) nor clients. 33 | 34 | ## 🤘 Features 35 | 36 | - [x] Multiple users and multiple channels (applications) per user 37 | - [x] Compatibility with Gotify's API for sending messages 38 | - [x] API and CLI for managing users and applications 39 | - [x] Optional check for weak passwords using [HIBP](https://haveibeenpwned.com/) 40 | - [x] Argon2 as KDF for password storage 41 | - [ ] Two-factor authentication, [issue](https://github.com/pushbits/server/issues/19) 42 | - [ ] Bi-directional key verification, [issue](https://github.com/pushbits/server/issues/20) 43 | 44 | ## 👮 License and Acknowledgments 45 | 46 | Please refer to [the LICENSE file](LICENSE) to learn more about the license of this code. 47 | It applies only where not specified differently. 48 | 49 | The idea for this software was inspired by [Gotify](https://gotify.net/). 50 | 51 | ## 💻 Development and Contributions 52 | 53 | The source code is located [on GitHub](https://github.com/pushbits/server). 54 | You can retrieve it by checking out the repository as follows: 55 | ```bash 56 | git clone https://github.com/pushbits/server.git 57 | ``` 58 | 59 | :wrench: **Want to contribute?** 60 | Before moving forward, please refer to [our contribution guidelines](CONTRIBUTING.md). 61 | 62 | :mailbox: **Found a security vulnerability?** 63 | Check [this document](SECURITY.md) for information on how you can bring it to our attention. 64 | 65 | :star: **Like fancy graphs?** See [our stargazers over time](https://starchart.cc/pushbits/server). 66 | 67 | ## ❓ Frequently Asked Questions (FAQ) 68 | 69 | ### Why Matrix instead of X? 70 | 71 | This project totally would've used Signal if it would offer a proper API. 72 | Sadly, neither [Signal](https://signal.org/) nor [WhatsApp](https://www.whatsapp.com/) come with an API (at the time of writing) through which PushBits could interact. 73 | 74 | In [Telegram](https://telegram.org/) there is an API to run bots, but these are limited in that they cannot create chats by themselves. 75 | If you insist on going with Telegram, have a look at [telepush](https://github.com/muety/telepush). 76 | 77 | The idea of a federated, synchronized but yet end-to-end encrypted protocol is awesome, but its clients simply aren't really there yet. 78 | Still, if you haven't tried it yet, we'd encourage you to check it out. 79 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | // Package database provides definitions and functionality related to the database. 2 | package database 3 | 4 | import ( 5 | "database/sql" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/pushbits/server/internal/authentication/credentials" 12 | "github.com/pushbits/server/internal/configuration" 13 | "github.com/pushbits/server/internal/log" 14 | "github.com/pushbits/server/internal/model" 15 | 16 | "gorm.io/driver/mysql" 17 | "gorm.io/driver/postgres" 18 | "gorm.io/driver/sqlite" 19 | "gorm.io/gorm" 20 | ) 21 | 22 | // Database holds information for the database connection. 23 | type Database struct { 24 | gormdb *gorm.DB 25 | sqldb *sql.DB 26 | credentialsManager *credentials.Manager 27 | } 28 | 29 | func createFileDir(file string) { 30 | dir := filepath.Dir(file) 31 | 32 | if _, err := os.Stat(dir); os.IsNotExist(err) { 33 | if err := os.MkdirAll(dir, 0o750); err != nil { 34 | panic(err) 35 | } 36 | } 37 | } 38 | 39 | // Create instanciates a database connection. 40 | func Create(cm *credentials.Manager, dialect, connection string) (*Database, error) { 41 | log.L.Println("Setting up database connection.") 42 | 43 | maxOpenConns := 5 44 | 45 | var db *gorm.DB 46 | var err error 47 | 48 | switch dialect { 49 | case "sqlite3": 50 | createFileDir(connection) 51 | maxOpenConns = 1 52 | db, err = gorm.Open(sqlite.Open(connection), &gorm.Config{}) 53 | case "mysql": 54 | db, err = gorm.Open(mysql.Open(connection), &gorm.Config{}) 55 | case "postgres": 56 | db, err = gorm.Open(postgres.Open(connection), &gorm.Config{}) 57 | default: 58 | message := "Database dialect is not supported" 59 | return nil, errors.New(message) 60 | } 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | sql, err := db.DB() 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | sql.SetMaxOpenConns(maxOpenConns) 72 | 73 | if dialect == "mysql" { 74 | sql.SetConnMaxLifetime(9 * time.Minute) 75 | } 76 | 77 | err = db.AutoMigrate(&model.User{}, &model.Application{}) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return &Database{gormdb: db, sqldb: sql, credentialsManager: cm}, nil 83 | } 84 | 85 | // Close closes the database connection. 86 | func (d *Database) Close() { 87 | err := d.sqldb.Close() 88 | if err != nil { 89 | log.L.Printf("Error while closing database: %s", err) 90 | } 91 | } 92 | 93 | // Populate fills the database with initial information like the admin user. 94 | func (d *Database) Populate(name, password, matrixID string) error { 95 | log.L.Print("Populating database.") 96 | 97 | var user model.User 98 | 99 | query := d.gormdb.Where("name = ?", name).First(&user) 100 | 101 | if errors.Is(query.Error, gorm.ErrRecordNotFound) { 102 | user, err := model.NewUser(d.credentialsManager, name, password, true, matrixID) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | if err := d.gormdb.Create(&user).Error; err != nil { 108 | return errors.New("user cannot be created") 109 | } 110 | } else { 111 | log.L.Printf("Priviledged user %s already exists.", name) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // RepairChannels resets channels that have been modified by a user. 118 | func (d *Database) RepairChannels(dp Dispatcher, behavior *configuration.RepairBehavior) error { 119 | log.L.Print("Repairing application channels.") 120 | 121 | users, err := d.GetUsers() 122 | if err != nil { 123 | return err 124 | } 125 | 126 | for _, user := range users { 127 | user := user // See https://stackoverflow.com/a/68247837 128 | 129 | applications, err := d.GetApplications(&user) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | for _, application := range applications { 135 | application := application // See https://stackoverflow.com/a/68247837 136 | 137 | if err := dp.UpdateApplication(&application, behavior); err != nil { 138 | return err 139 | } 140 | 141 | orphan, err := dp.IsOrphan(&application, &user) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | if orphan { 147 | log.L.Printf("Found orphan channel for application %s (ID %d)", application.Name, application.ID) 148 | 149 | if err = dp.RepairApplication(&application, &user); err != nil { 150 | log.L.Printf("Unable to repair application %s (ID %d).", application.Name, application.ID) 151 | log.L.Println(err) 152 | } 153 | } 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/api/notification_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/pushbits/server/internal/model" 12 | "github.com/pushbits/server/tests" 13 | ) 14 | 15 | func TestApi_CreateNotification(t *testing.T) { 16 | ctx := GetTestContext(t) 17 | 18 | assert := assert.New(t) 19 | require := require.New(t) 20 | 21 | testApplication := model.Application{ 22 | ID: 1, 23 | Token: "123456", 24 | UserID: 1, 25 | Name: "Test Application", 26 | MatrixID: "@testuser:test.de", 27 | } 28 | 29 | testCases := make([]tests.Request, 0) 30 | testCases = append(testCases, tests.Request{Name: "Valid with message", Method: "POST", Endpoint: "/message?token=123456&message=testmessage", ShouldStatus: 200, ShouldReturn: model.Notification{Message: "testmessage", Title: "Test Application"}}) 31 | testCases = append(testCases, tests.Request{Name: "Valid with message and title", Method: "POST", Endpoint: "/message?token=123456&message=testmessage&title=abcdefghijklmnop", ShouldStatus: 200, ShouldReturn: model.Notification{Message: "testmessage", Title: "abcdefghijklmnop"}}) 32 | testCases = append(testCases, tests.Request{Name: "Valid with message, title and priority", Method: "POST", Endpoint: "/message?token=123456&message=testmessage&title=abcdefghijklmnop&priority=3", ShouldStatus: 200, ShouldReturn: model.Notification{Message: "testmessage", Title: "abcdefghijklmnop", Priority: 3}}) 33 | testCases = append(testCases, tests.Request{Name: "Invalid with wrong field message2", Method: "POST", Endpoint: "/message?token=123456&message2=testmessage", ShouldStatus: 400}) 34 | testCases = append(testCases, tests.Request{Name: "No form data", Method: "POST", Endpoint: "/message", ShouldStatus: 400}) 35 | 36 | for _, req := range testCases { 37 | var notification model.Notification 38 | w, c, err := req.GetRequest() 39 | if err != nil { 40 | t.Fatal(err.Error()) 41 | } 42 | 43 | c.Set("app", &testApplication) 44 | ctx.NotificationHandler.CreateNotification(c) 45 | 46 | // Parse body only for successful requests 47 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 48 | body, err := io.ReadAll(w.Body) 49 | require.NoErrorf(err, "Can not read request body") 50 | err = json.Unmarshal(body, ¬ification) 51 | require.NoErrorf(err, "Can not unmarshal request body") 52 | 53 | shouldNotification, ok := req.ShouldReturn.(model.Notification) 54 | assert.Truef(ok, "(Test case %s) Type mismatch can not test further", req.Name) 55 | 56 | assert.Greaterf(len(notification.ID), 1, "(Test case %s) Notification id is not set correctly with \"%s\"", req.Name, notification.ID) 57 | 58 | assert.Equalf(shouldNotification.Message, notification.Message, "(Test case %s) Notification message should be %s but is %s", req.Name, shouldNotification.Message, notification.Message) 59 | assert.Equalf(shouldNotification.Title, notification.Title, "(Test case %s) Notification title should be %s but is %s", req.Name, shouldNotification.Title, notification.Title) 60 | assert.Equalf(shouldNotification.Priority, notification.Priority, "(Test case %s) Notification priority should be %s but is %s", req.Name, shouldNotification.Priority, notification.Priority) 61 | } 62 | 63 | assert.Equalf(w.Code, req.ShouldStatus, "(Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 64 | } 65 | } 66 | 67 | func TestApi_DeleteNotification(t *testing.T) { 68 | ctx := GetTestContext(t) 69 | 70 | assert := assert.New(t) 71 | 72 | testApplication := model.Application{ 73 | ID: 1, 74 | Token: "123456", 75 | UserID: 1, 76 | Name: "Test Application", 77 | MatrixID: "@testuser:test.de", 78 | } 79 | 80 | testCases := make(map[interface{}]tests.Request) 81 | testCases["1"] = tests.Request{Name: "Valid numeric string", Method: "DELETE", Endpoint: "/message?token=123456&message=testmessage", ShouldStatus: 200} 82 | testCases["abcde"] = tests.Request{Name: "Valid string", Method: "DELETE", Endpoint: "/message?token=123456&message=testmessage", ShouldStatus: 200} 83 | testCases[123456] = tests.Request{Name: "Invalid int", Method: "DELETE", Endpoint: "/message?token=123456&message=testmessage", ShouldStatus: 500} 84 | 85 | for id, req := range testCases { 86 | w, c, err := req.GetRequest() 87 | if err != nil { 88 | t.Fatal(err.Error()) 89 | } 90 | 91 | c.Set("app", &testApplication) 92 | c.Set("messageid", id) 93 | ctx.NotificationHandler.DeleteNotification(c) 94 | 95 | assert.Equalf(w.Code, req.ShouldStatus, "(Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/api/context_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/pushbits/server/internal/log" 10 | "github.com/pushbits/server/internal/model" 11 | "github.com/pushbits/server/tests" 12 | "github.com/pushbits/server/tests/mockups" 13 | ) 14 | 15 | func TestApi_getID(t *testing.T) { 16 | GetTestContext(t) 17 | 18 | assert := assert.New(t) 19 | require := require.New(t) 20 | testValue := uint(1337) 21 | 22 | testCases := make(map[interface{}]tests.Request) 23 | testCases[-1] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 24 | testCases[uint(1)] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 25 | testCases[uint(0)] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 26 | testCases[uint(500)] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 27 | testCases[500] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 28 | testCases["test"] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 29 | testCases[model.Application{}] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 30 | testCases[&model.Application{}] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 31 | testCases[&testValue] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 500} 32 | 33 | for id, req := range testCases { 34 | w, c, err := req.GetRequest() 35 | if err != nil { 36 | t.Fatal(err.Error()) 37 | } 38 | 39 | c.Set("id", id) 40 | idReturned, err := getID(c) 41 | 42 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 43 | require.NoErrorf(err, "getId with id %v (%t) returned an error although it should not: %v", id, id, err) 44 | 45 | idUint, ok := id.(uint) 46 | if ok { 47 | assert.Equalf(idReturned, idUint, "getApi id was set to %d but result is %d", idUint, idReturned) 48 | } 49 | } else { 50 | assert.Errorf(err, "getId with id %v (%t) returned no error although it should", id, id) 51 | } 52 | 53 | assert.Equalf(w.Code, req.ShouldStatus, "getApi id was set to %v (%T) and should result in status code %d but code is %d", id, id, req.ShouldStatus, w.Code) 54 | } 55 | } 56 | 57 | func TestApi_getApplication(t *testing.T) { 58 | ctx := GetTestContext(t) 59 | 60 | assert := assert.New(t) 61 | require := require.New(t) 62 | 63 | applications := mockups.GetAllApplications() 64 | 65 | err := mockups.AddApplicationsToDb(ctx.Database, applications) 66 | if err != nil { 67 | log.L.Fatalln("Cannot add mock applications to database: ", err) 68 | } 69 | 70 | // No testing of invalid ids as that is tested in TestApi_getID already 71 | testCases := make(map[uint]tests.Request) 72 | testCases[500] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 404} 73 | testCases[1] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 74 | testCases[2] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 75 | 76 | for id, req := range testCases { 77 | w, c, err := req.GetRequest() 78 | if err != nil { 79 | t.Fatal(err.Error()) 80 | } 81 | 82 | c.Set("id", id) 83 | app, err := getApplication(c, ctx.Database) 84 | 85 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 86 | require.NoErrorf(err, "getApplication with id %v returned an unexpected error: %v", id, err) 87 | require.NotNilf(app, "Expected a valid app for id %v, but got nil", id) 88 | assert.Equalf(app.ID, id, "Expected app ID %d, but got %d", id, app.ID) 89 | } else { 90 | require.Errorf(err, "Expected an error for id %v, but got none", id) 91 | assert.Nilf(app, "Expected app to be nil for id %v, but got %+v", id, app) 92 | } 93 | 94 | assert.Equalf(w.Code, req.ShouldStatus, "Expected status code %d for id %v, but got %d", req.ShouldStatus, id, w.Code) 95 | } 96 | } 97 | 98 | func TestApi_getUser(t *testing.T) { 99 | ctx := GetTestContext(t) 100 | 101 | assert := assert.New(t) 102 | require := require.New(t) 103 | 104 | _, err := mockups.AddUsersToDb(ctx.Database, ctx.Users) 105 | assert.NoErrorf(err, "Adding users to database failed: %v", err) 106 | 107 | // No testing of invalid ids as that is tested in TestApi_getID already 108 | testCases := make(map[uint]tests.Request) 109 | testCases[500] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 404} 110 | testCases[1] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 111 | testCases[2] = tests.Request{Name: "-", Method: "GET", Endpoint: "/", Data: "", ShouldStatus: 200} 112 | 113 | for id, req := range testCases { 114 | w, c, err := req.GetRequest() 115 | if err != nil { 116 | t.Fatal(err.Error()) 117 | } 118 | 119 | c.Set("id", id) 120 | user, err := getUser(c, ctx.Database) 121 | 122 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 123 | require.NoErrorf(err, "getUser with id %v returned an unexpected error: %v", id, err) 124 | require.NotNilf(user, "Expected a valid user for id %v, but got nil", id) 125 | assert.Equalf(user.ID, id, "Expected user ID %d, but got %d", id, user.ID) 126 | } else { 127 | require.Errorf(err, "Expected an error for id %v, but got none", id) 128 | assert.Nilf(user, "Expected user to be nil for id %v, but got %+v", id, user) 129 | } 130 | 131 | assert.Equalf(w.Code, req.ShouldStatus, "Expected status code %d for id %v, but got %d", req.ShouldStatus, id, w.Code) 132 | 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [eiken.dev](https://www.eiken.dev/). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /internal/configuration/configuration_test.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/jinzhu/configor" 10 | "github.com/stretchr/testify/assert" 11 | "gopkg.in/yaml.v2" 12 | 13 | "github.com/pushbits/server/internal/log" 14 | "github.com/pushbits/server/internal/pberrors" 15 | ) 16 | 17 | type Pair struct { 18 | Is interface{} 19 | Should interface{} 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | testMode = true 24 | m.Run() 25 | cleanUp() 26 | os.Exit(0) 27 | } 28 | 29 | func TestConfiguration_GetMinimal(t *testing.T) { 30 | err := writeMinimalConfig() 31 | if err != nil { 32 | log.L.Fatalln("Cannot write minimal config file: ", err) 33 | } 34 | 35 | validateConfig(t) 36 | } 37 | 38 | func TestConfiguration_GetValid(t *testing.T) { 39 | assert := assert.New(t) 40 | 41 | err := writeValidConfig() 42 | if err != nil { 43 | log.L.Fatalln("Cannot write valid config file: ", err) 44 | } 45 | 46 | validateConfig(t) 47 | 48 | config := Get() 49 | 50 | expectedValues := make(map[string]Pair) 51 | expectedValues["config.Admin.MatrixID"] = Pair{config.Admin.MatrixID, "000000"} 52 | expectedValues["config.Matrix.Username"] = Pair{config.Matrix.Username, "default-username"} 53 | expectedValues["config.Matrix.Password"] = Pair{config.Matrix.Password, "default-password"} 54 | 55 | for name, pair := range expectedValues { 56 | assert.Equalf(pair.Is, pair.Should, fmt.Sprintf("%s should be %v but is %v", name, pair.Should, pair.Is)) 57 | } 58 | } 59 | 60 | func TestConfiguration_GetEmpty(t *testing.T) { 61 | err := writeEmptyConfig() 62 | if err != nil { 63 | fmt.Println("Could not write empty config: ", err) 64 | os.Exit(1) 65 | } 66 | 67 | assert.Panicsf(t, func() { Get() }, "Get() did not panic although config is empty") 68 | } 69 | 70 | func TestConfiguration_GetInvalid(t *testing.T) { 71 | err := writeInvalidConfig() 72 | if err != nil { 73 | fmt.Println("Could not write invalid config: ", err) 74 | os.Exit(1) 75 | } 76 | 77 | assert.Panicsf(t, func() { Get() }, "Get() did not panic although config is empty") 78 | } 79 | 80 | func TestConfiguaration_ConfigFiles(t *testing.T) { 81 | files := configFiles() 82 | 83 | assert.Greater(t, len(files), 0) 84 | for _, file := range files { 85 | assert.Truef(t, strings.HasSuffix(file, ".yml"), "%s is no yaml file", file) 86 | } 87 | } 88 | 89 | // Checks if the values in the configuration are plausible 90 | func validateConfig(t *testing.T) { 91 | assert := assert.New(t) 92 | assert.NotPanicsf(func() { Get() }, "Get configuration should not panic") 93 | 94 | config := Get() 95 | asGreater := make(map[string]Pair) 96 | asGreater["config.Crypto.Argon2.Memory"] = Pair{config.Crypto.Argon2.Memory, uint32(0)} 97 | asGreater["config.Crypto.Argon2.Iterations"] = Pair{config.Crypto.Argon2.Iterations, uint32(0)} 98 | asGreater["config.Crypto.Argon2.SaltLength"] = Pair{config.Crypto.Argon2.SaltLength, uint32(0)} 99 | asGreater["config.Crypto.Argon2.KeyLength"] = Pair{config.Crypto.Argon2.KeyLength, uint32(0)} 100 | asGreater["config.Crypto.Argon2.Parallelism"] = Pair{config.Crypto.Argon2.Parallelism, uint8(0)} 101 | asGreater["config.HTTP.Port"] = Pair{config.HTTP.Port, 0} 102 | for name, pair := range asGreater { 103 | assert.Greaterf(pair.Is, pair.Should, fmt.Sprintf("%s should be > %v but is %v", name, pair.Should, pair.Is)) 104 | } 105 | 106 | asFalse := make(map[string]bool) 107 | asFalse["config.Formatting.ColoredTitle"] = config.Formatting.ColoredTitle 108 | asFalse["config.Debug"] = config.Debug 109 | asFalse["config.Security.CheckHIBP"] = config.Security.CheckHIBP 110 | for name, value := range asFalse { 111 | assert.Falsef(value, fmt.Sprintf("%s should be false but is %t", name, value)) 112 | } 113 | } 114 | 115 | type MinimalConfiguration struct { 116 | Admin struct { 117 | MatrixID string 118 | } 119 | Matrix struct { 120 | Username string 121 | Password string 122 | } 123 | } 124 | 125 | type InvalidConfiguration struct { 126 | Debug int 127 | HTTP struct { 128 | ListenAddress bool 129 | } 130 | Admin struct { 131 | Name int 132 | } 133 | Formatting string 134 | } 135 | 136 | // Writes a minimal config to config.yml 137 | func writeMinimalConfig() error { 138 | cleanUp() 139 | 140 | config := MinimalConfiguration{} 141 | config.Admin.MatrixID = "000000" 142 | config.Matrix.Username = "default-username" 143 | config.Matrix.Password = "default-password" 144 | 145 | configString, err := yaml.Marshal(&config) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | err = os.WriteFile("config_unittest.yml", configString, 0o644) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | return nil 156 | } 157 | 158 | // Writes a config with default values to config.yml 159 | func writeValidConfig() error { 160 | cleanUp() 161 | 162 | err := writeMinimalConfig() 163 | if err != nil { 164 | return err 165 | } 166 | 167 | // Load minimal config to get default values 168 | config := &Configuration{} 169 | err = configor.New(&configor.Config{ 170 | Environment: "production", 171 | ENVPrefix: "PUSHBITS", 172 | ErrorOnUnmatchedKeys: true, 173 | }).Load(config, "config_unittest.yml") 174 | if err != nil { 175 | return err 176 | } 177 | 178 | config.Admin.MatrixID = "000000" 179 | config.Matrix.Username = "default-username" 180 | config.Matrix.Password = "default-password" 181 | 182 | configString, err := yaml.Marshal(&config) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | err = os.WriteFile("config_unittest.yml", configString, 0o644) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | return nil 193 | } 194 | 195 | // Writes a config that is empty 196 | func writeEmptyConfig() error { 197 | cleanUp() 198 | 199 | err := os.WriteFile("config_unittest.yml", []byte(""), 0o644) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | return nil 205 | } 206 | 207 | // Writes a config with invalid entries 208 | func writeInvalidConfig() error { 209 | cleanUp() 210 | 211 | config := InvalidConfiguration{} 212 | config.Debug = 1337 213 | config.HTTP.ListenAddress = true 214 | config.Admin.Name = 23 215 | config.Formatting = "Nice" 216 | 217 | configString, err := yaml.Marshal(&config) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | err = os.WriteFile("config_unittest.yml", configString, 0o644) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func cleanUp() { 231 | err := os.Remove("config_unittest.yml") 232 | if err != nil { 233 | log.L.Warnln("Cannot remove config file: ", err) 234 | } 235 | } 236 | 237 | func TestConfigurationValidation_ConfigTLSFilesInconsistent(t *testing.T) { 238 | assert := assert.New(t) 239 | 240 | c := Configuration{} 241 | c.Admin.MatrixID = "000000" 242 | c.Matrix.Username = "default-username" 243 | c.Matrix.Password = "default-password" 244 | c.HTTP.CertFile = "populated" 245 | c.HTTP.KeyFile = "" 246 | 247 | is := validateConfiguration(&c) 248 | should := pberrors.ErrConfigTLSFilesInconsistent 249 | assert.Equal(is, should, "validateConfiguration() should return ConfigTLSFilesInconsistent") 250 | } 251 | -------------------------------------------------------------------------------- /internal/api/user_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/pushbits/server/internal/model" 13 | "github.com/pushbits/server/tests" 14 | ) 15 | 16 | func TestApi_CreateUser(t *testing.T) { 17 | ctx := GetTestContext(t) 18 | 19 | assert := assert.New(t) 20 | 21 | testCases := make([]tests.Request, 0) 22 | 23 | // Add all test users 24 | for _, user := range ctx.Users { 25 | createUser := &model.CreateUser{} 26 | createUser.ExternalUser.Name = user.Name 27 | createUser.ExternalUser.MatrixID = "@" + user.Name + ":matrix.org" 28 | createUser.ExternalUser.IsAdmin = user.IsAdmin 29 | createUser.UserCredentials.Password = ctx.Config.Admin.Password 30 | 31 | testCase := tests.Request{ 32 | Name: "Already existing user " + user.Name, 33 | Method: "POST", 34 | Endpoint: "/user", 35 | Data: createUser, 36 | Headers: map[string]string{"Content-Type": "application/json"}, 37 | ShouldStatus: 400, 38 | } 39 | testCases = append(testCases, testCase) 40 | 41 | } 42 | 43 | testCases = append(testCases, tests.Request{Name: "No data", Method: "POST", Endpoint: "/user", Data: "", ShouldStatus: 400}) 44 | testCases = append(testCases, tests.Request{Name: "Missing data urlencoded", Method: "POST", Endpoint: "/user", Data: "name=superuser&id=1&lol=5", ShouldStatus: 400}) 45 | testCases = append(testCases, tests.Request{Name: "Valid user urlencoded", Method: "POST", Endpoint: "/user", Data: "name=testuser1&matrix_id=%40testuser1%3Amatrix.org&is_admin=false&password=abcde", Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, ShouldStatus: 200}) 46 | testCases = append(testCases, tests.Request{Name: "Valid user JSON encoded", Method: "POST", Endpoint: "/user", Data: `{"name": "testuser2", "matrix_id": "@testuser2@matrix.org", "is_admin": true, "password": "cdefg"}`, Headers: map[string]string{"Content-Type": "application/json"}, ShouldStatus: 200}) 47 | 48 | for _, req := range testCases { 49 | w, c, err := req.GetRequest() 50 | if err != nil { 51 | t.Fatal(err.Error()) 52 | } 53 | 54 | ctx.UserHandler.CreateUser(c) 55 | 56 | assert.Equalf(w.Code, req.ShouldStatus, "(Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 57 | } 58 | } 59 | 60 | func TestApi_GetUsers(t *testing.T) { 61 | ctx := GetTestContext(t) 62 | 63 | assert := assert.New(t) 64 | require := require.New(t) 65 | 66 | request := tests.Request{ 67 | Method: "GET", 68 | Endpoint: "/user", 69 | } 70 | 71 | w, c, err := request.GetRequest() 72 | if err != nil { 73 | t.Fatalf("error getting request: %v", err) 74 | } 75 | 76 | ctx.UserHandler.GetUsers(c) 77 | assert.Equalf(w.Code, 200, "Response code should be 200 but is %d", w.Code) 78 | 79 | // Get users from body 80 | users := make([]model.ExternalUser, 0) 81 | usersRaw, err := io.ReadAll(w.Body) 82 | require.NoErrorf(err, "Failed to parse response body") 83 | err = json.Unmarshal(usersRaw, &users) 84 | require.NoErrorf(err, "Can not unmarshal users") 85 | 86 | // Check existence of all known users 87 | for _, user := range ctx.Users { 88 | found := false 89 | for _, userExt := range users { 90 | if user.ID == userExt.ID && user.Name == userExt.Name { 91 | found = true 92 | break 93 | } 94 | } 95 | assert.Truef(found, "Did not find user %s", user.Name) 96 | } 97 | } 98 | 99 | func TestApi_UpdateUser(t *testing.T) { 100 | ctx := GetTestContext(t) 101 | 102 | assert := assert.New(t) 103 | 104 | admin := getAdmin(ctx) 105 | testCases := make(map[uint]tests.Request) 106 | 107 | // Add all test users 108 | for _, user := range ctx.Users { 109 | updateUser := &model.UpdateUser{} 110 | user.Name += "+1" 111 | user.IsAdmin = !user.IsAdmin 112 | 113 | updateUser.Name = &user.Name 114 | updateUser.IsAdmin = &user.IsAdmin 115 | 116 | testCases[uint(1)] = tests.Request{ 117 | Name: "Already existing user " + user.Name, 118 | Method: "POST", 119 | Endpoint: "/user", 120 | Data: updateUser, 121 | Headers: map[string]string{"Content-Type": "application/json"}, 122 | ShouldStatus: 200, 123 | } 124 | } 125 | 126 | // Valid updates 127 | for id, req := range testCases { 128 | w, c, err := req.GetRequest() 129 | if err != nil { 130 | t.Fatal(err.Error()) 131 | } 132 | 133 | c.Set("id", id) 134 | c.Set("user", admin) 135 | ctx.UserHandler.UpdateUser(c) 136 | 137 | assert.Equalf(w.Code, req.ShouldStatus, "(Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 138 | } 139 | 140 | // Invalid without user set 141 | for id, req := range testCases { 142 | _, c, err := req.GetRequest() 143 | if err != nil { 144 | t.Fatal(err.Error()) 145 | } 146 | 147 | c.Set("id", id) 148 | assert.Panicsf(func() { ctx.UserHandler.UpdateUser(c) }, "User not set should panic but did not") 149 | } 150 | } 151 | 152 | func TestApi_GetUser(t *testing.T) { 153 | ctx := GetTestContext(t) 154 | 155 | assert := assert.New(t) 156 | require := require.New(t) 157 | 158 | testCases := make(map[interface{}]tests.Request) 159 | testCases["abcde"] = tests.Request{Name: "Invalid id - string", Method: "GET", Endpoint: "/user/abcde", ShouldStatus: 500} 160 | testCases[uint(9999999)] = tests.Request{Name: "Unknown id", Method: "GET", Endpoint: "/user/99999999", ShouldStatus: 404} 161 | 162 | // Check if we can get all existing users 163 | for _, user := range ctx.Users { 164 | testCases[user.ID] = tests.Request{ 165 | Name: "Valid user " + user.Name, 166 | Method: "GET", 167 | Endpoint: "/user/" + strconv.Itoa(int(user.ID)), 168 | ShouldStatus: 200, 169 | ShouldReturn: user, 170 | } 171 | } 172 | 173 | for id, testCase := range testCases { 174 | w, c, err := testCase.GetRequest() 175 | require.NoErrorf(err, "(Test case %s) Could not make request", testCase.Name) 176 | 177 | c.Set("id", id) 178 | ctx.UserHandler.GetUser(c) 179 | 180 | assert.Equalf(testCase.ShouldStatus, w.Code, "(Test case %s) Expected status code %d but have %d", testCase.Name, testCase.ShouldStatus, w.Code) 181 | 182 | // Check content for successful requests 183 | if testCase.ShouldReturn == 200 { 184 | user := &model.ExternalUser{} 185 | userBytes, err := io.ReadAll(w.Body) 186 | require.NoErrorf(err, "(Test case %s) Can not read body", testCase.Name) 187 | err = json.Unmarshal(userBytes, user) 188 | require.NoErrorf(err, "(Test case %s) Can not unmarshal body", testCase.Name) 189 | 190 | shouldUser, ok := testCase.ShouldReturn.(*model.User) 191 | assert.Truef(ok, "(Test case %s) Successful response but no should response", testCase.Name) 192 | 193 | // Check if the returned user match 194 | assert.Equalf(user.ID, shouldUser.ID, "(Test case %s) User ID should be %d but is %d", testCase.Name, user.ID, shouldUser.ID) 195 | assert.Equalf(user.Name, shouldUser.Name, "(Test case %s) User name should be %s but is %s", testCase.Name, user.Name, shouldUser.Name) 196 | assert.Equalf(user.MatrixID, shouldUser.MatrixID, "(Test case %s) User matrix ID should be %s but is %s", testCase.Name, user.MatrixID, shouldUser.MatrixID) 197 | assert.Equalf(user.IsAdmin, shouldUser.IsAdmin, "(Test case %s) User is admin should be %v but is %v", testCase.Name, user.IsAdmin, shouldUser.IsAdmin) 198 | } 199 | } 200 | } 201 | 202 | func TestApi_DeleteUser(t *testing.T) { 203 | ctx := GetTestContext(t) 204 | 205 | assert := assert.New(t) 206 | require := require.New(t) 207 | 208 | testCases := make(map[interface{}]tests.Request) 209 | testCases["abcde"] = tests.Request{Name: "Invalid user - string", Method: "DELETE", Endpoint: "/user/abcde", ShouldStatus: 500} 210 | testCases[uint(999999)] = tests.Request{Name: "Unknown user", Method: "DELETE", Endpoint: "/user/999999", ShouldStatus: 404} 211 | 212 | for _, user := range ctx.Users { 213 | shouldStatus := 200 214 | testCases[user.ID] = tests.Request{ 215 | Name: "Valid user " + user.Name, 216 | Method: "DELETE", 217 | Endpoint: "/user/" + strconv.Itoa(int(user.ID)), 218 | ShouldStatus: shouldStatus, 219 | } 220 | } 221 | 222 | for id, testCase := range testCases { 223 | w, c, err := testCase.GetRequest() 224 | require.NoErrorf(err, "(Test case %s) Could not make request", testCase.Name) 225 | 226 | c.Set("id", id) 227 | ctx.UserHandler.DeleteUser(c) 228 | 229 | assert.Equalf(testCase.ShouldStatus, w.Code, "(Test case %s) Expected status code %d but have %d", testCase.Name, testCase.ShouldStatus, w.Code) 230 | } 231 | } 232 | 233 | func getAdmin(ctx *TestContext) *model.User { 234 | for _, user := range ctx.Users { 235 | if user.IsAdmin { 236 | return user 237 | } 238 | } 239 | return nil 240 | } 241 | -------------------------------------------------------------------------------- /internal/api/application.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/pushbits/server/internal/authentication" 8 | "github.com/pushbits/server/internal/configuration" 9 | "github.com/pushbits/server/internal/log" 10 | "github.com/pushbits/server/internal/model" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // ApplicationHandler holds information for processing requests about applications. 16 | type ApplicationHandler struct { 17 | DB Database 18 | DP Dispatcher 19 | } 20 | 21 | func (h *ApplicationHandler) applicationExists(token string) bool { 22 | application, _ := h.DB.GetApplicationByToken(token) 23 | return application != nil 24 | } 25 | 26 | func (h *ApplicationHandler) generateToken(compat bool) string { 27 | return authentication.GenerateNotExistingToken(authentication.GenerateApplicationToken, compat, h.applicationExists) 28 | } 29 | 30 | func (h *ApplicationHandler) registerApplication(ctx *gin.Context, a *model.Application, u *model.User) error { 31 | if a == nil || u == nil { 32 | return errors.New("nil parameters provided") 33 | } 34 | 35 | log.L.Printf("Registering application %s.", a.Name) 36 | 37 | channelID, err := h.DP.RegisterApplication(a.ID, a.Name, u.MatrixID) 38 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 39 | return err 40 | } 41 | 42 | a.MatrixID = channelID 43 | 44 | err = h.DB.UpdateApplication(a) 45 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (h *ApplicationHandler) createApplication(ctx *gin.Context, u *model.User, name string, compat bool) (*model.Application, error) { 53 | if u == nil { 54 | return nil, errors.New("nil parameters provided") 55 | } 56 | 57 | log.L.Printf("Creating application %s.", name) 58 | 59 | application := model.Application{} 60 | application.Name = name 61 | application.Token = h.generateToken(compat) 62 | application.UserID = u.ID 63 | 64 | err := h.DB.CreateApplication(&application) 65 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 66 | return nil, err 67 | } 68 | 69 | if err := h.registerApplication(ctx, &application, u); err != nil { 70 | err := h.DB.DeleteApplication(&application) 71 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 72 | log.L.Printf("Cannot delete application with ID %d.", application.ID) 73 | } 74 | 75 | return nil, err 76 | } 77 | 78 | return &application, nil 79 | } 80 | 81 | func (h *ApplicationHandler) deleteApplication(ctx *gin.Context, a *model.Application, u *model.User) error { 82 | if a == nil || u == nil { 83 | return errors.New("nil parameters provided") 84 | } 85 | 86 | log.L.Printf("Deleting application %s (ID %d).", a.Name, a.ID) 87 | 88 | err := h.DP.DeregisterApplication(a, u) 89 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 90 | return err 91 | } 92 | 93 | err = h.DB.DeleteApplication(a) 94 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 95 | return err 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (h *ApplicationHandler) updateApplication(ctx *gin.Context, a *model.Application, updateApplication *model.UpdateApplication) error { 102 | if a == nil || updateApplication == nil { 103 | return errors.New("nil parameters provided") 104 | } 105 | 106 | log.L.Printf("Updating application %s (ID %d).", a.Name, a.ID) 107 | 108 | if updateApplication.Name != nil { 109 | log.L.Printf("Updating application name to '%s'.", *updateApplication.Name) 110 | a.Name = *updateApplication.Name 111 | } 112 | 113 | if updateApplication.RefreshToken != nil && (*updateApplication.RefreshToken) { 114 | log.L.Print("Updating application token.") 115 | compat := updateApplication.StrictCompatibility != nil && (*updateApplication.StrictCompatibility) 116 | a.Token = h.generateToken(compat) 117 | } 118 | 119 | err := h.DB.UpdateApplication(a) 120 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 121 | return err 122 | } 123 | 124 | err = h.DP.UpdateApplication(a, &configuration.RepairBehavior{ResetRoomName: true, ResetRoomTopic: true}) 125 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 126 | return err 127 | } 128 | 129 | return nil 130 | } 131 | 132 | // CreateApplication godoc 133 | // @Summary Create Application 134 | // @Description Create a new application 135 | // @ID post-application 136 | // @Tags Application 137 | // @Accept json,mpfd 138 | // @Produce json 139 | // @Param name query string true "Name of the application" 140 | // @Param strict_compatibility query boolean false "Use strict compatibility mode" 141 | // @Success 200 {object} model.Application 142 | // @Failure 400 "" 143 | // @Security BasicAuth 144 | // @Router /application [post] 145 | func (h *ApplicationHandler) CreateApplication(ctx *gin.Context) { 146 | var createApplication model.CreateApplication 147 | 148 | if err := ctx.Bind(&createApplication); err != nil { 149 | log.L.Println(err) 150 | return 151 | } 152 | 153 | user := authentication.GetUser(ctx) 154 | if user == nil { 155 | return 156 | } 157 | 158 | application, err := h.createApplication(ctx, user, createApplication.Name, createApplication.StrictCompatibility) 159 | if err != nil { 160 | return 161 | } 162 | 163 | ctx.JSON(http.StatusOK, &application) 164 | } 165 | 166 | // GetApplications godoc 167 | // @Summary Get Applications 168 | // @Description Get all applications from current user 169 | // @ID get-application 170 | // @Tags Application 171 | // @Accept json,mpfd 172 | // @Produce json 173 | // @Success 200 {array} model.Application 174 | // @Failure 500 "" 175 | // @Security BasicAuth 176 | // @Router /application [get] 177 | func (h *ApplicationHandler) GetApplications(ctx *gin.Context) { 178 | user := authentication.GetUser(ctx) 179 | if user == nil { 180 | return 181 | } 182 | 183 | applications, err := h.DB.GetApplications(user) 184 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 185 | return 186 | } 187 | 188 | ctx.JSON(http.StatusOK, &applications) 189 | } 190 | 191 | // GetApplication godoc 192 | // @Summary Get Application 193 | // @Description Get single application by ID 194 | // @ID get-application-id 195 | // @Tags Application 196 | // @Accept json,mpfd 197 | // @Produce json 198 | // @Param id path int true "ID of the application" 199 | // @Success 200 {object} model.Application 200 | // @Failure 404,403 "" 201 | // @Security BasicAuth 202 | // @Router /application/{id} [get] 203 | func (h *ApplicationHandler) GetApplication(ctx *gin.Context) { 204 | application, err := getApplication(ctx, h.DB) 205 | if err != nil || application == nil { 206 | return 207 | } 208 | 209 | user := authentication.GetUser(ctx) 210 | if user == nil { 211 | return 212 | } 213 | 214 | if user.ID != application.UserID { 215 | err := errors.New("application belongs to another user") 216 | ctx.AbortWithError(http.StatusForbidden, err) 217 | return 218 | } 219 | 220 | ctx.JSON(http.StatusOK, &application) 221 | } 222 | 223 | // DeleteApplication godoc 224 | // @Summary Delete Application 225 | // @Description Delete an application 226 | // @ID delete-application-id 227 | // @Tags Application 228 | // @Accept json,mpfd 229 | // @Produce json 230 | // @Param id path int true "ID of the application" 231 | // @Success 200 "" 232 | // @Failure 500,404,403 "" 233 | // @Security BasicAuth 234 | // @Router /application/{id} [delete] 235 | func (h *ApplicationHandler) DeleteApplication(ctx *gin.Context) { 236 | application, err := getApplication(ctx, h.DB) 237 | if err != nil || application == nil { 238 | return 239 | } 240 | 241 | if !isCurrentUser(ctx, application.UserID) { 242 | return 243 | } 244 | 245 | if err := h.deleteApplication(ctx, application, authentication.GetUser(ctx)); err != nil { 246 | return 247 | } 248 | 249 | ctx.JSON(http.StatusOK, gin.H{}) 250 | } 251 | 252 | // UpdateApplication godoc 253 | // @Summary Update Application 254 | // @Description Update an application 255 | // @ID put-application-id 256 | // @Tags Application 257 | // @Accept json,mpfd 258 | // @Produce json 259 | // @Param id path int true "ID of the application" 260 | // @Param name query string false "New name for the application" 261 | // @Param refresh_token query bool false "Generate new refresh token for the application" 262 | // @Param strict_compatibility query bool false "Whether to use strict compataibility mode" 263 | // @Success 200 "" 264 | // @Failure 500,404,403 "" 265 | // @Security BasicAuth 266 | // @Router /application/{id} [put] 267 | func (h *ApplicationHandler) UpdateApplication(ctx *gin.Context) { 268 | application, err := getApplication(ctx, h.DB) 269 | if err != nil || application == nil { 270 | return 271 | } 272 | 273 | if !isCurrentUser(ctx, application.UserID) { 274 | return 275 | } 276 | 277 | var updateApplication model.UpdateApplication 278 | if err := ctx.Bind(&updateApplication); err != nil { 279 | return 280 | } 281 | 282 | if err := h.updateApplication(ctx, application, &updateApplication); err != nil { 283 | return 284 | } 285 | 286 | ctx.JSON(http.StatusOK, gin.H{}) 287 | } 288 | -------------------------------------------------------------------------------- /internal/api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/pushbits/server/internal/authentication" 8 | "github.com/pushbits/server/internal/log" 9 | "github.com/pushbits/server/internal/model" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // UserHandler holds information for processing requests about users. 15 | type UserHandler struct { 16 | AH *ApplicationHandler 17 | CM CredentialsManager 18 | DB Database 19 | DP Dispatcher 20 | } 21 | 22 | func (h *UserHandler) userExists(name string) bool { 23 | user, _ := h.DB.GetUserByName(name) 24 | return user != nil 25 | } 26 | 27 | func (h *UserHandler) requireMultipleAdmins(ctx *gin.Context) error { 28 | if count, err := h.DB.AdminUserCount(); err != nil { 29 | ctx.AbortWithError(http.StatusInternalServerError, err) 30 | return err 31 | } else if count == 1 { 32 | err := errors.New("instance needs at least one privileged user") 33 | ctx.AbortWithError(http.StatusBadRequest, err) 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (h *UserHandler) deleteApplications(ctx *gin.Context, u *model.User) error { 41 | if ctx == nil || u == nil { 42 | return errors.New("nil parameters provided") 43 | } 44 | 45 | applications, err := h.DB.GetApplications(u) 46 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 47 | return err 48 | } 49 | 50 | for _, application := range applications { 51 | application := application // See https://stackoverflow.com/a/68247837 52 | 53 | if err := h.AH.deleteApplication(ctx, &application, u); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (h *UserHandler) updateChannels(ctx *gin.Context, u *model.User, matrixID string) error { 62 | if ctx == nil || u == nil { 63 | return errors.New("nil parameters provided") 64 | } 65 | 66 | applications, err := h.DB.GetApplications(u) 67 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 68 | return err 69 | } 70 | 71 | for _, application := range applications { 72 | application := application // See https://stackoverflow.com/a/68247837 73 | 74 | err := h.DP.DeregisterApplication(&application, u) 75 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 76 | return err 77 | } 78 | } 79 | 80 | u.MatrixID = matrixID 81 | 82 | for _, application := range applications { 83 | application := application // See https://stackoverflow.com/a/68247837 84 | 85 | err := h.AH.registerApplication(ctx, &application, u) 86 | if err != nil { 87 | return err 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (h *UserHandler) updateUserFields(ctx *gin.Context, u *model.User, updateUser model.UpdateUser) error { 95 | if updateUser.Name != nil { 96 | u.Name = *updateUser.Name 97 | } 98 | if updateUser.Password != nil { 99 | hash, err := h.CM.CreatePasswordHash(*updateUser.Password) 100 | if success := SuccessOrAbort(ctx, http.StatusBadRequest, err); !success { 101 | return err 102 | } 103 | u.PasswordHash = hash 104 | } 105 | if updateUser.MatrixID != nil { 106 | u.MatrixID = *updateUser.MatrixID 107 | } 108 | if updateUser.IsAdmin != nil { 109 | u.IsAdmin = *updateUser.IsAdmin 110 | } 111 | return nil 112 | } 113 | 114 | func (h *UserHandler) updateUser(ctx *gin.Context, u *model.User, updateUser model.UpdateUser) error { 115 | if u == nil { 116 | return errors.New("nil parameters provided") 117 | } 118 | 119 | if updateUser.MatrixID != nil && u.MatrixID != *updateUser.MatrixID { 120 | if err := h.updateChannels(ctx, u, *updateUser.MatrixID); err != nil { 121 | return err 122 | } 123 | } 124 | 125 | log.L.Printf("Updating user %s.", u.Name) 126 | 127 | if err := h.updateUserFields(ctx, u, updateUser); err != nil { 128 | return err 129 | } 130 | 131 | err := h.DB.UpdateUser(u) 132 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 133 | return err 134 | } 135 | 136 | return nil 137 | } 138 | 139 | // CreateUser godoc 140 | // This method assumes that the requesting user has privileges. 141 | // @Summary Create a User 142 | // @Description Creates a new user 143 | // @ID post-user 144 | // @Tags User 145 | // @Accept json,mpfd 146 | // @Produce json 147 | // @Param name query string true "Name of the user" 148 | // @Param is_admin query bool false "Whether to set the user as admin or not" 149 | // @Param matrix_id query string true "Matrix ID of the user in the format @user:domain.tld" 150 | // @Param password query string true "The users password" 151 | // @Success 200 {object} model.ExternalUser 152 | // @Failure 500,404,403 "" 153 | // @Security BasicAuth 154 | // @Router /user [post] 155 | func (h *UserHandler) CreateUser(ctx *gin.Context) { 156 | var createUser model.CreateUser 157 | 158 | if err := ctx.Bind(&createUser); err != nil { 159 | return 160 | } 161 | 162 | if h.userExists(createUser.Name) { 163 | ctx.AbortWithError(http.StatusBadRequest, errors.New("username already exists")) 164 | return 165 | } 166 | 167 | log.L.Printf("Creating user %s.", createUser.Name) 168 | 169 | user, err := h.DB.CreateUser(createUser) 170 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 171 | return 172 | } 173 | if user == nil { 174 | return 175 | } 176 | 177 | ctx.JSON(http.StatusOK, user.IntoExternalUser()) 178 | } 179 | 180 | // GetUsers godoc 181 | // This method assumes that the requesting user has privileges. 182 | // @Summary Get Users 183 | // @Description Gets a list of all users 184 | // @ID get-user 185 | // @Tags User 186 | // @Accept json,mpfd 187 | // @Produce json 188 | // @Success 200 {object} []model.ExternalUser 189 | // @Failure 500 "" 190 | // @Security BasicAuth 191 | // @Router /user [get] 192 | func (h *UserHandler) GetUsers(ctx *gin.Context) { 193 | if ctx == nil { 194 | return 195 | } 196 | 197 | users, err := h.DB.GetUsers() 198 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, err); !success { 199 | return 200 | } 201 | 202 | externalUsers := make([]*model.ExternalUser, len(users)) 203 | 204 | for i, user := range users { 205 | externalUsers[i] = user.IntoExternalUser() 206 | } 207 | 208 | ctx.JSON(http.StatusOK, &externalUsers) 209 | } 210 | 211 | // GetUser godoc 212 | // This method assumes that the requesting user has privileges. 213 | // @Summary Get User 214 | // @Description Gets single user 215 | // @ID get-user-id 216 | // @Tags User 217 | // @Accept json,mpfd 218 | // @Produce json 219 | // @Param id path integer true "The users id" 220 | // @Success 200 {object} model.ExternalUser 221 | // @Failure 500,404 "" 222 | // @Security BasicAuth 223 | // @Router /user/{id} [get] 224 | func (h *UserHandler) GetUser(ctx *gin.Context) { 225 | user, err := getUser(ctx, h.DB) 226 | if err != nil || user == nil { 227 | return 228 | } 229 | 230 | ctx.JSON(http.StatusOK, user.IntoExternalUser()) 231 | } 232 | 233 | // DeleteUser godoc 234 | // This method assumes that the requesting user has privileges. 235 | // @Summary Delete User 236 | // @Description Delete user 237 | // @ID delete-user-id 238 | // @Tags User 239 | // @Accept json,mpfd 240 | // @Produce json 241 | // @Param id path integer true "The users id" 242 | // @Success 200 "" 243 | // @Failure 500,404 "" 244 | // @Security BasicAuth 245 | // @Router /user/{id} [delete] 246 | func (h *UserHandler) DeleteUser(ctx *gin.Context) { 247 | user, err := getUser(ctx, h.DB) 248 | if err != nil || user == nil { 249 | return 250 | } 251 | 252 | // Last privileged user must not be deleted. 253 | if user.IsAdmin { 254 | if err := h.requireMultipleAdmins(ctx); err != nil { 255 | return 256 | } 257 | } 258 | 259 | log.L.Printf("Deleting user %s.", user.Name) 260 | 261 | if err := h.deleteApplications(ctx, user); err != nil { 262 | return 263 | } 264 | 265 | if success := SuccessOrAbort(ctx, http.StatusInternalServerError, h.DB.DeleteUser(user)); !success { 266 | return 267 | } 268 | 269 | ctx.JSON(http.StatusOK, gin.H{}) 270 | } 271 | 272 | // UpdateUser godoc 273 | // This method assumes that the requesting user has privileges. If users can later update their own user, make sure they 274 | // cannot give themselves privileges. 275 | // @Summary Update User 276 | // @Description Update user information 277 | // @ID put-user-id 278 | // @Tags User 279 | // @Accept json,mpfd 280 | // @Produce json 281 | // @Param id path integer true "The users id" 282 | // @Param name query string true "Name of the user" 283 | // @Param is_admin query bool false "Whether to set the user as admin or not" 284 | // @Param matrix_id query string true "Matrix ID of the user in the format @user:domain.tld" 285 | // @Param password query string true "The users password" 286 | // @Success 200 "" 287 | // @Failure 500,404,400 "" 288 | // @Security BasicAuth 289 | // @Router /user/{id} [put] 290 | func (h *UserHandler) UpdateUser(ctx *gin.Context) { 291 | user, err := getUser(ctx, h.DB) 292 | if err != nil || user == nil { 293 | return 294 | } 295 | 296 | var updateUser model.UpdateUser 297 | if err := ctx.Bind(&updateUser); err != nil { 298 | return 299 | } 300 | 301 | requestingUser := authentication.GetUser(ctx) 302 | if requestingUser == nil { 303 | return 304 | } 305 | 306 | // Last privileged user must not be taken privileges. Assumes that the current user has privileges. 307 | if user.ID == requestingUser.ID && updateUser.IsAdmin != nil && !(*updateUser.IsAdmin) { 308 | if err := h.requireMultipleAdmins(ctx); err != nil { 309 | return 310 | } 311 | } 312 | 313 | if err := h.updateUser(ctx, user, updateUser); err != nil { 314 | return 315 | } 316 | 317 | ctx.JSON(http.StatusOK, gin.H{}) 318 | } 319 | -------------------------------------------------------------------------------- /internal/dispatcher/notification.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html" 7 | "strings" 8 | 9 | "github.com/gomarkdown/markdown" 10 | "maunium.net/go/mautrix" 11 | "maunium.net/go/mautrix/event" 12 | mId "maunium.net/go/mautrix/id" 13 | 14 | "github.com/pushbits/server/internal/log" 15 | "github.com/pushbits/server/internal/model" 16 | "github.com/pushbits/server/internal/pberrors" 17 | ) 18 | 19 | type notificationContentType string 20 | 21 | const ( 22 | contentTypePlain notificationContentType = "text/plain" 23 | contentTypeMarkdown notificationContentType = "text/markdown" 24 | contentTypeHTML notificationContentType = "text/html" 25 | ) 26 | 27 | func getContentType(extras map[string]any) notificationContentType { 28 | if optionsDisplayRaw, ok := extras["client::display"]; ok { 29 | if optionsDisplay, ok2 := optionsDisplayRaw.(map[string]interface{}); ok2 { 30 | if ctRaw, ok3 := optionsDisplay["contentType"]; ok3 { 31 | contentTypeString := strings.ToLower(fmt.Sprintf("%v", ctRaw)) 32 | switch contentTypeString { 33 | case "text/markdown": 34 | return contentTypeMarkdown 35 | case "text/html": 36 | return contentTypeHTML 37 | case "text/plain": 38 | return contentTypePlain 39 | default: 40 | log.L.Printf("Unknown content type specified: %s, defaulting to text/plain", contentTypeString) 41 | return contentTypePlain 42 | } 43 | } 44 | } 45 | } 46 | 47 | return contentTypePlain 48 | } 49 | 50 | // MessageFormat is a matrix message format 51 | type MessageFormat string 52 | 53 | // MsgType is a matrix msgtype 54 | type MsgType string 55 | 56 | // Define matrix constants 57 | const ( 58 | MessageFormatHTML = MessageFormat("org.matrix.custom.html") 59 | MsgTypeText = MsgType("m.text") 60 | ) 61 | 62 | // MessageEvent is the content of a matrix message event 63 | type MessageEvent struct { 64 | Body string `json:"body"` 65 | FormattedBody string `json:"formatted_body"` 66 | MsgType MsgType `json:"msgtype"` 67 | RelatesTo *RelatesTo `json:"m.relates_to,omitempty"` 68 | Format MessageFormat `json:"format"` 69 | NewContent *NewContent `json:"m.new_content,omitempty"` 70 | } 71 | 72 | // RelatesTo holds information about relations to other message events 73 | type RelatesTo struct { 74 | InReplyTo map[string]string `json:"m.in_reply_to,omitempty"` 75 | RelType string `json:"rel_type,omitempty"` 76 | EventID string `json:"event_id,omitempty"` 77 | } 78 | 79 | // NewContent holds information about an updated message event 80 | type NewContent struct { 81 | Body string `json:"body"` 82 | FormattedBody string `json:"formatted_body"` 83 | MsgType MsgType `json:"msgtype"` 84 | Format MessageFormat `json:"format"` 85 | } 86 | 87 | // SendNotification sends a notification to a given user. 88 | func (d *Dispatcher) SendNotification(a *model.Application, n *model.Notification) (eventID string, err error) { 89 | log.L.Printf("Sending notification to room %s.", a.MatrixID) 90 | 91 | plainMessage := strings.TrimSpace(n.Message) 92 | plainTitle := strings.TrimSpace(n.Title) 93 | message := d.getFormattedMessage(n) 94 | title := d.getFormattedTitle(n) // Does not append

anymore 95 | 96 | text := fmt.Sprintf("%s\n\n%s", plainTitle, plainMessage) 97 | formattedText := fmt.Sprintf("%s

%s", title, message) // Append

here 98 | 99 | messageEvent := &MessageEvent{ 100 | Body: text, 101 | FormattedBody: formattedText, 102 | MsgType: MsgTypeText, 103 | Format: MessageFormatHTML, 104 | } 105 | 106 | evt, err := d.mautrixClient.SendMessageEvent(context.Background(), mId.RoomID(a.MatrixID), event.EventMessage, &messageEvent) 107 | if err != nil { 108 | log.L.Errorln(err) 109 | return "", err 110 | } 111 | 112 | return evt.EventID.String(), nil 113 | } 114 | 115 | // DeleteNotification sends a notification to a given user that another notification is deleted 116 | func (d *Dispatcher) DeleteNotification(a *model.Application, n *model.DeleteNotification) error { 117 | log.L.Printf("Sending delete notification to room %s", a.MatrixID) 118 | var oldFormattedBody string 119 | var oldBody string 120 | 121 | // Get the message we want to delete 122 | deleteMessage, err := d.getMessage(a, n.ID) 123 | if err != nil { 124 | log.L.Println(err) 125 | return pberrors.ErrMessageNotFound 126 | } 127 | 128 | oldBody, oldFormattedBody, err = bodiesFromMessage(deleteMessage) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // Update the message with strikethrough 134 | newBody := fmt.Sprintf("%s\n- deleted", oldBody) 135 | newFormattedBody := fmt.Sprintf("%s
- deleted", oldFormattedBody) 136 | 137 | _, err = d.replaceMessage(a, newBody, newFormattedBody, deleteMessage.ID.String(), oldBody, oldFormattedBody) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | _, err = d.respondToMessage(a, "This message got deleted", "This message got deleted.", deleteMessage) 143 | 144 | return err 145 | } 146 | 147 | // HTML-formats the title 148 | func (d *Dispatcher) getFormattedTitle(n *model.Notification) string { 149 | trimmedTitle := strings.TrimSpace(n.Title) 150 | var title string 151 | 152 | contentType := getContentType(n.Extras) 153 | 154 | switch contentType { 155 | case contentTypeMarkdown: 156 | title = string(markdown.ToHTML([]byte(trimmedTitle), nil, nil)) 157 | case contentTypeHTML: 158 | title = trimmedTitle 159 | case contentTypePlain: 160 | title = html.EscapeString(trimmedTitle) 161 | title = "" + title + "" 162 | } 163 | 164 | if d.formatting.ColoredTitle { 165 | title = d.coloredText(d.priorityToColor(n.Priority), title) 166 | } 167 | 168 | return title 169 | } 170 | 171 | // Converts different syntaxes to a HTML-formatted message 172 | func (d *Dispatcher) getFormattedMessage(n *model.Notification) string { 173 | trimmedMessage := strings.TrimSpace(n.Message) 174 | var message string 175 | 176 | contentType := getContentType(n.Extras) 177 | 178 | switch contentType { 179 | case contentTypeMarkdown: 180 | message = string(markdown.ToHTML([]byte(trimmedMessage), nil, nil)) 181 | case contentTypeHTML: 182 | message = trimmedMessage 183 | case contentTypePlain: 184 | message = strings.ReplaceAll(html.EscapeString(trimmedMessage), "\n", "
") 185 | } 186 | 187 | return message 188 | } 189 | 190 | // Maps priorities to hex colors 191 | func (d *Dispatcher) priorityToColor(prio int) string { 192 | switch { 193 | case prio < 0: 194 | return "#828282" 195 | case prio <= 3: // info - default color 196 | return "" 197 | case prio <= 10: // low - yellow 198 | return "#edd711" 199 | case prio <= 20: // mid - orange 200 | return "#ed6d11" 201 | case prio > 20: // high - red 202 | return "#ed1f11" 203 | } 204 | 205 | return "" 206 | } 207 | 208 | // Maps a priority to a color tag 209 | func (d *Dispatcher) coloredText(color string, text string) string { 210 | if color == "" { 211 | return text 212 | } 213 | 214 | return "" + text + "" 215 | } 216 | 217 | // Searches in the messages list for the given id 218 | func (d *Dispatcher) getMessage(a *model.Application, id string) (*event.Event, error) { 219 | start := "" 220 | end := "" 221 | maxPages := 10 // Maximum pages to request (10 messages per page) 222 | 223 | for i := 0; i < maxPages; i++ { 224 | messages, err := d.mautrixClient.Messages(context.Background(), mId.RoomID(a.MatrixID), start, end, 'b', nil, 10) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | for _, event := range messages.Chunk { 230 | if event.ID.String() == id { 231 | return event, nil 232 | } 233 | } 234 | start = messages.End 235 | } 236 | 237 | return nil, pberrors.ErrMessageNotFound 238 | } 239 | 240 | // Replaces the content of a matrix message 241 | func (d *Dispatcher) replaceMessage(a *model.Application, newBody, newFormattedBody string, messageID string, oldBody, oldFormattedBody string) (*mautrix.RespSendEvent, error) { 242 | newMessage := NewContent{ 243 | Body: newBody, 244 | FormattedBody: newFormattedBody, 245 | MsgType: MsgTypeText, 246 | Format: MessageFormatHTML, 247 | } 248 | 249 | replaceRelation := RelatesTo{ 250 | RelType: "m.replace", 251 | EventID: messageID, 252 | } 253 | 254 | replaceEvent := MessageEvent{ 255 | Body: oldBody, 256 | FormattedBody: oldFormattedBody, 257 | MsgType: MsgTypeText, 258 | NewContent: &newMessage, 259 | RelatesTo: &replaceRelation, 260 | Format: MessageFormatHTML, 261 | } 262 | 263 | sendEvent, err := d.mautrixClient.SendMessageEvent(context.Background(), mId.RoomID(a.MatrixID), event.EventMessage, &replaceEvent) 264 | if err != nil { 265 | log.L.Errorln(err) 266 | return nil, err 267 | } 268 | 269 | return sendEvent, nil 270 | } 271 | 272 | // Sends a notification in response to another matrix message event 273 | func (d *Dispatcher) respondToMessage(a *model.Application, body, formattedBody string, respondMessage *event.Event) (*mautrix.RespSendEvent, error) { 274 | oldBody, oldFormattedBody, err := bodiesFromMessage(respondMessage) 275 | if err != nil { 276 | return nil, err 277 | } 278 | 279 | // Formatting according to https://matrix.org/docs/spec/client_server/latest#fallbacks-and-event-representation 280 | newFormattedBody := fmt.Sprintf("
In reply to %s
%s
\n
%s", respondMessage.RoomID, respondMessage.ID, respondMessage.Sender, respondMessage.Sender, oldFormattedBody, formattedBody) 281 | newBody := fmt.Sprintf("> <%s>%s\n\n%s", respondMessage.Sender, oldBody, body) 282 | 283 | notificationEvent := MessageEvent{ 284 | FormattedBody: newFormattedBody, 285 | Body: newBody, 286 | MsgType: MsgTypeText, 287 | Format: MessageFormatHTML, 288 | } 289 | 290 | notificationReply := make(map[string]string) 291 | notificationReply["event_id"] = respondMessage.ID.String() 292 | 293 | notificationRelation := RelatesTo{ 294 | InReplyTo: notificationReply, 295 | } 296 | notificationEvent.RelatesTo = ¬ificationRelation 297 | 298 | sendEvent, err := d.mautrixClient.SendMessageEvent(context.Background(), mId.RoomID(a.MatrixID), event.EventMessage, ¬ificationEvent) 299 | if err != nil { 300 | log.L.Errorln(err) 301 | return nil, err 302 | } 303 | 304 | return sendEvent, nil 305 | } 306 | 307 | // Extracts body and formatted body from a matrix message event 308 | func bodiesFromMessage(message *event.Event) (body, formattedBody string, err error) { 309 | msgContent := message.Content.AsMessage() 310 | if msgContent == nil { 311 | return "", "", pberrors.ErrMessageNotFound 312 | } 313 | 314 | formattedBody = msgContent.Body 315 | if msgContent.FormattedBody != "" { 316 | formattedBody = msgContent.FormattedBody 317 | } 318 | 319 | return msgContent.Body, formattedBody, nil 320 | } 321 | -------------------------------------------------------------------------------- /internal/api/application_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/pushbits/server/internal/model" 13 | "github.com/pushbits/server/tests" 14 | ) 15 | 16 | // Collect all created applications to check & delete them later 17 | var SuccessApplications = make(map[uint][]model.Application) 18 | 19 | func TestApi_RegisterApplicationWithoutUser(t *testing.T) { 20 | ctx := GetTestContext(t) 21 | 22 | assert := assert.New(t) 23 | 24 | reqWoUser := tests.Request{Name: "Invalid JSON Data", Method: "POST", Endpoint: "/application", Data: `{"name": "test1", "strict_compatibility": true}`, Headers: map[string]string{"Content-Type": "application/json"}} 25 | _, c, err := reqWoUser.GetRequest() 26 | if err != nil { 27 | t.Fatal(err.Error()) 28 | } 29 | 30 | assert.Panicsf(func() { ctx.ApplicationHandler.CreateApplication(c) }, "CreateApplication did not panic although user is not in context") 31 | } 32 | 33 | func TestApi_RegisterApplication(t *testing.T) { 34 | ctx := GetTestContext(t) 35 | 36 | assert := assert.New(t) 37 | require := require.New(t) 38 | 39 | testCases := make([]tests.Request, 0) 40 | testCases = append(testCases, tests.Request{Name: "Invalid Form Data", Method: "POST", Endpoint: "/application", Data: "k=1&v=abc", ShouldStatus: 400}) 41 | testCases = append(testCases, tests.Request{Name: "Invalid JSON Data", Method: "POST", Endpoint: "/application", Data: `{"name": "test1", "strict_compatibility": "oh yes"}`, Headers: map[string]string{"Content-Type": "application/json"}, ShouldStatus: 400}) 42 | testCases = append(testCases, tests.Request{Name: "Valid JSON Data", Method: "POST", Endpoint: "/application", Data: `{"name": "test2", "strict_compatibility": true}`, Headers: map[string]string{"Content-Type": "application/json"}, ShouldStatus: 200}) 43 | 44 | for _, user := range ctx.Users { 45 | SuccessApplications[user.ID] = make([]model.Application, 0) 46 | for _, req := range testCases { 47 | var application model.Application 48 | w, c, err := req.GetRequest() 49 | if err != nil { 50 | t.Fatal(err.Error()) 51 | } 52 | 53 | c.Set("user", user) 54 | ctx.ApplicationHandler.CreateApplication(c) 55 | 56 | // Parse body only for successful requests 57 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 58 | body, err := io.ReadAll(w.Body) 59 | require.NoErrorf(err, "Cannot read request body") 60 | err = json.Unmarshal(body, &application) 61 | require.NoErrorf(err, "Cannot unmarshal request body") 62 | 63 | SuccessApplications[user.ID] = append(SuccessApplications[user.ID], application) 64 | } 65 | 66 | assert.Equalf(w.Code, req.ShouldStatus, "CreateApplication (Test case: \"%s\") Expected status code %v but received %v.", req.Name, req.ShouldStatus, w.Code) 67 | } 68 | } 69 | } 70 | 71 | func TestApi_GetApplications(t *testing.T) { 72 | ctx := GetTestContext(t) 73 | 74 | var applications []model.Application 75 | 76 | assert := assert.New(t) 77 | require := require.New(t) 78 | 79 | testCases := make([]tests.Request, 0) 80 | testCases = append(testCases, tests.Request{Name: "Valid Request", Method: "GET", Endpoint: "/application", ShouldStatus: 200}) 81 | 82 | for _, user := range ctx.Users { 83 | for _, req := range testCases { 84 | w, c, err := req.GetRequest() 85 | if err != nil { 86 | t.Fatal(err.Error()) 87 | } 88 | 89 | c.Set("user", user) 90 | ctx.ApplicationHandler.GetApplications(c) 91 | 92 | // Parse body only for successful requests 93 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 94 | body, err := io.ReadAll(w.Body) 95 | require.NoErrorf(err, "Cannot read request body") 96 | err = json.Unmarshal(body, &applications) 97 | require.NoErrorf(err, "Cannot unmarshal request body") 98 | if err != nil { 99 | continue 100 | } 101 | 102 | assert.Truef(validateAllApplications(user, applications), "Did not find application created previously") 103 | assert.Equalf(len(applications), len(SuccessApplications[user.ID]), "Created %d application(s) but got %d back", len(SuccessApplications[user.ID]), len(applications)) 104 | } 105 | 106 | assert.Equalf(w.Code, req.ShouldStatus, "GetApplications (Test case: \"%s\") Expected status code %v but received %v.", req.Name, req.ShouldStatus, w.Code) 107 | } 108 | } 109 | } 110 | 111 | func TestApi_GetApplicationsWithoutUser(t *testing.T) { 112 | ctx := GetTestContext(t) 113 | 114 | assert := assert.New(t) 115 | 116 | testCase := tests.Request{Name: "Valid Request", Method: "GET", Endpoint: "/application"} 117 | 118 | _, c, err := testCase.GetRequest() 119 | if err != nil { 120 | t.Fatal(err.Error()) 121 | } 122 | 123 | assert.Panicsf(func() { ctx.ApplicationHandler.GetApplications(c) }, "GetApplications did not panic although user is not in context") 124 | } 125 | 126 | func TestApi_GetApplicationErrors(t *testing.T) { 127 | ctx := GetTestContext(t) 128 | 129 | assert := assert.New(t) 130 | 131 | // Arbitrary test cases 132 | testCases := make(map[uint]tests.Request) 133 | testCases[0] = tests.Request{Name: "Requesting unknown application 0", Method: "GET", Endpoint: "/application/0", ShouldStatus: 404} 134 | testCases[5555] = tests.Request{Name: "Requesting unknown application 5555", Method: "GET", Endpoint: "/application/5555", ShouldStatus: 404} 135 | testCases[99999999999999999] = tests.Request{Name: "Requesting unknown application 99999999999999999", Method: "GET", Endpoint: "/application/99999999999999999", ShouldStatus: 404} 136 | 137 | for _, user := range ctx.Users { 138 | for id, req := range testCases { 139 | w, c, err := req.GetRequest() 140 | if err != nil { 141 | t.Fatal(err.Error()) 142 | } 143 | 144 | c.Set("user", user) 145 | c.Set("id", id) 146 | ctx.ApplicationHandler.GetApplication(c) 147 | 148 | assert.Equalf(w.Code, req.ShouldStatus, "GetApplication (Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 149 | } 150 | } 151 | } 152 | 153 | func TestApi_GetApplication(t *testing.T) { 154 | ctx := GetTestContext(t) 155 | 156 | var application model.Application 157 | 158 | assert := assert.New(t) 159 | require := require.New(t) 160 | 161 | // Previously generated applications 162 | for _, user := range ctx.Users { 163 | for _, app := range SuccessApplications[user.ID] { 164 | req := tests.Request{Name: fmt.Sprintf("Requesting application %s (%d)", app.Name, app.ID), Method: "GET", Endpoint: fmt.Sprintf("/application/%d", app.ID), ShouldStatus: 200} 165 | 166 | w, c, err := req.GetRequest() 167 | if err != nil { 168 | t.Fatal(err.Error()) 169 | } 170 | 171 | c.Set("user", user) 172 | c.Set("id", app.ID) 173 | ctx.ApplicationHandler.GetApplication(c) 174 | 175 | // Parse body only for successful requests 176 | if req.ShouldStatus >= 200 && req.ShouldStatus < 300 { 177 | body, err := io.ReadAll(w.Body) 178 | require.NoErrorf(err, "Cannot read request body") 179 | err = json.Unmarshal(body, &application) 180 | require.NoErrorf(err, "Cannot unmarshal request body: %v", err) 181 | 182 | assert.Equalf(application.ID, app.ID, "Application ID should be %d but is %d", app.ID, application.ID) 183 | assert.Equalf(application.Name, app.Name, "Application Name should be %s but is %s", app.Name, application.Name) 184 | assert.Equalf(application.UserID, app.UserID, "Application user ID should be %d but is %d", app.UserID, application.UserID) 185 | 186 | } 187 | 188 | assert.Equalf(w.Code, req.ShouldStatus, "GetApplication (Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 189 | } 190 | } 191 | } 192 | 193 | func TestApi_UpdateApplication(t *testing.T) { 194 | ctx := GetTestContext(t) 195 | 196 | assert := assert.New(t) 197 | require := require.New(t) 198 | 199 | for _, user := range ctx.Users { 200 | testCases := make(map[uint]tests.Request) 201 | // Previously generated applications 202 | for _, app := range SuccessApplications[user.ID] { 203 | newName := app.Name + "-new_name" 204 | updateApp := model.UpdateApplication{ 205 | Name: &newName, 206 | } 207 | updateAppBytes, err := json.Marshal(updateApp) 208 | require.NoErrorf(err, "Error on marshaling updateApplication struct") 209 | 210 | // Valid 211 | testCases[app.ID] = tests.Request{Name: fmt.Sprintf("Update application (valid) %s (%d)", app.Name, app.ID), Method: "PUT", Endpoint: fmt.Sprintf("/application/%d", app.ID), ShouldStatus: 200, Data: string(updateAppBytes), Headers: map[string]string{"Content-Type": "application/json"}} 212 | // Invalid 213 | testCases[app.ID] = tests.Request{Name: fmt.Sprintf("Update application (invalid) %s (%d)", app.Name, app.ID), Method: "PUT", Endpoint: fmt.Sprintf("/application/%d", app.ID), ShouldStatus: 200, Data: "{}", Headers: map[string]string{"Content-Type": "application/json"}} 214 | } 215 | // Arbitrary test cases 216 | testCases[5555] = tests.Request{Name: "Update application 5555", Method: "PUT", Endpoint: "/application/5555", ShouldStatus: 404, Data: "random data"} 217 | testCases[5556] = tests.Request{Name: "Update application 5556", Method: "PUT", Endpoint: "/application/5556", ShouldStatus: 404, Data: `{"new_name": "new name"}`, Headers: map[string]string{"Content-Type": "application/json"}} 218 | 219 | for id, req := range testCases { 220 | w, c, err := req.GetRequest() 221 | if err != nil { 222 | t.Fatal(err.Error()) 223 | } 224 | 225 | c.Set("user", user) 226 | c.Set("id", id) 227 | ctx.ApplicationHandler.UpdateApplication(c) 228 | 229 | assert.Equalf(w.Code, req.ShouldStatus, "UpdateApplication (Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 230 | } 231 | } 232 | } 233 | 234 | func TestApi_DeleteApplication(t *testing.T) { 235 | ctx := GetTestContext(t) 236 | 237 | assert := assert.New(t) 238 | 239 | for _, user := range ctx.Users { 240 | testCases := make(map[uint]tests.Request) 241 | // Previously generated applications 242 | for _, app := range SuccessApplications[user.ID] { 243 | testCases[app.ID] = tests.Request{Name: fmt.Sprintf("Delete application %s (%d)", app.Name, app.ID), Method: "DELETE", Endpoint: fmt.Sprintf("/application/%d", app.ID), ShouldStatus: 200} 244 | } 245 | // Arbitrary test cases 246 | testCases[5555] = tests.Request{Name: "Delete application 5555", Method: "DELETE", Endpoint: "/application/5555", ShouldStatus: 404} 247 | 248 | for id, req := range testCases { 249 | w, c, err := req.GetRequest() 250 | if err != nil { 251 | t.Fatal(err.Error()) 252 | } 253 | 254 | c.Set("user", user) 255 | c.Set("id", id) 256 | ctx.ApplicationHandler.DeleteApplication(c) 257 | 258 | assert.Equalf(w.Code, req.ShouldStatus, "DeleteApplication (Test case: \"%s\") Expected status code %v but have %v.", req.Name, req.ShouldStatus, w.Code) 259 | } 260 | } 261 | } 262 | 263 | // True if all created applications are in list 264 | func validateAllApplications(user *model.User, apps []model.Application) bool { 265 | if _, ok := SuccessApplications[user.ID]; !ok { 266 | return len(apps) == 0 267 | } 268 | 269 | for _, successApp := range SuccessApplications[user.ID] { 270 | foundApp := false 271 | for _, app := range apps { 272 | if app.ID == successApp.ID { 273 | foundApp = true 274 | break 275 | } 276 | } 277 | 278 | if !foundApp { 279 | return false 280 | } 281 | } 282 | 283 | return true 284 | } 285 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 5 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 6 | github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= 7 | github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= 8 | github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= 9 | github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= 10 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= 11 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 12 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 13 | github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= 14 | github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 15 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 16 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 17 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 18 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 19 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 20 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 21 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 26 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 27 | github.com/gin-contrib/location v1.0.2 h1:FQ3bYuxtXjv/E8unfwfqdUXPms9JR64qBEul8TOw6IA= 28 | github.com/gin-contrib/location v1.0.2/go.mod h1:g+5CKBkpOHL+PkrH2j6wK1u46MTOKZvBM27Vg4/IFuc= 29 | github.com/gin-contrib/location v1.0.3 h1:iy5FY2JsunZ73Lnq8YZsx7wkGFY1xcyRdKiRh/8Uptg= 30 | github.com/gin-contrib/location v1.0.3/go.mod h1:fMoqRQxX0d5ycvxzP7e5VtqfID00RPb4jMGDh3oT0pk= 31 | github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= 32 | github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= 33 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 34 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 35 | github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= 36 | github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 37 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 38 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 39 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 40 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 41 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 42 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 43 | github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= 44 | github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 45 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= 46 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 47 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 48 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 49 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 50 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 51 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 52 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 53 | github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ= 54 | github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 55 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 56 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 57 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 58 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 59 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 60 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 61 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 62 | github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= 63 | github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 64 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 65 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 66 | github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= 67 | github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= 68 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 69 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 70 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 71 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 72 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 73 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 74 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 75 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 76 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 77 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 78 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 79 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 80 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 81 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 82 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 83 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 84 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 85 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 86 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 87 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 88 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 89 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 90 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 91 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 92 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 93 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 94 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 95 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 96 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 97 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 98 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 99 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 100 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 101 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 102 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 103 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 104 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 105 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 106 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 107 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 108 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 109 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 110 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 111 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 112 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 113 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 114 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 115 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 116 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 117 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 118 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 119 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 120 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 121 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 122 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 123 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 124 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 125 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 126 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 127 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 128 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 129 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 130 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 131 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 132 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 133 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 134 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 135 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 136 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 137 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 138 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 139 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 140 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 141 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 142 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 143 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 144 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 145 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 146 | go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ= 147 | go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= 148 | go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= 149 | go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= 150 | golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= 151 | golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 152 | golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= 153 | golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 154 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 155 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 156 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 157 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 158 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 159 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 160 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 161 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= 162 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= 163 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 164 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 165 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 166 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 167 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 168 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 169 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 170 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 171 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 172 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 173 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 174 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 175 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 176 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 178 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 179 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 180 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 181 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 182 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 183 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 184 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 185 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 186 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 187 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 188 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 189 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 190 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 191 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 196 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 198 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 199 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 200 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 201 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 202 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 203 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 204 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 205 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 206 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 207 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 208 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 209 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 210 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 211 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 212 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 213 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 214 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 215 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 216 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 217 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 218 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 219 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 220 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 221 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 222 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 223 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 224 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 225 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 226 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 227 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 228 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 229 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 230 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 231 | gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= 232 | gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= 233 | gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= 234 | gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 235 | gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= 236 | gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 237 | gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= 238 | gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 239 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 240 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 241 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 242 | maunium.net/go/mautrix v0.23.0 h1:HNlR19eew5lvrNSL2muhExaGhYdaGk5FfEiA82QqUP4= 243 | maunium.net/go/mautrix v0.23.0/go.mod h1:AGnnaz3ylGikUo1I1MJVn9QLsl2No1/ZNnGDyO0QD5s= 244 | maunium.net/go/mautrix v0.24.0 h1:kBeyWhgL1W8/d8BEFlBSlgIpItPgP1l37hzF8cN3R70= 245 | maunium.net/go/mautrix v0.24.0/go.mod h1:HqA1HUutQYJkrYRPkK64itARDz79PCec1oWVEB72HVQ= 246 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 247 | --------------------------------------------------------------------------------