diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93e6fc3..f46a74b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,25 +1,34 @@ name: Main -on: push +on: [push, pull_request] jobs: - test_publish: - name: Test and publish + test_build_publish: + name: Test, build, and publish runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v2 - name: Install dependencies run: make setup + - name: Export GOBIN + uses: actions/setup-go@v2 + with: + go-version: 1.16 - name: Run tests - run: | - export PATH="$PATH:$(go env GOPATH)/bin" # Currently the path needs to be set manually. - make test + run: make test - name: Build image run: make build_image + - name: Get Branch # Needed to evaluate env.BRANCH. + if: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} # Otherwise will fail on pull requests. + run: | + raw=$(git branch -r --contains ${{ github.ref }}) + branch=${raw##*/} + echo "BRANCH=$branch" >> $GITHUB_ENV - name: Login to Docker Hub + if: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' && env.BRANCH == 'master' }} # Only login for tagged commits pushed to master. uses: docker/login-action@v1 - with: + with: # Secrets are not exposed to pull request contexts. username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Publish image - if: startsWith(github.ref, 'refs/tags/') # Only publish for tagged commits. - run: | - make push_image + if: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' && env.BRANCH == 'master' }} # Only publish for tagged commits pushed to master. + run: make push_image diff --git a/.gitignore b/.gitignore index 4471252..f5f8b98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -app +out/ *.db config.yml diff --git a/Dockerfile b/Dockerfile index 3f22476..4dfd8dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN set -ex \ && go mod download \ && go mod verify \ && make build \ - && chmod +x /build/app + && chmod +x /build/out/pushbits FROM alpine @@ -22,7 +22,7 @@ EXPOSE 8080 WORKDIR /app COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=builder /build/app ./run +COPY --from=builder /build/out/pushbits ./run RUN set -ex \ && apk add --no-cache ca-certificates curl \ diff --git a/Makefile b/Makefile index 56aeeed..a45cbe8 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ IMAGE := eikendev/pushbits .PHONY: build build: - go build -ldflags="-w -s" -o app ./cmd/pushbits + mkdir -p ./out + go build -ldflags="-w -s" -o ./out/pushbits ./cmd/pushbits .PHONY: test test: @@ -12,16 +13,13 @@ test: fi go vet ./... gocyclo -over 10 $(shell find . -iname '*.go' -type f) + staticcheck ./... go test -v -cover ./... - stdout=$$(golint ./... 2>&1); \ - if [ "$$stdout" ]; then \ - exit 1; \ - fi .PHONY: setup setup: go get -u github.com/fzipp/gocyclo/cmd/gocyclo - go get -u golang.org/x/lint/golint + go get -u honnef.co/go/tools/cmd/staticcheck .PHONY: build_image build_image: diff --git a/README.md b/README.md index d72ead4..48b7570 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,22 @@ -[![Build status](https://img.shields.io/github/workflow/status/pushbits/server/Main)](https://github.com/pushbits/server/actions) -[![Docker Hub pulls](https://img.shields.io/docker/pulls/eikendev/pushbits)](https://hub.docker.com/r/eikendev/pushbits) -[![Image size](https://img.shields.io/docker/image-size/eikendev/pushbits)](https://hub.docker.com/r/eikendev/pushbits) -![License](https://img.shields.io/github/license/pushbits/server) - -# PushBits - | :exclamation: **This software is currently in alpha phase.** | |-----------------------------------------------------------------| -## About +
+

PushBits

+

+ Receive your important notifications immediately, over Matrix. +

+

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

+
+ +

+ Build status  + Downloads  + Image size  + License  +

