├── .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 |
7 |
8 | 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 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
In reply to %s\n
%s