+ +## 💡 About PushBits is a relay server for push notifications. It enables you to send notifications via a simple web API, and delivers them to you through [Matrix](https://matrix.org/). @@ -32,7 +40,7 @@ I myself started using Matrix only for this project. The idea of a federated, synchronized but yet end-to-end encrypted protocol is awesome, but its clients simply aren't really there yet. Still, if you haven't tried it yet, I suggest you to check it out. -### Features +## 🤘 Features - [x] Multiple users and multiple channels (applications) per user - [x] Compatibility with Gotify's API for sending messages @@ -42,7 +50,7 @@ Still, if you haven't tried it yet, I suggest you to check it out. - [ ] Two-factor authentication, [issue](https://github.com/pushbits/server/issues/19) - [ ] Bi-directional key verification, [issue](https://github.com/pushbits/server/issues/20) -## Installation +## 🚀 Installation PushBits is meant to be self-hosted. That means you have to install it on your own server. @@ -53,7 +61,7 @@ The image is hosted [here on Docker Hub](https://hub.docker.com/r/eikendev/pushb | :warning: **You are advised to install PushBits behind a reverse proxy and enable TLS.** Otherwise, your credentials will be transmitted unencrypted. | |----------------------------------------------------------------------------------------------------------------------------------------------------------| -## Configuration +## âš™ Configuration To see what can be configured, have a look at the `config.sample.yml` file inside the root of the repository. @@ -87,7 +95,7 @@ In this example, the configuration file would be located at `./data/config.yml` The SQLite database would be written to `./data/pushbits.db`. **Don't forget to adjust the permissions** of the `./data` directory, otherwise PushBits will fail to operate. -## Usage +## 📄 Usage Now, how can you interact with the server? I wrote [a little CLI tool called pbcli](https://github.com/PushBits/cli) to make basic API requests to the server. @@ -134,12 +142,24 @@ curl \ HTML-Content might not be fully rendered in your Matrix-Client - see the corresponding [Matrix specs](https://spec.matrix.org/unstable/client-server-api/#mroommessage-msgtypes). This also holds for Markdown, as it is transfered to the corresponding HTML-syntax. -## Acknowledgments +### Deleting a Message + +You can delete a message, this will send a notification in response to the original message informing you that the message is "deleted". + +You need the message ID for deleting a message. As it might contain characters not valid in uris we provide an additional `id_url_encoded` field for messages, use that value for deleting a message. + +```bash +curl \ + --request DELETE \ + "https://pushbits.example.com/message/${MESSAGE_ID}?token=$PB_TOKEN" +``` + +## 👮 Acknowledgments The idea for this software and most parts of the initial source are heavily inspired by [Gotify](https://gotify.net/). Many thanks to [jmattheis](https://jmattheis.de/) for his well-structured code. -## Development +## 💻 Development The source code is located on [GitHub](https://github.com/pushbits/server). You can retrieve it by checking out the repository as follows. diff --git a/cmd/pushbits/main.go b/cmd/pushbits/main.go index 424816a..f2965e6 100644 --- a/cmd/pushbits/main.go +++ b/cmd/pushbits/main.go @@ -47,7 +47,7 @@ func main() { log.Fatal(err) } - dp, err := dispatcher.Create(db, c.Matrix.Homeserver, c.Matrix.Username, c.Matrix.Password, c.Formatting) + dp, err := dispatcher.Create(c.Matrix.Homeserver, c.Matrix.Username, c.Matrix.Password, c.Formatting) if err != nil { log.Fatal(err) } diff --git a/go.mod b/go.mod index e5c9e72..28b463c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( github.com/alexedwards/argon2id v0.0.0-20201228115903-cf543ebc1f7b + github.com/fzipp/gocyclo v0.3.1 // indirect github.com/gin-contrib/location v0.0.2 github.com/gin-gonic/gin v1.6.3 github.com/go-playground/validator/v10 v10.3.0 // indirect @@ -18,9 +19,11 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/ugorji/go v1.2.4 // indirect - golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect + golang.org/x/sys v0.0.0-20210608053332-aa57babbf139 // indirect + golang.org/x/tools v0.1.3 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gorm.io/driver/mysql v1.0.4 gorm.io/driver/sqlite v1.1.4 gorm.io/gorm v1.20.12 + honnef.co/go/tools v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index c30c298..35c2598 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/alexedwards/argon2id v0.0.0-20201228115903-cf543ebc1f7b/go.mod h1:Kmn github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fzipp/gocyclo v0.3.1 h1:A9UeX3HJSXTBzvHzhqoYVuE0eAhe+aM8XBCCwsPMZOc= +github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E= github.com/gin-contrib/location v0.0.2 h1:QZKh1+K/LLR4KG/61eIO3b7MLuKi8tytQhV6texLgP4= github.com/gin-contrib/location v0.0.2/go.mod h1:NGoidiRlf0BlA/VKSVp+g3cuSMeTmip/63PhEjRhUAc= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -85,22 +87,55 @@ github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.4 h1:C5VurWRRCKjuENsbM6GYVw8W++WVW9rSxoACKIvxzz8= github.com/ugorji/go/codec v1.2.4/go.mod h1:bWBu1+kIRWcF8uMklKaJrR6fTWQOwAlrIzX22pHwryA= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210608053332-aa57babbf139 h1:C+AwYEtBp/VQwoLntUmQ/yx3MS9vmZaKNdw5eOpoQe8= +golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.3 h1:L69ShwSZEyCsLKoAxDKeMvLDZkumEe8gXUZAjab0tX8= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -124,3 +159,5 @@ gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9D gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= gorm.io/gorm v1.20.12 h1:ebZ5KrSHzet+sqOCVdH9mTjW91L298nX3v5lVxAzSUY= gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +honnef.co/go/tools v0.2.0 h1:ws8AfbgTX3oIczLPNPCu5166oBg9ST2vNs0rcht+mDE= +honnef.co/go/tools v0.2.0/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY= diff --git a/internal/api/context.go b/internal/api/context.go index c408515..90a5ec8 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -20,6 +20,17 @@ func getID(ctx *gin.Context) (uint, error) { return id, nil } +func getMessageID(ctx *gin.Context) (string, error) { + id, ok := ctx.MustGet("messageid").(string) + if !ok { + err := errors.New("an error occured while retrieving messageID from context") + ctx.AbortWithError(http.StatusInternalServerError, err) + return "", err + } + + return id, nil +} + func getApplication(ctx *gin.Context, db Database) (*model.Application, error) { id, err := getID(ctx) if err != nil { diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..64c744a --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,5 @@ +package api + +import "errors" + +var ErrorMessageNotFound = errors.New("message not found") diff --git a/internal/api/middleware.go b/internal/api/middleware.go index a6507e1..3d84840 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -8,6 +8,10 @@ type idInURI struct { ID uint `uri:"id" binding:"required"` } +type messageIdInURI struct { + MessageID string `uri:"messageid" binding:"required"` +} + // RequireIDInURI returns a Gin middleware which requires an ID to be supplied in the URI of the request. func RequireIDInURI() gin.HandlerFunc { return func(ctx *gin.Context) { @@ -20,3 +24,16 @@ func RequireIDInURI() gin.HandlerFunc { ctx.Set("id", requestModel.ID) } } + +// RequireMessageIDInURI returns a Gin middleware which requires an messageID to be supplied in the URI of the request. +func RequireMessageIDInURI() gin.HandlerFunc { + return func(ctx *gin.Context) { + var requestModel messageIdInURI + + if err := ctx.BindUri(&requestModel); err != nil { + return + } + + ctx.Set("messageid", requestModel.MessageID) + } +} diff --git a/internal/api/notification.go b/internal/api/notification.go index bfb8af0..064c716 100644 --- a/internal/api/notification.go +++ b/internal/api/notification.go @@ -3,6 +3,7 @@ package api import ( "log" "net/http" + "net/url" "strings" "time" @@ -18,7 +19,8 @@ type NotificationDatabase interface { // The NotificationDispatcher interface for relaying notifications. type NotificationDispatcher interface { - SendNotification(a *model.Application, n *model.Notification) error + SendNotification(a *model.Application, n *model.Notification) (id string, err error) + DeleteNotification(a *model.Application, n *model.DeleteNotification) error } // NotificationHandler holds information for processing requests about notifications. @@ -38,16 +40,41 @@ func (h *NotificationHandler) CreateNotification(ctx *gin.Context) { application := authentication.GetApplication(ctx) log.Printf("Sending notification for application %s.", application.Name) - notification.ID = 0 notification.ApplicationID = application.ID if strings.TrimSpace(notification.Title) == "" { notification.Title = application.Name } notification.Date = time.Now() - if success := successOrAbort(ctx, http.StatusInternalServerError, h.DP.SendNotification(application, ¬ification)); !success { + messageID, err := h.DP.SendNotification(application, ¬ification) + + if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success { return } + notification.ID = messageID + notification.UrlEncodedID = url.QueryEscape(messageID) + ctx.JSON(http.StatusOK, ¬ification) } + +// DeleteNotification is used to delete (or mark as deleted) a notification for a user +func (h *NotificationHandler) DeleteNotification(ctx *gin.Context) { + application := authentication.GetApplication(ctx) + id, err := getMessageID(ctx) + + if success := successOrAbort(ctx, http.StatusUnprocessableEntity, err); !success { + return + } + + n := model.DeleteNotification{ + ID: id, + Date: time.Now(), + } + + if success := successOrAbort(ctx, http.StatusInternalServerError, h.DP.DeleteNotification(application, &n)); !success { + return + } + + ctx.Status(http.StatusOK) +} diff --git a/internal/api/util.go b/internal/api/util.go index 177f537..d045139 100644 --- a/internal/api/util.go +++ b/internal/api/util.go @@ -11,7 +11,13 @@ import ( func successOrAbort(ctx *gin.Context, code int, err error) bool { if err != nil { - ctx.AbortWithError(code, err) + // If we know the error force error code + switch err { + case ErrorMessageNotFound: + ctx.AbortWithError(http.StatusNotFound, err) + default: + ctx.AbortWithError(code, err) + } } return err == nil diff --git a/internal/authentication/credentials/password.go b/internal/authentication/credentials/password.go index 729838a..f5ab29c 100644 --- a/internal/authentication/credentials/password.go +++ b/internal/authentication/credentials/password.go @@ -14,7 +14,7 @@ func (m *Manager) CreatePasswordHash(password string) ([]byte, error) { if err != nil { return []byte{}, errors.New("HIBP is not available, please wait until service is available again") } else if pwned { - return []byte{}, errors.New("Password is pwned, please choose another one") + return []byte{}, errors.New("password is pwned, please choose another one") } } diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go index a9128ff..735c072 100644 --- a/internal/dispatcher/dispatcher.go +++ b/internal/dispatcher/dispatcher.go @@ -11,19 +11,14 @@ var ( loginType = "m.login.password" ) -// The Database interface for encapsulating database access. -type Database interface { -} - // Dispatcher holds information for sending notifications to clients. type Dispatcher struct { - db Database client *gomatrix.Client formatting configuration.Formatting } // Create instanciates a dispatcher connection. -func Create(db Database, homeserver, username, password string, formatting configuration.Formatting) (*Dispatcher, error) { +func Create(homeserver, username, password string, formatting configuration.Formatting) (*Dispatcher, error) { log.Println("Setting up dispatcher.") client, err := gomatrix.NewClient(homeserver, "", "") diff --git a/internal/dispatcher/notification.go b/internal/dispatcher/notification.go index 2a59a6a..9fce7a0 100644 --- a/internal/dispatcher/notification.go +++ b/internal/dispatcher/notification.go @@ -7,11 +7,50 @@ import ( "strings" "github.com/gomarkdown/markdown" + "github.com/matrix-org/gomatrix" + "github.com/pushbits/server/internal/api" "github.com/pushbits/server/internal/model" ) +// MessageFormat is a matrix message format +type MessageFormat string + +// MsgType is a matrix msgtype +type MsgType string + +// Define matrix constants +const ( + MessageFormatHTML = MessageFormat("org.matrix.custom.html") + MsgTypeText = MsgType("m.text") +) + +// MessageEvent is the content of a matrix message event +type MessageEvent struct { + Body string `json:"body"` + FormattedBody string `json:"formatted_body"` + MsgType MsgType `json:"msgtype"` + RelatesTo RelatesTo `json:"m.relates_to,omitempty"` + Format MessageFormat `json:"format"` + NewContent NewContent `json:"m.new_content,omitempty"` +} + +// RelatesTo holds information about relations to other message events +type RelatesTo struct { + InReplyTo map[string]string `json:"m.in_reply_to,omitempty"` + RelType string `json:"rel_type,omitempty"` + EventID string `json:"event_id,omitempty"` +} + +// NewContent holds information about an updated message event +type NewContent struct { + Body string `json:"body"` + FormattedBody string `json:"formatted_body"` + MsgType MsgType `json:"msgtype"` + Format MessageFormat `json:"format"` +} + // SendNotification sends a notification to the specified user. -func (d *Dispatcher) SendNotification(a *model.Application, n *model.Notification) error { +func (d *Dispatcher) SendNotification(a *model.Application, n *model.Notification) (id string, err error) { log.Printf("Sending notification to room %s.", a.MatrixID) plainMessage := strings.TrimSpace(n.Message) @@ -22,7 +61,42 @@ func (d *Dispatcher) SendNotification(a *model.Application, n *model.Notificatio text := fmt.Sprintf("%s\n\n%s", plainTitle, plainMessage) formattedText := fmt.Sprintf("%s %s", title, message) - _, err := d.client.SendFormattedText(a.MatrixID, text, formattedText) + respSendEvent, err := d.client.SendFormattedText(a.MatrixID, text, formattedText) + + return respSendEvent.EventID, err +} + +// DeleteNotification sends a notification to the specified user that another notificaion is deleted +func (d *Dispatcher) DeleteNotification(a *model.Application, n *model.DeleteNotification) error { + log.Printf("Sending delete notification to room %s", a.MatrixID) + var oldFormattedBody string + var oldBody string + + // get the message we want to delete + deleteMessage, err := d.getMessage(a, n.ID) + + if err != nil { + log.Println(err) + return api.ErrorMessageNotFound + } + + oldBody, oldFormattedBody, err = bodiesFromMessage(deleteMessage) + + if err != nil { + return err + } + + // update the message with strikethrough + newBody := fmt.Sprintf("%s\n- deleted", oldBody) + newFormattedBody := fmt.Sprintf("%s
- deleted", oldFormattedBody) + + _, err = d.replaceMessage(a, newBody, newFormattedBody, deleteMessage.ID, oldBody, oldFormattedBody) + + if err != nil { + return err + } + + _, err = d.respondToMessage(a, "This message got deleted", "This message got deleted.", deleteMessage) return err } @@ -92,3 +166,111 @@ func (d *Dispatcher) coloredText(color string, text string) string { return "" + text + "" } + +// Searches in the messages list for the given id +func (d *Dispatcher) getMessage(a *model.Application, id string) (gomatrix.Event, error) { + start := "" + end := "" + maxPages := 10 // maximum pages to request (10 messages per page) + + for i := 0; i < maxPages; i++ { + messages, _ := d.client.Messages(a.MatrixID, start, end, 'b', 10) + for _, event := range messages.Chunk { + if event.ID == id { + return event, nil + } + } + start = messages.End + } + return gomatrix.Event{}, api.ErrorMessageNotFound +} + +// Replaces the content of a matrix message +func (d *Dispatcher) replaceMessage(a *model.Application, newBody, newFormattedBody string, messageID string, oldBody, oldFormattedBody string) (*gomatrix.RespSendEvent, error) { + newMessage := NewContent{ + Body: newBody, + FormattedBody: newFormattedBody, + MsgType: MsgTypeText, + Format: MessageFormatHTML, + } + + replaceRelation := RelatesTo{ + RelType: "m.replace", + EventID: messageID, + } + + replaceEvent := MessageEvent{ + Body: oldBody, + FormattedBody: oldFormattedBody, + MsgType: MsgTypeText, + NewContent: newMessage, + RelatesTo: replaceRelation, + Format: MessageFormatHTML, + } + + sendEvent, err := d.client.SendMessageEvent(a.MatrixID, "m.room.message", replaceEvent) + + if err != nil { + log.Println(err) + return nil, err + } + + return sendEvent, nil +} + +// Sends a notification in response to another matrix message event +func (d *Dispatcher) respondToMessage(a *model.Application, body, formattedBody string, respondMessage gomatrix.Event) (*gomatrix.RespSendEvent, error) { + oldBody, oldFormattedBody, err := bodiesFromMessage(respondMessage) + + if err != nil { + return nil, err + } + + // formatting according to https://matrix.org/docs/spec/client_server/latest#fallbacks-and-event-representation + newFormattedBody := fmt.Sprintf("
In reply to %s
%s
\n
%s", respondMessage.RoomID, respondMessage.ID, respondMessage.Sender, respondMessage.Sender, oldFormattedBody, formattedBody) + newBody := fmt.Sprintf("> <%s>%s\n\n%s", respondMessage.Sender, oldBody, body) + + notificationEvent := MessageEvent{ + FormattedBody: newFormattedBody, + Body: newBody, + MsgType: MsgTypeText, + Format: MessageFormatHTML, + } + + notificationReply := make(map[string]string) + notificationReply["event_id"] = respondMessage.ID + + notificationRelation := RelatesTo{ + InReplyTo: notificationReply, + } + notificationEvent.RelatesTo = notificationRelation + + return d.client.SendMessageEvent(a.MatrixID, "m.room.message", notificationEvent) +} + +// Extracts body and formatted body from a matrix message event +func bodiesFromMessage(message gomatrix.Event) (body, formattedBody string, err error) { + if val, ok := message.Content["body"]; ok { + body, ok := val.(string) + + if !ok { + return "", "", api.ErrorMessageNotFound + } + + formattedBody = body + + } else { + return "", "", api.ErrorMessageNotFound + } + + if val, ok := message.Content["formatted_body"]; ok { + body, ok := val.(string) + if !ok { + return "", "", api.ErrorMessageNotFound + } + + formattedBody = body + } + + return body, formattedBody, nil +} diff --git a/internal/model/notification.go b/internal/model/notification.go index ef8d6ed..fc46221 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -6,7 +6,8 @@ import ( // Notification holds information like the message, the title, and the priority of a notification. type Notification struct { - ID uint `json:"id"` + ID string `json:"id"` + UrlEncodedID string `json:"id_url_encoded"` ApplicationID uint `json:"appid"` Message string `json:"message" form:"message" query:"message" binding:"required"` Title string `json:"title" form:"title" query:"title"` @@ -14,3 +15,9 @@ type Notification struct { Extras map[string]interface{} `json:"extras,omitempty" form:"-" query:"-"` Date time.Time `json:"date"` } + +// DeleteNotification holds information like the message ID of a deletion notification. +type DeleteNotification struct { + ID string `json:"id" form:"id"` + Date time.Time `json:"date"` +} diff --git a/internal/router/router.go b/internal/router/router.go index 587c256..68f61ca 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -46,6 +46,7 @@ func Create(debug bool, cm *credentials.Manager, db *database.Database, dp *disp r.GET("/health", healthHandler.Health) r.POST("/message", auth.RequireApplicationToken(), notificationHandler.CreateNotification) + r.DELETE("/message/:messageid", api.RequireMessageIDInURI(), auth.RequireApplicationToken(), notificationHandler.DeleteNotification) userGroup := r.Group("/user") userGroup.Use(auth.RequireAdmin())