├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── questions.md └── workflows │ └── build.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── GO_VERSION ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── api ├── application.go ├── application_test.go ├── client.go ├── client_test.go ├── errorHandling.go ├── errorHandling_test.go ├── health.go ├── health_test.go ├── internalutil.go ├── message.go ├── message_test.go ├── plugin.go ├── plugin_test.go ├── stream │ ├── client.go │ ├── once.go │ ├── once_test.go │ ├── stream.go │ └── stream_test.go ├── tokens.go ├── tokens_test.go ├── user.go └── user_test.go ├── app.go ├── auth ├── authentication.go ├── authentication_test.go ├── cors.go ├── cors_test.go ├── password │ ├── password.go │ └── password_test.go ├── token.go ├── token_test.go ├── util.go └── util_test.go ├── config.example.yml ├── config ├── config.go └── config_test.go ├── database ├── application.go ├── application_test.go ├── client.go ├── client_test.go ├── database.go ├── database_test.go ├── message.go ├── message_test.go ├── migration_test.go ├── ping.go ├── ping_test.go ├── plugin.go ├── plugin_test.go ├── user.go └── user_test.go ├── docker └── Dockerfile ├── docs ├── package.go ├── spec.json ├── swagger.go ├── swagger_test.go ├── ui.go └── ui_test.go ├── error ├── handler.go ├── handler_test.go ├── notfound.go └── notfound_test.go ├── go.mod ├── go.sum ├── mode ├── mode.go └── mode_test.go ├── model ├── application.go ├── client.go ├── error.go ├── health.go ├── message.go ├── paging.go ├── pluginconf.go ├── user.go └── version.go ├── plugin ├── compat │ ├── instance.go │ ├── plugin.go │ ├── plugin_test.go │ ├── v1.go │ ├── v1_test.go │ ├── wrap.go │ ├── wrap_test.go │ ├── wrap_test_norace.go │ └── wrap_test_race.go ├── example │ ├── clock │ │ └── main.go │ ├── echo │ │ └── echo.go │ └── minimal │ │ └── main.go ├── manager.go ├── manager_test.go ├── manager_test_norace.go ├── manager_test_race.go ├── messagehandler.go ├── pluginenabled.go ├── pluginenabled_test.go ├── storagehandler.go └── testing │ ├── broken │ ├── cantinstantiate │ │ └── main.go │ ├── malformedconstructor │ │ └── main.go │ ├── noinstance │ │ └── main.go │ ├── nothing │ │ └── main.go │ └── unknowninfo │ │ └── main.go │ └── mock │ └── mock.go ├── renovate.json ├── router ├── router.go └── router_test.go ├── runner ├── runner.go ├── umask.go └── umask_fallback.go ├── test ├── asserts.go ├── asserts_test.go ├── assets │ ├── image-header-with.html │ ├── image.png │ └── text.txt ├── auth.go ├── auth_test.go ├── filepath.go ├── filepath_test.go ├── testdb │ ├── database.go │ └── database_test.go ├── tmpdir.go ├── tmpdir_test.go ├── token.go └── token_test.go ├── ui.png └── ui ├── .eslintignore ├── .eslintrc.yml ├── .gitignore ├── .prettierrc ├── package.json ├── public ├── index.html ├── manifest.json └── static │ ├── apple-touch-icon-114x114.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-144x144.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-57x57.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-72x72.png │ ├── apple-touch-icon-76x76.png │ ├── defaultapp.png │ ├── favicon-128.png │ ├── favicon-16x16.png │ ├── favicon-196x196.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ └── notification.ogg ├── serve.go ├── src ├── CurrentUser.ts ├── apiAuth.ts ├── application │ ├── AddApplicationDialog.tsx │ ├── AppStore.ts │ ├── Applications.tsx │ └── UpdateApplicationDialog.tsx ├── client │ ├── AddClientDialog.tsx │ ├── ClientStore.ts │ ├── Clients.tsx │ └── UpdateClientDialog.tsx ├── common │ ├── BaseStore.ts │ ├── ConfirmDialog.tsx │ ├── ConnectionErrorBanner.tsx │ ├── Container.tsx │ ├── CopyableSecret.tsx │ ├── DefaultPage.tsx │ ├── LastUsedCell.tsx │ ├── LoadingSpinner.tsx │ ├── Markdown.tsx │ ├── NumberField.tsx │ ├── ScrollUpButton.tsx │ └── SettingsDialog.tsx ├── config.ts ├── index.tsx ├── inject.tsx ├── layout │ ├── Header.tsx │ ├── Layout.tsx │ └── Navigation.tsx ├── message │ ├── Message.tsx │ ├── Messages.tsx │ ├── MessagesStore.ts │ ├── WebSocketStore.ts │ └── extras.ts ├── plugin │ ├── PluginDetailView.tsx │ ├── PluginStore.ts │ └── Plugins.tsx ├── react-app-env.d.ts ├── reactions.ts ├── registerServiceWorker.ts ├── setupTests.ts ├── snack │ ├── SnackBarHandler.tsx │ ├── SnackManager.ts │ └── browserNotification.ts ├── tests │ ├── application.test.ts │ ├── authentication.ts │ ├── client.test.ts │ ├── message.test.ts │ ├── plugin.test.ts │ ├── selector.ts │ ├── setup.ts │ ├── user.test.ts │ └── utils.ts ├── typedef │ ├── notifyjs.d.ts │ └── react-timeago.d.ts ├── types.ts └── user │ ├── AddEditUserDialog.tsx │ ├── Login.tsx │ ├── Register.tsx │ ├── UserStore.ts │ └── Users.tsx ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea/ 3 | build/ 4 | licenses/ 5 | coverage.txt 6 | data/ 7 | images/ 8 | .git/ 9 | */node_modules/ 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | 10 | [*.go] 11 | indent_style = tab 12 | 13 | [*.{js,ts,tsx}] 14 | indent_style = space 15 | quote_type = single 16 | 17 | [*.json] 18 | indent_style = space 19 | 20 | [*.html] 21 | indent_style = space 22 | 23 | [*.md] 24 | indent_style = space 25 | trim_trailing_whitespace = false 26 | 27 | [Makefile] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jmattheis 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://jmattheis.de/donate 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Found a bug? Tell us and help us improve 4 | title: '' 5 | labels: a:bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Can the issue be reproduced with the latest available release? (y/n)** 11 | 12 | **Which one is the environment gotify server is running in?** 13 | - [ ] Docker 14 | - [ ] Linux machine 15 | - [ ] Windows machine 16 |
Docker startup command or config file here (please mask sensitive information)
17 | 18 | ``` 19 | 20 | ``` 21 |
22 | 23 | **Do you have an reverse proxy installed in front of gotify server? (Please select None if the problem can be reproduced without the presense of a reverse proxy)** 24 | - [ ] None 25 | - [ ] Nginx 26 | - [ ] Apache 27 | - [ ] Caddy 28 |
Reverse proxy configuration (please mask sensitive information)
29 | 30 | ``` 31 | 32 | ``` 33 |
34 | 35 | **On which client do you experience problems? (Select as many as you can see)** 36 | - [ ] WebUI 37 | - [ ] gotify-cli 38 | - [ ] Android Client 39 | - [ ] 3rd-party API call (Please include your code) 40 | 41 | 42 | **What did you do?** 43 | 44 | **What did you expect to see?** 45 | 46 | **What did you see instead? (Include screenshots, android logcat/request dumps if possible)** 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: a:feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Questions 3 | about: Having difficulties with gotify? Feel free to ask here 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | 16 | **Have you read the documentation?** 17 | - [ ] Yes, but it does not include related information regarding my question. 18 | - [ ] Yes, but the steps described in the documentation do not work on my machine. 19 | - [ ] Yes, but I am having difficulty understanding it and want clarification. 20 | 21 | **You are setting up gotify in** 22 | - [ ] Docker 23 | - [ ] Linux native platform 24 | - [ ] Windows native platform 25 | 26 | 27 | **Describe your problem** 28 | 33 | 34 | 35 | 36 | **Any errors, logs, or other information that might help us identify your problem** 37 | 38 | Ex: `docker-compose.yml`, `nginx.conf`, android logcat, browser requests, etc. 39 | 40 |
Name of the information here
41 | 
42 | contents here
43 | 
44 | 
45 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | gotify: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/setup-go@v5 9 | with: 10 | go-version: 1.24.x 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: '22' 14 | - uses: actions/checkout@v4 15 | - run: (cd ui && yarn) 16 | - run: make build-js 17 | - uses: golangci/golangci-lint-action@v7 18 | with: 19 | version: v2.0.2 20 | args: --timeout=5m 21 | skip-cache: true 22 | - run: go mod download 23 | - run: make download-tools 24 | - run: make test 25 | - run: make check-ci 26 | - uses: codecov/codecov-action@v5 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | - if: startsWith(github.ref, 'refs/tags/v') 30 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 31 | - if: startsWith(github.ref, 'refs/tags/v') 32 | run: | 33 | export LD_FLAGS="-w -s -X main.Version=$VERSION -X main.BuildDate=$(date "+%F-%T") -X main.Commit=$(git rev-parse --verify HEAD) -X main.Mode=prod" 34 | echo "LD_FLAGS=$LD_FLAGS" >> $GITHUB_ENV 35 | 36 | make build 37 | sudo chown -R $UID build 38 | make package-zip 39 | ls -lath build 40 | - if: startsWith(github.ref, 'refs/tags/v') 41 | name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | - if: startsWith(github.ref, 'refs/tags/v') 44 | name: Set up QEMU 45 | uses: docker/setup-qemu-action@v3 46 | - if: startsWith(github.ref, 'refs/tags/v') 47 | uses: docker/login-action@v3 48 | with: 49 | username: ${{ secrets.DOCKER_USER }} 50 | password: ${{ secrets.DOCKER_PASS }} 51 | - if: startsWith(github.ref, 'refs/tags/v') 52 | uses: docker/login-action@v3 53 | with: 54 | registry: ghcr.io 55 | username: ${{ secrets.DOCKER_GHCR_USER }} 56 | password: ${{ secrets.DOCKER_GHCR_PASS }} 57 | - if: startsWith(github.ref, 'refs/tags/v') 58 | run: | 59 | make DOCKER_BUILD_PUSH=true build-docker 60 | - if: startsWith(github.ref, 'refs/tags/v') 61 | uses: svenstaro/upload-release-action@v2 62 | with: 63 | repo_token: ${{ secrets.GITHUB_TOKEN }} 64 | file: build/*.zip 65 | tag: ${{ github.ref }} 66 | overwrite: true 67 | file_glob: true 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea/ 3 | build/ 4 | certs/ 5 | build/ 6 | licenses/ 7 | coverage.txt 8 | */node_modules/ 9 | **/*-packr.go 10 | config.yml 11 | data/ 12 | images/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - asciicheck 5 | - copyloopvar 6 | - godot 7 | - gomodguard 8 | - goprintffuncname 9 | - misspell 10 | - nakedret 11 | - nolintlint 12 | - sqlclosecheck 13 | - staticcheck 14 | - unconvert 15 | - whitespace 16 | disable: 17 | - err113 18 | - errcheck 19 | - funlen 20 | - gochecknoglobals 21 | - gocognit 22 | - goconst 23 | - gocyclo 24 | - godox 25 | - lll 26 | - nestif 27 | - nlreturn 28 | - noctx 29 | - testpackage 30 | - wsl 31 | settings: 32 | misspell: 33 | locale: US 34 | exclusions: 35 | generated: lax 36 | presets: 37 | - comments 38 | - common-false-positives 39 | - legacy 40 | - std-error-handling 41 | paths: 42 | - plugin/example 43 | - plugin/testing 44 | - third_party$ 45 | - builtin$ 46 | - examples$ 47 | formatters: 48 | enable: 49 | - gofmt 50 | - gofumpt 51 | - goimports 52 | settings: 53 | gofumpt: 54 | extra-rules: true 55 | exclusions: 56 | generated: lax 57 | paths: 58 | - plugin/example 59 | - plugin/testing 60 | - third_party$ 61 | - builtin$ 62 | - examples$ 63 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gotify/committers -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at gotify@protonmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in Gotify! 4 | 5 | First of all, please note that we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 6 | 7 | If you have any questions you can join the chat on [#gotify:matrix.org](https://matrix.to/#/#gotify:matrix.org). 8 | 9 | ## Where to Contribute 10 | 11 | | Repository| Description| Technology| 12 | | ---| ---| ---| 13 | |[gotify/server](https://github.com/gotify/server) |server implementation and WebUI code |`Go` `Typescript` `React`| 14 | |[gotify/android](https://github.com/gotify/android) |android client implementation |`Java` `Android` | 15 | |[gotify/plugin-template](https://github.com/gotify/plugin-template)|official gotify plugin template |`Go` | 16 | |[gotify/cli](https://github.com/gotify/cli) |official CLI client |`Go` | 17 | |[gotify/website](https://github.com/gotify/website) |documentaion [gotify.net](https://gotify.net/)|`Markdown` `Docusaurus` | 18 | |[gotify/contrib](https://github.com/gotify/contrib) |community-contributed projects |`misc` | 19 | 20 | ## Ways to Contribute 21 | 22 | ### Document Refinements 23 | 24 | _Keywords: **Documentation**, **Writing**_ 25 | 26 | Documents are residing in the [gotify/website](https://github.com/gotify/website) repository. Open an issue or PR and indicate the part of the document you are working on or the information you want to add to the document. 27 | 28 | ### Feature Request and implementation 29 | 30 | _Keywords: **Features**, **Coding**_ 31 | 32 | When proposing features to gotify/\*, please first discuss the change you wish to make via issue, chat or any other method with the maintainers. 33 | 34 | After the feature request is approved, file an issue or comment under the existing one indicating whether you want to submit the implementation yourself. If you decided not to, the maintainers would evaluate the necessity and urgency of the feature and decide whether to wait for another contributor to claim the request or commit an implementation himself/herself. 35 | 36 | ### Bug Reports and Fixes 37 | 38 | _Keywords: **Bug Hunt**, **Coding**_ 39 | 40 | If you are not sure if the problem you are facing is indeed a bug, we recommend discussing it in the [community chat]((https://matrix.to/#/#gotify:matrix.org)) first, opening an issue is also welcome. 41 | 42 | After the bug is confirmed, please file a new or comment under the existing issue describing the bug and indicate whether you want to sumbit the fix yourself. 43 | 44 | If you want to submit a fix to an already confirmed issue, please indicate that you wish to submit a PR in a comment before starting your work. 45 | 46 | ### Community Contribution Projects 47 | 48 | _Keywords:_ **Features**, **Coding**, **Writing** 49 | 50 | Make gotify more powerful and easy-to-use than ever by: 51 | - writing a [plugin](https://gotify.net/docs/plugin) 52 | - writing a client (smartphones, Windows, Linux, Browser Add-on, etc.) 53 | - writing about how you have used gotify for your applications 54 | 55 | Also, after you have finished, consider submitting your hard work to the community contributions [repository](https://github.com/gotify/contrib) so that more users can make a use of it. 56 | -------------------------------------------------------------------------------- /GO_VERSION: -------------------------------------------------------------------------------- 1 | 1.24.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 jmattheis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | The Gotify logo is licensed under the Creative Commons Attribution 4.0 International Public License. 26 | http://creativecommons.org/licenses/by/4.0/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 |

gotify/server

8 | 9 |

10 | 11 | Build Status 12 | 13 | 14 | codecov 15 | 16 | 17 | Go Report Card 18 | 19 | 20 | Matrix 21 | 22 | 23 | Docker Pulls 24 | 25 | 26 | latest release 27 | 28 |

29 | 30 | ## Intro 31 | We wanted a simple server for sending and receiving messages (in real time per WebSocket). For this, not many open source projects existed and most of the existing ones were abandoned. Also, a requirement was that it can be self-hosted. We know there are many free and commercial push services out there. 32 | 33 | ## Features 34 | 35 | Gotify UI screenshot 36 | 37 | * send messages via REST-API 38 | * receive messages via WebSocket 39 | * manage users, clients and applications 40 | * [Plugins](https://gotify.net/docs/plugin) 41 | * Web-UI -> [./ui](ui) 42 | * CLI for sending messages -> [gotify/cli](https://github.com/gotify/cli) 43 | * Android-App -> [gotify/android](https://github.com/gotify/android) 44 | 45 | [Get it on Google Play][playstore] 46 | [Get it on F-Droid][fdroid] 47 | 48 | (Google Play and the Google Play logo are trademarks of Google LLC.) 49 | 50 | --- 51 | 52 | **[Documentation](https://gotify.net/docs)** 53 | 54 | [Install](https://gotify.net/docs/install) ᛫ 55 | [Configuration](https://gotify.net/docs/config) ᛫ 56 | [REST-API](https://gotify.net/api-docs) ᛫ 57 | [Setup Dev Environment](https://gotify.net/docs/dev-setup) 58 | 59 | ## Contributing 60 | 61 | We welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 62 | 63 | ## Versioning 64 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the 65 | [tags on this repository](https://github.com/gotify/server/tags). 66 | 67 | ## License 68 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 69 | 70 | [playstore]: https://play.google.com/store/apps/details?id=com.github.gotify 71 | [fdroid]: https://f-droid.org/de/packages/com.github.gotify/ 72 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest version. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please report (suspected) security vulnerabilities to 10 | **[gotify@protonmail.com](mailto:gotify@protonmail.com)**. You will receive a 11 | response from us within a few days. If the issue is confirmed, we will release a 12 | patch as soon as possible. 13 | -------------------------------------------------------------------------------- /api/errorHandling.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func successOrAbort(ctx *gin.Context, code int, err error) (success bool) { 6 | if err != nil { 7 | ctx.AbortWithError(code, err) 8 | } 9 | return err == nil 10 | } 11 | -------------------------------------------------------------------------------- /api/errorHandling_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func TestErrorHandling(t *testing.T) { 12 | rec := httptest.NewRecorder() 13 | 14 | ctx, _ := gin.CreateTestContext(rec) 15 | successOrAbort(ctx, 500, errors.New("err")) 16 | 17 | if rec.Code != 500 { 18 | t.Fail() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gotify/server/v2/model" 6 | ) 7 | 8 | // The HealthDatabase interface for encapsulating database access. 9 | type HealthDatabase interface { 10 | Ping() error 11 | } 12 | 13 | // The HealthAPI provides handlers for the health information. 14 | type HealthAPI struct { 15 | DB HealthDatabase 16 | } 17 | 18 | // Health returns health information. 19 | // swagger:operation GET /health health getHealth 20 | // 21 | // Get health information. 22 | // 23 | // --- 24 | // produces: [application/json] 25 | // responses: 26 | // 200: 27 | // description: Ok 28 | // schema: 29 | // $ref: "#/definitions/Health" 30 | // 500: 31 | // description: Ok 32 | // schema: 33 | // $ref: "#/definitions/Health" 34 | func (a *HealthAPI) Health(ctx *gin.Context) { 35 | if err := a.DB.Ping(); err != nil { 36 | ctx.JSON(500, model.Health{ 37 | Health: model.StatusOrange, 38 | Database: model.StatusRed, 39 | }) 40 | return 41 | } 42 | ctx.JSON(200, model.Health{ 43 | Health: model.StatusGreen, 44 | Database: model.StatusGreen, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /api/health_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/server/v2/mode" 9 | "github.com/gotify/server/v2/model" 10 | "github.com/gotify/server/v2/test" 11 | "github.com/gotify/server/v2/test/testdb" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | func TestHealthSuite(t *testing.T) { 16 | suite.Run(t, new(HealthSuite)) 17 | } 18 | 19 | type HealthSuite struct { 20 | suite.Suite 21 | db *testdb.Database 22 | a *HealthAPI 23 | ctx *gin.Context 24 | recorder *httptest.ResponseRecorder 25 | } 26 | 27 | func (s *HealthSuite) BeforeTest(suiteName, testName string) { 28 | mode.Set(mode.TestDev) 29 | s.recorder = httptest.NewRecorder() 30 | s.db = testdb.NewDB(s.T()) 31 | s.ctx, _ = gin.CreateTestContext(s.recorder) 32 | withURL(s.ctx, "http", "example.com") 33 | s.a = &HealthAPI{DB: s.db} 34 | } 35 | 36 | func (s *HealthSuite) AfterTest(suiteName, testName string) { 37 | s.db.Close() 38 | } 39 | 40 | func (s *HealthSuite) TestHealthSuccess() { 41 | s.a.Health(s.ctx) 42 | test.BodyEquals(s.T(), model.Health{Health: model.StatusGreen, Database: model.StatusGreen}, s.recorder) 43 | } 44 | 45 | func (s *HealthSuite) TestDatabaseFailure() { 46 | s.db.Close() 47 | s.a.Health(s.ctx) 48 | test.BodyEquals(s.T(), model.Health{Health: model.StatusOrange, Database: model.StatusRed}, s.recorder) 49 | } 50 | -------------------------------------------------------------------------------- /api/internalutil.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "math/bits" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func withID(ctx *gin.Context, name string, f func(id uint)) { 12 | if id, err := strconv.ParseUint(ctx.Param(name), 10, bits.UintSize); err == nil { 13 | f(uint(id)) 14 | } else { 15 | ctx.AbortWithError(400, errors.New("invalid id")) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/stream/client.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/gotify/server/v2/model" 9 | ) 10 | 11 | const ( 12 | writeWait = 2 * time.Second 13 | ) 14 | 15 | var ping = func(conn *websocket.Conn) error { 16 | return conn.WriteMessage(websocket.PingMessage, nil) 17 | } 18 | 19 | var writeJSON = func(conn *websocket.Conn, v interface{}) error { 20 | return conn.WriteJSON(v) 21 | } 22 | 23 | type client struct { 24 | conn *websocket.Conn 25 | onClose func(*client) 26 | write chan *model.MessageExternal 27 | userID uint 28 | token string 29 | once once 30 | } 31 | 32 | func newClient(conn *websocket.Conn, userID uint, token string, onClose func(*client)) *client { 33 | return &client{ 34 | conn: conn, 35 | write: make(chan *model.MessageExternal, 1), 36 | userID: userID, 37 | token: token, 38 | onClose: onClose, 39 | } 40 | } 41 | 42 | // Close closes the connection. 43 | func (c *client) Close() { 44 | c.once.Do(func() { 45 | c.conn.Close() 46 | close(c.write) 47 | }) 48 | } 49 | 50 | // NotifyClose closes the connection and notifies that the connection was closed. 51 | func (c *client) NotifyClose() { 52 | c.once.Do(func() { 53 | c.conn.Close() 54 | close(c.write) 55 | c.onClose(c) 56 | }) 57 | } 58 | 59 | // startWriteHandler starts listening on the client connection. As we do not need anything from the client, 60 | // we ignore incoming messages. Leaves the loop on errors. 61 | func (c *client) startReading(pongWait time.Duration) { 62 | defer c.NotifyClose() 63 | c.conn.SetReadLimit(64) 64 | c.conn.SetReadDeadline(time.Now().Add(pongWait)) 65 | c.conn.SetPongHandler(func(appData string) error { 66 | c.conn.SetReadDeadline(time.Now().Add(pongWait)) 67 | return nil 68 | }) 69 | for { 70 | if _, _, err := c.conn.NextReader(); err != nil { 71 | printWebSocketError("ReadError", err) 72 | return 73 | } 74 | } 75 | } 76 | 77 | // startWriteHandler starts the write loop. The method has the following tasks: 78 | // * ping the client in the interval provided as parameter 79 | // * write messages send by the channel to the client 80 | // * on errors exit the loop. 81 | func (c *client) startWriteHandler(pingPeriod time.Duration) { 82 | pingTicker := time.NewTicker(pingPeriod) 83 | defer func() { 84 | c.NotifyClose() 85 | pingTicker.Stop() 86 | }() 87 | 88 | for { 89 | select { 90 | case message, ok := <-c.write: 91 | if !ok { 92 | return 93 | } 94 | 95 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 96 | if err := writeJSON(c.conn, message); err != nil { 97 | printWebSocketError("WriteError", err) 98 | return 99 | } 100 | case <-pingTicker.C: 101 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 102 | if err := ping(c.conn); err != nil { 103 | printWebSocketError("PingError", err) 104 | return 105 | } 106 | } 107 | } 108 | } 109 | 110 | func printWebSocketError(prefix string, err error) { 111 | closeError, ok := err.(*websocket.CloseError) 112 | 113 | if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) { 114 | // normal closure 115 | return 116 | } 117 | 118 | fmt.Println("WebSocket:", prefix, err) 119 | } 120 | -------------------------------------------------------------------------------- /api/stream/once.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package stream 6 | 7 | import ( 8 | "sync" 9 | "sync/atomic" 10 | ) 11 | 12 | // Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go) 13 | // This version unlocks the mutex early and therefore doesn't hold the lock while executing func f(). 14 | type once struct { 15 | m sync.Mutex 16 | done uint32 17 | } 18 | 19 | func (o *once) Do(f func()) { 20 | if atomic.LoadUint32(&o.done) == 1 { 21 | return 22 | } 23 | if o.mayExecute() { 24 | f() 25 | } 26 | } 27 | 28 | func (o *once) mayExecute() bool { 29 | o.m.Lock() 30 | defer o.m.Unlock() 31 | if o.done == 0 { 32 | atomic.StoreUint32(&o.done, 1) 33 | return true 34 | } 35 | return false 36 | } 37 | -------------------------------------------------------------------------------- /api/stream/once_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_Execute(t *testing.T) { 11 | executeOnce := once{} 12 | execution := make(chan struct{}) 13 | fExecute := func() { 14 | execution <- struct{}{} 15 | } 16 | go executeOnce.Do(fExecute) 17 | go executeOnce.Do(fExecute) 18 | 19 | select { 20 | case <-execution: 21 | // expected 22 | case <-time.After(100 * time.Millisecond): 23 | t.Fatal("fExecute should be executed once") 24 | } 25 | 26 | select { 27 | case <-execution: 28 | t.Fatal("should only execute once") 29 | case <-time.After(100 * time.Millisecond): 30 | // expected 31 | } 32 | 33 | assert.False(t, executeOnce.mayExecute()) 34 | 35 | go executeOnce.Do(fExecute) 36 | 37 | select { 38 | case <-execution: 39 | t.Fatal("should only execute once") 40 | case <-time.After(100 * time.Millisecond): 41 | // expected 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/tokens.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gotify/server/v2/auth" 5 | ) 6 | 7 | var generateApplicationToken = auth.GenerateApplicationToken 8 | 9 | var generateClientToken = auth.GenerateClientToken 10 | 11 | var generateImageName = auth.GenerateImageName 12 | -------------------------------------------------------------------------------- /api/tokens_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTokenGeneration(t *testing.T) { 11 | assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken()) 12 | assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken()) 13 | assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName()) 14 | } 15 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gotify/server/v2/config" 8 | "github.com/gotify/server/v2/database" 9 | "github.com/gotify/server/v2/mode" 10 | "github.com/gotify/server/v2/model" 11 | "github.com/gotify/server/v2/router" 12 | "github.com/gotify/server/v2/runner" 13 | ) 14 | 15 | var ( 16 | // Version the version of Gotify. 17 | Version = "unknown" 18 | // Commit the git commit hash of this version. 19 | Commit = "unknown" 20 | // BuildDate the date on which this binary was build. 21 | BuildDate = "unknown" 22 | // Mode the build mode. 23 | Mode = mode.Dev 24 | ) 25 | 26 | func main() { 27 | vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} 28 | mode.Set(Mode) 29 | 30 | fmt.Println("Starting Gotify version", vInfo.Version+"@"+BuildDate) 31 | conf := config.Get() 32 | 33 | if conf.PluginsDir != "" { 34 | if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil { 35 | panic(err) 36 | } 37 | } 38 | if err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil { 39 | panic(err) 40 | } 41 | 42 | db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true) 43 | if err != nil { 44 | panic(err) 45 | } 46 | defer db.Close() 47 | 48 | engine, closeable := router.Create(db, vInfo, conf) 49 | defer closeable() 50 | 51 | if err := runner.Run(engine, conf); err != nil { 52 | fmt.Println("Server error: ", err) 53 | os.Exit(1) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /auth/cors.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gin-contrib/cors" 9 | "github.com/gotify/server/v2/config" 10 | "github.com/gotify/server/v2/mode" 11 | ) 12 | 13 | // CorsConfig generates a config to use in gin cors middleware based on server configuration. 14 | func CorsConfig(conf *config.Configuration) cors.Config { 15 | corsConf := cors.Config{ 16 | MaxAge: 12 * time.Hour, 17 | AllowBrowserExtensions: true, 18 | } 19 | if mode.IsDev() { 20 | corsConf.AllowAllOrigins = true 21 | corsConf.AllowMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"} 22 | corsConf.AllowHeaders = []string{ 23 | "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", 24 | "Connection", "Accept-Encoding", "Accept-Language", "Host", 25 | } 26 | } else { 27 | compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins) 28 | corsConf.AllowMethods = conf.Server.Cors.AllowMethods 29 | corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders 30 | corsConf.AllowOriginFunc = func(origin string) bool { 31 | for _, compiledOrigin := range compiledOrigins { 32 | if compiledOrigin.MatchString(strings.ToLower(origin)) { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 { 39 | corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin) 40 | } 41 | } 42 | 43 | return corsConf 44 | } 45 | 46 | func headerIgnoreCase(conf *config.Configuration, search string) (value string) { 47 | for key, value := range conf.Server.ResponseHeaders { 48 | if strings.ToLower(key) == search { 49 | return value 50 | } 51 | } 52 | return "" 53 | } 54 | 55 | func compileAllowedCORSOrigins(allowedOrigins []string) []*regexp.Regexp { 56 | var compiledAllowedOrigins []*regexp.Regexp 57 | for _, origin := range allowedOrigins { 58 | compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin)) 59 | } 60 | 61 | return compiledAllowedOrigins 62 | } 63 | -------------------------------------------------------------------------------- /auth/cors_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/gin-contrib/cors" 8 | "github.com/gotify/server/v2/config" 9 | "github.com/gotify/server/v2/mode" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCorsConfig(t *testing.T) { 14 | mode.Set(mode.Prod) 15 | serverConf := config.Configuration{} 16 | serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"} 17 | serverConf.Server.Cors.AllowHeaders = []string{"content-type"} 18 | serverConf.Server.Cors.AllowMethods = []string{"GET"} 19 | 20 | actual := CorsConfig(&serverConf) 21 | allowF := actual.AllowOriginFunc 22 | actual.AllowOriginFunc = nil // func cannot be checked with equal 23 | 24 | assert.Equal(t, cors.Config{ 25 | AllowAllOrigins: false, 26 | AllowHeaders: []string{"content-type"}, 27 | AllowMethods: []string{"GET"}, 28 | MaxAge: 12 * time.Hour, 29 | AllowBrowserExtensions: true, 30 | }, actual) 31 | assert.NotNil(t, allowF) 32 | assert.True(t, allowF("http://test.com")) 33 | assert.False(t, allowF("https://test.com")) 34 | assert.False(t, allowF("https://other.com")) 35 | } 36 | 37 | func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) { 38 | mode.Set(mode.Prod) 39 | serverConf := config.Configuration{} 40 | serverConf.Server.ResponseHeaders = map[string]string{"Access-control-allow-origin": "https://example.com"} 41 | 42 | actual := CorsConfig(&serverConf) 43 | assert.NotNil(t, actual.AllowOriginFunc) 44 | actual.AllowOriginFunc = nil // func cannot be checked with equal 45 | 46 | assert.Equal(t, cors.Config{ 47 | AllowAllOrigins: false, 48 | AllowOrigins: []string{"https://example.com"}, 49 | MaxAge: 12 * time.Hour, 50 | AllowBrowserExtensions: true, 51 | }, actual) 52 | } 53 | 54 | func TestDevCorsConfig(t *testing.T) { 55 | mode.Set(mode.Dev) 56 | serverConf := config.Configuration{} 57 | serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"} 58 | serverConf.Server.Cors.AllowHeaders = []string{"content-type"} 59 | serverConf.Server.Cors.AllowMethods = []string{"GET"} 60 | 61 | actual := CorsConfig(&serverConf) 62 | 63 | assert.Equal(t, cors.Config{ 64 | AllowHeaders: []string{ 65 | "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", 66 | "Connection", "Accept-Encoding", "Accept-Language", "Host", 67 | }, 68 | AllowMethods: []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}, 69 | MaxAge: 12 * time.Hour, 70 | AllowAllOrigins: true, 71 | AllowBrowserExtensions: true, 72 | }, actual) 73 | } 74 | -------------------------------------------------------------------------------- /auth/password/password.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | // CreatePassword returns a hashed version of the given password. 6 | func CreatePassword(pw string, strength int) []byte { 7 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), strength) 8 | if err != nil { 9 | panic(err) 10 | } 11 | return hashedPassword 12 | } 13 | 14 | // ComparePassword compares a hashed password with its possible plaintext equivalent. 15 | func ComparePassword(hashedPassword, password []byte) bool { 16 | return bcrypt.CompareHashAndPassword(hashedPassword, password) == nil 17 | } 18 | -------------------------------------------------------------------------------- /auth/password/password_test.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPasswordSuccess(t *testing.T) { 10 | password := CreatePassword("secret", 5) 11 | assert.Equal(t, true, ComparePassword(password, []byte("secret"))) 12 | } 13 | 14 | func TestPasswordFailure(t *testing.T) { 15 | password := CreatePassword("secret", 5) 16 | assert.Equal(t, false, ComparePassword(password, []byte("secretx"))) 17 | } 18 | 19 | func TestBCryptFailure(t *testing.T) { 20 | assert.Panics(t, func() { CreatePassword("secret", 12312) }) 21 | } 22 | -------------------------------------------------------------------------------- /auth/token.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | var ( 9 | tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") 10 | randomTokenLength = 14 11 | applicationPrefix = "A" 12 | clientPrefix = "C" 13 | pluginPrefix = "P" 14 | 15 | randReader = rand.Reader 16 | ) 17 | 18 | func randIntn(n int) int { 19 | max := big.NewInt(int64(n)) 20 | res, err := rand.Int(randReader, max) 21 | if err != nil { 22 | panic("random source is not available") 23 | } 24 | return int(res.Int64()) 25 | } 26 | 27 | // GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token. 28 | func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { 29 | for { 30 | token := generateToken() 31 | if !tokenExists(token) { 32 | return token 33 | } 34 | } 35 | } 36 | 37 | // GenerateApplicationToken generates an application token. 38 | func GenerateApplicationToken() string { 39 | return generateRandomToken(applicationPrefix) 40 | } 41 | 42 | // GenerateClientToken generates a client token. 43 | func GenerateClientToken() string { 44 | return generateRandomToken(clientPrefix) 45 | } 46 | 47 | // GeneratePluginToken generates a plugin token. 48 | func GeneratePluginToken() string { 49 | return generateRandomToken(pluginPrefix) 50 | } 51 | 52 | // GenerateImageName generates an image name. 53 | func GenerateImageName() string { 54 | return generateRandomString(25) 55 | } 56 | 57 | func generateRandomToken(prefix string) string { 58 | return prefix + generateRandomString(randomTokenLength) 59 | } 60 | 61 | func generateRandomString(length int) string { 62 | res := make([]byte, length) 63 | for i := range res { 64 | index := randIntn(len(tokenCharacters)) 65 | res[i] = tokenCharacters[index] 66 | } 67 | return string(res) 68 | } 69 | 70 | func init() { 71 | randIntn(2) 72 | } 73 | -------------------------------------------------------------------------------- /auth/token_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gotify/server/v2/test" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTokenHavePrefix(t *testing.T) { 14 | for i := 0; i < 50; i++ { 15 | assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) 16 | assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) 17 | assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P")) 18 | assert.NotEmpty(t, GenerateImageName()) 19 | } 20 | } 21 | 22 | func TestGenerateNotExistingToken(t *testing.T) { 23 | count := 5 24 | token := GenerateNotExistingToken(func() string { 25 | return fmt.Sprint(count) 26 | }, func(token string) bool { 27 | count-- 28 | return token != "0" 29 | }) 30 | assert.Equal(t, "0", token) 31 | } 32 | 33 | func TestBadCryptoReaderPanics(t *testing.T) { 34 | assert.Panics(t, func() { 35 | randReader = test.UnreadableReader() 36 | defer func() { 37 | randReader = rand.Reader 38 | }() 39 | randIntn(2) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /auth/util.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gotify/server/v2/model" 6 | ) 7 | 8 | // RegisterAuthentication registers the user id, user and or token. 9 | func RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tokenID string) { 10 | ctx.Set("user", user) 11 | ctx.Set("userid", userID) 12 | ctx.Set("tokenid", tokenID) 13 | } 14 | 15 | // GetUserID returns the user id which was previously registered by RegisterAuthentication. 16 | func GetUserID(ctx *gin.Context) uint { 17 | id := TryGetUserID(ctx) 18 | if id == nil { 19 | panic("token and user may not be null") 20 | } 21 | return *id 22 | } 23 | 24 | // TryGetUserID returns the user id or nil if one is not set. 25 | func TryGetUserID(ctx *gin.Context) *uint { 26 | user := ctx.MustGet("user").(*model.User) 27 | if user == nil { 28 | userID := ctx.MustGet("userid").(uint) 29 | if userID == 0 { 30 | return nil 31 | } 32 | return &userID 33 | } 34 | 35 | return &user.ID 36 | } 37 | 38 | // GetTokenID returns the tokenID. 39 | func GetTokenID(ctx *gin.Context) string { 40 | return ctx.MustGet("tokenid").(string) 41 | } 42 | -------------------------------------------------------------------------------- /auth/util_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/server/v2/mode" 9 | "github.com/gotify/server/v2/model" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/suite" 12 | ) 13 | 14 | func TestUtilSuite(t *testing.T) { 15 | suite.Run(t, new(UtilSuite)) 16 | } 17 | 18 | type UtilSuite struct { 19 | suite.Suite 20 | } 21 | 22 | func (s *UtilSuite) BeforeTest(suiteName, testName string) { 23 | mode.Set(mode.TestDev) 24 | } 25 | 26 | func (s *UtilSuite) Test_getID() { 27 | s.expectUserIDWith(&model.User{ID: 2}, 0, 2) 28 | s.expectUserIDWith(nil, 5, 5) 29 | assert.Panics(s.T(), func() { 30 | s.expectUserIDWith(nil, 0, 0) 31 | }) 32 | s.expectTryUserIDWith(nil, 0, nil) 33 | } 34 | 35 | func (s *UtilSuite) Test_getToken() { 36 | ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) 37 | RegisterAuthentication(ctx, nil, 1, "asdasda") 38 | actualID := GetTokenID(ctx) 39 | assert.Equal(s.T(), "asdasda", actualID) 40 | } 41 | 42 | func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) { 43 | ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) 44 | RegisterAuthentication(ctx, user, tokenUserID, "") 45 | actualID := GetUserID(ctx) 46 | assert.Equal(s.T(), expectedID, actualID) 47 | } 48 | 49 | func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) { 50 | ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) 51 | RegisterAuthentication(ctx, user, tokenUserID, "") 52 | actualID := TryGetUserID(ctx) 53 | assert.Equal(s.T(), expectedID, actualID) 54 | } 55 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | # Example configuration file for the server. 2 | # Save it to `config.yml` when edited 3 | 4 | server: 5 | keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing. 6 | listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". 7 | port: 80 # the port the HTTP server will listen on 8 | 9 | ssl: 10 | enabled: false # if https should be enabled 11 | redirecttohttps: true # redirect to https if site is accessed by http 12 | listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". 13 | port: 443 # the https port 14 | certfile: # the cert file (leave empty when using letsencrypt) 15 | certkey: # the cert key (leave empty when using letsencrypt) 16 | letsencrypt: 17 | enabled: false # if the certificate should be requested from letsencrypt 18 | accepttos: false # if you accept the tos from letsencrypt 19 | cache: data/certs # the directory of the cache from letsencrypt 20 | hosts: # the hosts for which letsencrypt should request certificates 21 | # - mydomain.tld 22 | # - myotherdomain.tld 23 | 24 | responseheaders: # response headers are added to every response (default: none) 25 | # X-Custom-Header: "custom value" 26 | # 27 | trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets) 28 | # - 127.0.0.1/32 29 | # - ::1 30 | 31 | cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers. 32 | alloworigins: 33 | # - ".+.example.com" 34 | # - "otherdomain.com" 35 | allowmethods: 36 | # - "GET" 37 | # - "POST" 38 | allowheaders: 39 | # - "Authorization" 40 | # - "content-type" 41 | stream: 42 | pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing. 43 | allowedorigins: # allowed origins for websocket connections (same origin is always allowed) 44 | # - ".+.example.com" 45 | # - "otherdomain.com" 46 | 47 | database: # for database see (configure database section) 48 | dialect: sqlite3 49 | connection: data/gotify.db 50 | 51 | defaultuser: # on database creation, gotify creates an admin user 52 | name: admin # the username of the default user 53 | pass: admin # the password of the default user 54 | passstrength: 10 # the bcrypt password strength (higher = better but also slower) 55 | uploadedimagesdir: data/images # the directory for storing uploaded images 56 | pluginsdir: data/plugins # the directory where plugin resides 57 | registration: false # enable registrations 58 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/gotify/server/v2/mode" 8 | "github.com/jinzhu/configor" 9 | ) 10 | 11 | // Configuration is stuff that can be configured externally per env variables or config file (config.yml). 12 | type Configuration struct { 13 | Server struct { 14 | KeepAlivePeriodSeconds int 15 | ListenAddr string `default:""` 16 | Port int `default:"80"` 17 | 18 | SSL struct { 19 | Enabled bool `default:"false"` 20 | RedirectToHTTPS bool `default:"true"` 21 | ListenAddr string `default:""` 22 | Port int `default:"443"` 23 | CertFile string `default:""` 24 | CertKey string `default:""` 25 | LetsEncrypt struct { 26 | Enabled bool `default:"false"` 27 | AcceptTOS bool `default:"false"` 28 | Cache string `default:"data/certs"` 29 | Hosts []string 30 | } 31 | } 32 | ResponseHeaders map[string]string 33 | Stream struct { 34 | PingPeriodSeconds int `default:"45"` 35 | AllowedOrigins []string 36 | } 37 | Cors struct { 38 | AllowOrigins []string 39 | AllowMethods []string 40 | AllowHeaders []string 41 | } 42 | 43 | TrustedProxies []string 44 | } 45 | Database struct { 46 | Dialect string `default:"sqlite3"` 47 | Connection string `default:"data/gotify.db"` 48 | } 49 | DefaultUser struct { 50 | Name string `default:"admin"` 51 | Pass string `default:"admin"` 52 | } 53 | PassStrength int `default:"10"` 54 | UploadedImagesDir string `default:"data/images"` 55 | PluginsDir string `default:"data/plugins"` 56 | Registration bool `default:"false"` 57 | } 58 | 59 | func configFiles() []string { 60 | if mode.Get() == mode.TestDev { 61 | return []string{"config.yml"} 62 | } 63 | return []string{"config.yml", "/etc/gotify/config.yml"} 64 | } 65 | 66 | // Get returns the configuration extracted from env variables or config file. 67 | func Get() *Configuration { 68 | conf := new(Configuration) 69 | err := configor.New(&configor.Config{ENVPrefix: "GOTIFY", Silent: true}).Load(conf, configFiles()...) 70 | if err != nil { 71 | panic(err) 72 | } 73 | addTrailingSlashToPaths(conf) 74 | return conf 75 | } 76 | 77 | func addTrailingSlashToPaths(conf *Configuration) { 78 | if !strings.HasSuffix(conf.UploadedImagesDir, "/") && !strings.HasSuffix(conf.UploadedImagesDir, "\\") { 79 | conf.UploadedImagesDir += string(filepath.Separator) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /database/application.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | // GetApplicationByToken returns the application for the given token or nil. 11 | func (d *GormDatabase) GetApplicationByToken(token string) (*model.Application, error) { 12 | app := new(model.Application) 13 | err := d.DB.Where("token = ?", token).Find(app).Error 14 | if err == gorm.ErrRecordNotFound { 15 | err = nil 16 | } 17 | if app.Token == token { 18 | return app, err 19 | } 20 | return nil, err 21 | } 22 | 23 | // GetApplicationByID returns the application for the given id or nil. 24 | func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) { 25 | app := new(model.Application) 26 | err := d.DB.Where("id = ?", id).Find(app).Error 27 | if err == gorm.ErrRecordNotFound { 28 | err = nil 29 | } 30 | if app.ID == id { 31 | return app, err 32 | } 33 | return nil, err 34 | } 35 | 36 | // CreateApplication creates an application. 37 | func (d *GormDatabase) CreateApplication(application *model.Application) error { 38 | return d.DB.Create(application).Error 39 | } 40 | 41 | // DeleteApplicationByID deletes an application by its id. 42 | func (d *GormDatabase) DeleteApplicationByID(id uint) error { 43 | d.DeleteMessagesByApplication(id) 44 | return d.DB.Where("id = ?", id).Delete(&model.Application{}).Error 45 | } 46 | 47 | // GetApplicationsByUser returns all applications from a user. 48 | func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) { 49 | var apps []*model.Application 50 | err := d.DB.Where("user_id = ?", userID).Order("id ASC").Find(&apps).Error 51 | if err == gorm.ErrRecordNotFound { 52 | err = nil 53 | } 54 | return apps, err 55 | } 56 | 57 | // UpdateApplication updates an application. 58 | func (d *GormDatabase) UpdateApplication(app *model.Application) error { 59 | return d.DB.Save(app).Error 60 | } 61 | 62 | // UpdateApplicationTokenLastUsed updates the last used time of the application token. 63 | func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error { 64 | return d.DB.Model(&model.Application{}).Where("token = ?", token).Update("last_used", t).Error 65 | } 66 | -------------------------------------------------------------------------------- /database/application_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func (s *DatabaseSuite) TestApplication() { 11 | if app, err := s.db.GetApplicationByToken("asdasdf"); assert.NoError(s.T(), err) { 12 | assert.Nil(s.T(), app, "not existing app") 13 | } 14 | 15 | if app, err := s.db.GetApplicationByID(uint(1)); assert.NoError(s.T(), err) { 16 | assert.Nil(s.T(), app, "not existing app") 17 | } 18 | 19 | user := &model.User{Name: "test", Pass: []byte{1}} 20 | s.db.CreateUser(user) 21 | assert.NotEqual(s.T(), 0, user.ID) 22 | 23 | if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { 24 | assert.Empty(s.T(), apps) 25 | } 26 | 27 | app := &model.Application{UserID: user.ID, Token: "C0000000000", Name: "backupserver"} 28 | s.db.CreateApplication(app) 29 | 30 | if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { 31 | assert.Len(s.T(), apps, 1) 32 | assert.Contains(s.T(), apps, app) 33 | } 34 | 35 | newApp, err := s.db.GetApplicationByToken(app.Token) 36 | if assert.NoError(s.T(), err) { 37 | assert.Equal(s.T(), app, newApp) 38 | } 39 | 40 | newApp, err = s.db.GetApplicationByID(app.ID) 41 | if assert.NoError(s.T(), err) { 42 | assert.Equal(s.T(), app, newApp) 43 | } 44 | 45 | lastUsed := time.Now().Add(-time.Hour) 46 | s.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed) 47 | newApp, err = s.db.GetApplicationByID(app.ID) 48 | if assert.NoError(s.T(), err) { 49 | assert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix()) 50 | } 51 | app.LastUsed = &lastUsed 52 | 53 | newApp.Image = "asdasd" 54 | assert.NoError(s.T(), s.db.UpdateApplication(newApp)) 55 | 56 | newApp, err = s.db.GetApplicationByID(app.ID) 57 | if assert.NoError(s.T(), err) { 58 | assert.Equal(s.T(), "asdasd", newApp.Image) 59 | } 60 | 61 | assert.NoError(s.T(), s.db.DeleteApplicationByID(app.ID)) 62 | 63 | if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { 64 | assert.Empty(s.T(), apps) 65 | } 66 | 67 | if app, err := s.db.GetApplicationByID(app.ID); assert.NoError(s.T(), err) { 68 | assert.Nil(s.T(), app) 69 | } 70 | } 71 | 72 | func (s *DatabaseSuite) TestDeleteAppDeletesMessages() { 73 | assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 55, Token: "token"})) 74 | assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 66, Token: "token2"})) 75 | assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 12, ApplicationID: 55})) 76 | assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 13, ApplicationID: 66})) 77 | assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 14, ApplicationID: 55})) 78 | assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 15, ApplicationID: 55})) 79 | 80 | assert.NoError(s.T(), s.db.DeleteApplicationByID(55)) 81 | 82 | if msg, err := s.db.GetMessageByID(12); assert.NoError(s.T(), err) { 83 | assert.Nil(s.T(), msg) 84 | } 85 | if msg, err := s.db.GetMessageByID(13); assert.NoError(s.T(), err) { 86 | assert.NotNil(s.T(), msg) 87 | } 88 | if msg, err := s.db.GetMessageByID(14); assert.NoError(s.T(), err) { 89 | assert.Nil(s.T(), msg) 90 | } 91 | if msg, err := s.db.GetMessageByID(15); assert.NoError(s.T(), err) { 92 | assert.Nil(s.T(), msg) 93 | } 94 | 95 | if msgs, err := s.db.GetMessagesByApplication(55); assert.NoError(s.T(), err) { 96 | assert.Empty(s.T(), msgs) 97 | } 98 | if msgs, err := s.db.GetMessagesByApplication(66); assert.NoError(s.T(), err) { 99 | assert.NotEmpty(s.T(), msgs) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /database/client.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | // GetClientByID returns the client for the given id or nil. 11 | func (d *GormDatabase) GetClientByID(id uint) (*model.Client, error) { 12 | client := new(model.Client) 13 | err := d.DB.Where("id = ?", id).Find(client).Error 14 | if err == gorm.ErrRecordNotFound { 15 | err = nil 16 | } 17 | if client.ID == id { 18 | return client, err 19 | } 20 | return nil, err 21 | } 22 | 23 | // GetClientByToken returns the client for the given token or nil. 24 | func (d *GormDatabase) GetClientByToken(token string) (*model.Client, error) { 25 | client := new(model.Client) 26 | err := d.DB.Where("token = ?", token).Find(client).Error 27 | if err == gorm.ErrRecordNotFound { 28 | err = nil 29 | } 30 | if client.Token == token { 31 | return client, err 32 | } 33 | return nil, err 34 | } 35 | 36 | // CreateClient creates a client. 37 | func (d *GormDatabase) CreateClient(client *model.Client) error { 38 | return d.DB.Create(client).Error 39 | } 40 | 41 | // GetClientsByUser returns all clients from a user. 42 | func (d *GormDatabase) GetClientsByUser(userID uint) ([]*model.Client, error) { 43 | var clients []*model.Client 44 | err := d.DB.Where("user_id = ?", userID).Find(&clients).Error 45 | if err == gorm.ErrRecordNotFound { 46 | err = nil 47 | } 48 | return clients, err 49 | } 50 | 51 | // DeleteClientByID deletes a client by its id. 52 | func (d *GormDatabase) DeleteClientByID(id uint) error { 53 | return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error 54 | } 55 | 56 | // UpdateClient updates a client. 57 | func (d *GormDatabase) UpdateClient(client *model.Client) error { 58 | return d.DB.Save(client).Error 59 | } 60 | 61 | // UpdateClientTokensLastUsed updates the last used timestamp of clients. 62 | func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error { 63 | return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error 64 | } 65 | -------------------------------------------------------------------------------- /database/client_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func (s *DatabaseSuite) TestClient() { 11 | if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { 12 | assert.Nil(s.T(), client, "not existing client") 13 | } 14 | if client, err := s.db.GetClientByToken("asdasd"); assert.NoError(s.T(), err) { 15 | assert.Nil(s.T(), client, "not existing client") 16 | } 17 | 18 | user := &model.User{Name: "test", Pass: []byte{1}} 19 | s.db.CreateUser(user) 20 | assert.NotEqual(s.T(), 0, user.ID) 21 | 22 | if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { 23 | assert.Empty(s.T(), clients) 24 | } 25 | 26 | client := &model.Client{UserID: user.ID, Token: "C0000000000", Name: "android"} 27 | assert.NoError(s.T(), s.db.CreateClient(client)) 28 | 29 | if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { 30 | assert.Len(s.T(), clients, 1) 31 | assert.Contains(s.T(), clients, client) 32 | } 33 | 34 | newClient, err := s.db.GetClientByID(client.ID) 35 | if assert.NoError(s.T(), err) { 36 | assert.Equal(s.T(), client, newClient) 37 | } 38 | 39 | if newClient, err := s.db.GetClientByToken(client.Token); assert.NoError(s.T(), err) { 40 | assert.Equal(s.T(), client, newClient) 41 | } 42 | 43 | updateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: "C0000000000", Name: "new_name"} 44 | s.db.UpdateClient(updateClient) 45 | if updatedClient, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) { 46 | assert.Equal(s.T(), updateClient, updatedClient) 47 | } 48 | 49 | lastUsed := time.Now().Add(-time.Hour) 50 | s.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed) 51 | newClient, err = s.db.GetClientByID(client.ID) 52 | if assert.NoError(s.T(), err) { 53 | assert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix()) 54 | } 55 | 56 | s.db.DeleteClientByID(client.ID) 57 | 58 | if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { 59 | assert.Empty(s.T(), clients) 60 | } 61 | 62 | if client, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) { 63 | assert.Nil(s.T(), client) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | "github.com/gotify/server/v2/auth/password" 9 | "github.com/gotify/server/v2/model" 10 | "github.com/jinzhu/gorm" 11 | _ "github.com/jinzhu/gorm/dialects/mysql" // enable the mysql dialect. 12 | _ "github.com/jinzhu/gorm/dialects/postgres" // enable the postgres dialect. 13 | _ "github.com/jinzhu/gorm/dialects/sqlite" // enable the sqlite3 dialect. 14 | ) 15 | 16 | var mkdirAll = os.MkdirAll 17 | 18 | // New creates a new wrapper for the gorm database framework. 19 | func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) { 20 | createDirectoryIfSqlite(dialect, connection) 21 | 22 | db, err := gorm.Open(dialect, connection) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | // We normally don't need that much connections, so we limit them. F.ex. mysql complains about 28 | // "too many connections", while load testing Gotify. 29 | db.DB().SetMaxOpenConns(10) 30 | 31 | if dialect == "sqlite3" { 32 | // We use the database connection inside the handlers from the http 33 | // framework, therefore concurrent access occurs. Sqlite cannot handle 34 | // concurrent writes, so we limit sqlite to one connection. 35 | // see https://github.com/mattn/go-sqlite3/issues/274 36 | db.DB().SetMaxOpenConns(1) 37 | } 38 | 39 | if dialect == "mysql" { 40 | // Mysql has a setting called wait_timeout, which defines the duration 41 | // after which a connection may not be used anymore. 42 | // The default for this setting on mariadb is 10 minutes. 43 | // See https://github.com/docker-library/mariadb/issues/113 44 | db.DB().SetConnMaxLifetime(9 * time.Minute) 45 | } 46 | 47 | if err := db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf)).Error; err != nil { 48 | return nil, err 49 | } 50 | 51 | if err := prepareBlobColumn(dialect, db); err != nil { 52 | return nil, err 53 | } 54 | 55 | userCount := 0 56 | db.Find(new(model.User)).Count(&userCount) 57 | if createDefaultUserIfNotExist && userCount == 0 { 58 | db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) 59 | } 60 | 61 | return &GormDatabase{DB: db}, nil 62 | } 63 | 64 | func prepareBlobColumn(dialect string, db *gorm.DB) error { 65 | blobType := "" 66 | switch dialect { 67 | case "mysql": 68 | blobType = "longblob" 69 | case "postgres": 70 | blobType = "bytea" 71 | } 72 | if blobType != "" { 73 | for _, target := range []struct { 74 | Table interface{} 75 | Column string 76 | }{ 77 | {model.Message{}, "extras"}, 78 | {model.PluginConf{}, "config"}, 79 | {model.PluginConf{}, "storage"}, 80 | } { 81 | if err := db.Model(target.Table).ModifyColumn(target.Column, blobType).Error; err != nil { 82 | return err 83 | } 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | func createDirectoryIfSqlite(dialect, connection string) { 90 | if dialect == "sqlite3" { 91 | if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) { 92 | if err := mkdirAll(filepath.Dir(connection), 0o777); err != nil { 93 | panic(err) 94 | } 95 | } 96 | } 97 | } 98 | 99 | // GormDatabase is a wrapper for the gorm framework. 100 | type GormDatabase struct { 101 | DB *gorm.DB 102 | } 103 | 104 | // Close closes the gorm database connection. 105 | func (d *GormDatabase) Close() { 106 | d.DB.Close() 107 | } 108 | -------------------------------------------------------------------------------- /database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/gotify/server/v2/test" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | func TestDatabaseSuite(t *testing.T) { 14 | suite.Run(t, new(DatabaseSuite)) 15 | } 16 | 17 | type DatabaseSuite struct { 18 | suite.Suite 19 | db *GormDatabase 20 | tmpDir test.TmpDir 21 | } 22 | 23 | func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { 24 | s.tmpDir = test.NewTmpDir("gotify_databasesuite") 25 | db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) 26 | assert.Nil(s.T(), err) 27 | s.db = db 28 | } 29 | 30 | func (s *DatabaseSuite) AfterTest(suiteName, testName string) { 31 | s.db.Close() 32 | assert.Nil(s.T(), s.tmpDir.Clean()) 33 | } 34 | 35 | func TestInvalidDialect(t *testing.T) { 36 | tmpDir := test.NewTmpDir("gotify_testinvaliddialect") 37 | defer tmpDir.Clean() 38 | _, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) 39 | assert.Error(t, err) 40 | } 41 | 42 | func TestCreateSqliteFolder(t *testing.T) { 43 | tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder") 44 | defer tmpDir.Clean() 45 | 46 | db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) 47 | assert.Nil(t, err) 48 | assert.DirExists(t, tmpDir.Path("somepath")) 49 | db.Close() 50 | } 51 | 52 | func TestWithAlreadyExistingSqliteFolder(t *testing.T) { 53 | tmpDir := test.NewTmpDir("gotify_testwithexistingfolder") 54 | defer tmpDir.Clean() 55 | 56 | db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) 57 | assert.Nil(t, err) 58 | assert.DirExists(t, tmpDir.Path("somepath")) 59 | db.Close() 60 | } 61 | 62 | func TestPanicsOnMkdirError(t *testing.T) { 63 | tmpDir := test.NewTmpDir("gotify_testpanicsonmkdirerror") 64 | defer tmpDir.Clean() 65 | mkdirAll = func(path string, perm os.FileMode) error { 66 | return errors.New("ERROR") 67 | } 68 | assert.Panics(t, func() { 69 | New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /database/message.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/gotify/server/v2/model" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | // GetMessageByID returns the messages for the given id or nil. 9 | func (d *GormDatabase) GetMessageByID(id uint) (*model.Message, error) { 10 | msg := new(model.Message) 11 | err := d.DB.Find(msg, id).Error 12 | if err == gorm.ErrRecordNotFound { 13 | err = nil 14 | } 15 | if msg.ID == id { 16 | return msg, err 17 | } 18 | return nil, err 19 | } 20 | 21 | // CreateMessage creates a message. 22 | func (d *GormDatabase) CreateMessage(message *model.Message) error { 23 | return d.DB.Create(message).Error 24 | } 25 | 26 | // GetMessagesByUser returns all messages from a user. 27 | func (d *GormDatabase) GetMessagesByUser(userID uint) ([]*model.Message, error) { 28 | var messages []*model.Message 29 | err := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID). 30 | Where("messages.application_id = applications.id").Order("id desc").Find(&messages).Error 31 | if err == gorm.ErrRecordNotFound { 32 | err = nil 33 | } 34 | return messages, err 35 | } 36 | 37 | // GetMessagesByUserSince returns limited messages from a user. 38 | // If since is 0 it will be ignored. 39 | func (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error) { 40 | var messages []*model.Message 41 | db := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID). 42 | Where("messages.application_id = applications.id").Order("id desc").Limit(limit) 43 | if since != 0 { 44 | db = db.Where("messages.id < ?", since) 45 | } 46 | err := db.Find(&messages).Error 47 | if err == gorm.ErrRecordNotFound { 48 | err = nil 49 | } 50 | return messages, err 51 | } 52 | 53 | // GetMessagesByApplication returns all messages from an application. 54 | func (d *GormDatabase) GetMessagesByApplication(tokenID uint) ([]*model.Message, error) { 55 | var messages []*model.Message 56 | err := d.DB.Where("application_id = ?", tokenID).Order("id desc").Find(&messages).Error 57 | if err == gorm.ErrRecordNotFound { 58 | err = nil 59 | } 60 | return messages, err 61 | } 62 | 63 | // GetMessagesByApplicationSince returns limited messages from an application. 64 | // If since is 0 it will be ignored. 65 | func (d *GormDatabase) GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error) { 66 | var messages []*model.Message 67 | db := d.DB.Where("application_id = ?", appID).Order("id desc").Limit(limit) 68 | if since != 0 { 69 | db = db.Where("messages.id < ?", since) 70 | } 71 | err := db.Find(&messages).Error 72 | if err == gorm.ErrRecordNotFound { 73 | err = nil 74 | } 75 | return messages, err 76 | } 77 | 78 | // DeleteMessageByID deletes a message by its id. 79 | func (d *GormDatabase) DeleteMessageByID(id uint) error { 80 | return d.DB.Where("id = ?", id).Delete(&model.Message{}).Error 81 | } 82 | 83 | // DeleteMessagesByApplication deletes all messages from an application. 84 | func (d *GormDatabase) DeleteMessagesByApplication(applicationID uint) error { 85 | return d.DB.Where("application_id = ?", applicationID).Delete(&model.Message{}).Error 86 | } 87 | 88 | // DeleteMessagesByUser deletes all messages from a user. 89 | func (d *GormDatabase) DeleteMessagesByUser(userID uint) error { 90 | app, _ := d.GetApplicationsByUser(userID) 91 | for _, app := range app { 92 | d.DeleteMessagesByApplication(app.ID) 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /database/migration_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/gotify/server/v2/test" 8 | "github.com/jinzhu/gorm" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | func TestMigration(t *testing.T) { 14 | suite.Run(t, &MigrationSuite{}) 15 | } 16 | 17 | type MigrationSuite struct { 18 | suite.Suite 19 | tmpDir test.TmpDir 20 | } 21 | 22 | func (s *MigrationSuite) BeforeTest(suiteName, testName string) { 23 | s.tmpDir = test.NewTmpDir("gotify_migrationsuite") 24 | db, err := gorm.Open("sqlite3", s.tmpDir.Path("test_obsolete.db")) 25 | assert.Nil(s.T(), err) 26 | defer db.Close() 27 | 28 | assert.Nil(s.T(), db.CreateTable(new(model.User)).Error) 29 | assert.Nil(s.T(), db.Create(&model.User{ 30 | Name: "test_user", 31 | Admin: true, 32 | }).Error) 33 | 34 | // we should not be able to create applications by now 35 | assert.False(s.T(), db.HasTable(new(model.Application))) 36 | } 37 | 38 | func (s *MigrationSuite) AfterTest(suiteName, testName string) { 39 | assert.Nil(s.T(), s.tmpDir.Clean()) 40 | } 41 | 42 | func (s *MigrationSuite) TestMigration() { 43 | db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true) 44 | assert.Nil(s.T(), err) 45 | defer db.Close() 46 | 47 | assert.True(s.T(), db.DB.HasTable(new(model.Application))) 48 | 49 | // a user already exist, not adding a new user 50 | if user, err := db.GetUserByName("admin"); assert.NoError(s.T(), err) { 51 | assert.Nil(s.T(), user) 52 | } 53 | 54 | // the old user should persist 55 | if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) { 56 | assert.Equal(s.T(), true, user.Admin) 57 | } 58 | 59 | // we should be able to create applications 60 | if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) { 61 | assert.Nil(s.T(), db.CreateApplication(&model.Application{ 62 | Token: "A1234", 63 | UserID: user.ID, 64 | Description: "this is a test application", 65 | Name: "test application", 66 | })) 67 | } 68 | if app, err := db.GetApplicationByToken("A1234"); assert.NoError(s.T(), err) { 69 | assert.Equal(s.T(), "test application", app.Name) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /database/ping.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | // Ping pings the database to verify the connection. 4 | func (d *GormDatabase) Ping() error { 5 | return d.DB.DB().Ping() 6 | } 7 | -------------------------------------------------------------------------------- /database/ping_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | ) 6 | 7 | func (s *DatabaseSuite) TestPing_onValidDB() { 8 | err := s.db.Ping() 9 | assert.NoError(s.T(), err) 10 | } 11 | 12 | func (s *DatabaseSuite) TestPing_onClosedDB() { 13 | s.db.Close() 14 | err := s.db.Ping() 15 | assert.Error(s.T(), err) 16 | } 17 | -------------------------------------------------------------------------------- /database/plugin.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/gotify/server/v2/model" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | // GetPluginConfByUser gets plugin configurations from a user. 9 | func (d *GormDatabase) GetPluginConfByUser(userid uint) ([]*model.PluginConf, error) { 10 | var plugins []*model.PluginConf 11 | err := d.DB.Where("user_id = ?", userid).Find(&plugins).Error 12 | if err == gorm.ErrRecordNotFound { 13 | err = nil 14 | } 15 | return plugins, err 16 | } 17 | 18 | // GetPluginConfByUserAndPath gets plugin configuration by user and file name. 19 | func (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error) { 20 | plugin := new(model.PluginConf) 21 | err := d.DB.Where("user_id = ? AND module_path = ?", userid, path).First(plugin).Error 22 | if err == gorm.ErrRecordNotFound { 23 | err = nil 24 | } 25 | if plugin.ModulePath == path { 26 | return plugin, err 27 | } 28 | return nil, err 29 | } 30 | 31 | // GetPluginConfByApplicationID gets plugin configuration by its internal appid. 32 | func (d *GormDatabase) GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error) { 33 | plugin := new(model.PluginConf) 34 | err := d.DB.Where("application_id = ?", appid).First(plugin).Error 35 | if err == gorm.ErrRecordNotFound { 36 | err = nil 37 | } 38 | if plugin.ApplicationID == appid { 39 | return plugin, err 40 | } 41 | return nil, err 42 | } 43 | 44 | // CreatePluginConf creates a new plugin configuration. 45 | func (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error { 46 | return d.DB.Create(p).Error 47 | } 48 | 49 | // GetPluginConfByToken gets plugin configuration by plugin token. 50 | func (d *GormDatabase) GetPluginConfByToken(token string) (*model.PluginConf, error) { 51 | plugin := new(model.PluginConf) 52 | err := d.DB.Where("token = ?", token).First(plugin).Error 53 | if err == gorm.ErrRecordNotFound { 54 | err = nil 55 | } 56 | if plugin.Token == token { 57 | return plugin, err 58 | } 59 | return nil, err 60 | } 61 | 62 | // GetPluginConfByID gets plugin configuration by plugin ID. 63 | func (d *GormDatabase) GetPluginConfByID(id uint) (*model.PluginConf, error) { 64 | plugin := new(model.PluginConf) 65 | err := d.DB.Where("id = ?", id).First(plugin).Error 66 | if err == gorm.ErrRecordNotFound { 67 | err = nil 68 | } 69 | if plugin.ID == id { 70 | return plugin, err 71 | } 72 | return nil, err 73 | } 74 | 75 | // UpdatePluginConf updates plugin configuration. 76 | func (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error { 77 | return d.DB.Save(p).Error 78 | } 79 | 80 | // DeletePluginConfByID deletes a plugin configuration by its id. 81 | func (d *GormDatabase) DeletePluginConfByID(id uint) error { 82 | return d.DB.Where("id = ?", id).Delete(&model.PluginConf{}).Error 83 | } 84 | -------------------------------------------------------------------------------- /database/plugin_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/gotify/server/v2/model" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func (s *DatabaseSuite) TestPluginConf() { 10 | plugin := model.PluginConf{ 11 | ModulePath: "github.com/gotify/example-plugin", 12 | Token: "Pabc", 13 | UserID: 1, 14 | Enabled: true, 15 | Config: nil, 16 | ApplicationID: 2, 17 | } 18 | 19 | assert.Nil(s.T(), s.db.CreatePluginConf(&plugin)) 20 | 21 | assert.Equal(s.T(), uint(1), plugin.ID) 22 | pluginConf, err := s.db.GetPluginConfByUserAndPath(1, "github.com/gotify/example-plugin") 23 | require.NoError(s.T(), err) 24 | assert.Equal(s.T(), "Pabc", pluginConf.Token) 25 | 26 | pluginConf, err = s.db.GetPluginConfByToken("Pabc") 27 | require.NoError(s.T(), err) 28 | assert.Equal(s.T(), true, pluginConf.Enabled) 29 | 30 | pluginConf, err = s.db.GetPluginConfByApplicationID(2) 31 | require.NoError(s.T(), err) 32 | assert.Equal(s.T(), "Pabc", pluginConf.Token) 33 | 34 | pluginConf, err = s.db.GetPluginConfByID(1) 35 | require.NoError(s.T(), err) 36 | assert.Equal(s.T(), "github.com/gotify/example-plugin", pluginConf.ModulePath) 37 | 38 | pluginConf, err = s.db.GetPluginConfByToken("Pnotexist") 39 | require.NoError(s.T(), err) 40 | assert.Nil(s.T(), pluginConf) 41 | 42 | pluginConf, err = s.db.GetPluginConfByID(12) 43 | require.NoError(s.T(), err) 44 | assert.Nil(s.T(), pluginConf) 45 | 46 | pluginConf, err = s.db.GetPluginConfByUserAndPath(1, "not/exist") 47 | require.NoError(s.T(), err) 48 | assert.Nil(s.T(), pluginConf) 49 | 50 | pluginConf, err = s.db.GetPluginConfByApplicationID(99) 51 | require.NoError(s.T(), err) 52 | assert.Nil(s.T(), pluginConf) 53 | 54 | pluginConfs, err := s.db.GetPluginConfByUser(1) 55 | require.NoError(s.T(), err) 56 | assert.Len(s.T(), pluginConfs, 1) 57 | 58 | pluginConfs, err = s.db.GetPluginConfByUser(0) 59 | require.NoError(s.T(), err) 60 | assert.Len(s.T(), pluginConfs, 0) 61 | 62 | testConf := `{"test_config_key":"hello"}` 63 | plugin.Enabled = false 64 | plugin.Config = []byte(testConf) 65 | assert.Nil(s.T(), s.db.UpdatePluginConf(&plugin)) 66 | pluginConf, err = s.db.GetPluginConfByToken("Pabc") 67 | require.NoError(s.T(), err) 68 | assert.Equal(s.T(), false, pluginConf.Enabled) 69 | assert.Equal(s.T(), testConf, string(pluginConf.Config)) 70 | } 71 | -------------------------------------------------------------------------------- /database/user.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/gotify/server/v2/model" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | // GetUserByName returns the user by the given name or nil. 9 | func (d *GormDatabase) GetUserByName(name string) (*model.User, error) { 10 | user := new(model.User) 11 | err := d.DB.Where("name = ?", name).Find(user).Error 12 | if err == gorm.ErrRecordNotFound { 13 | err = nil 14 | } 15 | if user.Name == name { 16 | return user, err 17 | } 18 | return nil, err 19 | } 20 | 21 | // GetUserByID returns the user by the given id or nil. 22 | func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) { 23 | user := new(model.User) 24 | err := d.DB.Find(user, id).Error 25 | if err == gorm.ErrRecordNotFound { 26 | err = nil 27 | } 28 | if user.ID == id { 29 | return user, err 30 | } 31 | return nil, err 32 | } 33 | 34 | // CountUser returns the user count which satisfies the given condition. 35 | func (d *GormDatabase) CountUser(condition ...interface{}) (int, error) { 36 | c := -1 37 | handle := d.DB.Model(new(model.User)) 38 | if len(condition) == 1 { 39 | handle = handle.Where(condition[0]) 40 | } else if len(condition) > 1 { 41 | handle = handle.Where(condition[0], condition[1:]...) 42 | } 43 | err := handle.Count(&c).Error 44 | return c, err 45 | } 46 | 47 | // GetUsers returns all users. 48 | func (d *GormDatabase) GetUsers() ([]*model.User, error) { 49 | var users []*model.User 50 | err := d.DB.Find(&users).Error 51 | return users, err 52 | } 53 | 54 | // DeleteUserByID deletes a user by its id. 55 | func (d *GormDatabase) DeleteUserByID(id uint) error { 56 | apps, _ := d.GetApplicationsByUser(id) 57 | for _, app := range apps { 58 | d.DeleteApplicationByID(app.ID) 59 | } 60 | clients, _ := d.GetClientsByUser(id) 61 | for _, client := range clients { 62 | d.DeleteClientByID(client.ID) 63 | } 64 | pluginConfs, _ := d.GetPluginConfByUser(id) 65 | for _, conf := range pluginConfs { 66 | d.DeletePluginConfByID(conf.ID) 67 | } 68 | return d.DB.Where("id = ?", id).Delete(&model.User{}).Error 69 | } 70 | 71 | // UpdateUser updates a user. 72 | func (d *GormDatabase) UpdateUser(user *model.User) error { 73 | return d.DB.Save(user).Error 74 | } 75 | 76 | // CreateUser creates a user. 77 | func (d *GormDatabase) CreateUser(user *model.User) error { 78 | return d.DB.Create(user).Error 79 | } 80 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILDKIT_SBOM_SCAN_CONTEXT=true 2 | # Suppress warning about invalid variable expansion 3 | ARG GO_VERSION=PLEASE_PROVIDE_GO_VERSION 4 | ARG DEBIAN=sid-slim 5 | 6 | # Hack to normalize platform to match the chosed build image 7 | # Get the gotify/build image tag 8 | ARG __TARGETPLATFORM_DASHES=${TARGETPLATFORM/\//-} 9 | ARG __TARGETPLATFORM_GO_NOTATION=${__TARGETPLATFORM_DASHES/arm\/v7/arm-7} 10 | 11 | # --- JS Builder --- 12 | 13 | FROM --platform=${BUILDPLATFORM} node:23 AS js-builder 14 | 15 | ARG BUILD_JS=0 16 | 17 | COPY ./Makefile /src/gotify/Makefile 18 | COPY ./ui /src/gotify/ui 19 | 20 | RUN if [ "$BUILD_JS" = "1" ]; then \ 21 | (cd /src/gotify/ui && yarn install) && \ 22 | (cd /src/gotify && make build-js) \ 23 | else \ 24 | mkdir -p /src/gotify/ui/build; \ 25 | fi 26 | 27 | # --- Go Builder --- 28 | 29 | FROM --platform=${BUILDPLATFORM} gotify/build:${GO_VERSION}-${__TARGETPLATFORM_GO_NOTATION} AS builder 30 | 31 | ARG BUILDPLATFORM 32 | ARG TARGETPLATFORM 33 | ARG BUILD_JS=0 34 | ARG RUN_TESTS=0 # 0=never, 1=native only 35 | ARG LD_FLAGS="" 36 | ENV DEBIAN_FRONTEND=noninteractive 37 | 38 | RUN apt-get update && apt-get install -yq --no-install-recommends \ 39 | ca-certificates \ 40 | git 41 | 42 | COPY . /src/gotify 43 | COPY --from=js-builder /src/gotify/ui/build /ui-build 44 | 45 | RUN if [ "$BUILD_JS" = "1" ]; then \ 46 | cp -r --update /ui-build /src/gotify/ui/build; \ 47 | fi 48 | 49 | RUN cd /src/gotify && \ 50 | if [ "$RUN_TESTS" = "1" ] && [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ 51 | go test -v ./...; \ 52 | fi && \ 53 | LD_FLAGS=${LD_FLAGS} make OUTPUT=/target/app/gotify-app _build_within_docker 54 | 55 | FROM debian:${DEBIAN} 56 | 57 | # Build-time configurables 58 | ARG GOTIFY_SERVER_EXPOSE=80 59 | ENV GOTIFY_SERVER_PORT=$GOTIFY_SERVER_EXPOSE 60 | 61 | WORKDIR /app 62 | 63 | RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -yq --no-install-recommends \ 64 | tzdata \ 65 | curl \ 66 | ca-certificates && \ 67 | rm -rf /var/lib/apt/lists/* 68 | 69 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD curl --fail http://localhost:$GOTIFY_SERVER_PORT/health || exit 1 70 | EXPOSE $GOTIFY_SERVER_EXPOSE 71 | 72 | COPY --from=builder /target / 73 | 74 | ENTRYPOINT ["./gotify-app"] 75 | -------------------------------------------------------------------------------- /docs/package.go: -------------------------------------------------------------------------------- 1 | // Package docs Gotify REST-API. 2 | // 3 | // This is the documentation of the Gotify REST-API. 4 | // 5 | // # Authentication 6 | // In Gotify there are two token types: 7 | // __clientToken__: a client is something that receives message and manages stuff like creating new tokens or delete messages. (f.ex this token should be used for an android app) 8 | // __appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script) 9 | // 10 | // The token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or 11 | // through a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`). 12 | // There is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken. 13 | // 14 | // \--- 15 | // 16 | // Found a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues) 17 | // 18 | // Schemes: http, https 19 | // Host: localhost 20 | // Version: 2.0.2 21 | // License: MIT https://github.com/gotify/server/blob/master/LICENSE 22 | // 23 | // Consumes: 24 | // - application/json 25 | // 26 | // Produces: 27 | // - application/json 28 | // 29 | // SecurityDefinitions: 30 | // appTokenQuery: 31 | // type: apiKey 32 | // name: token 33 | // in: query 34 | // clientTokenQuery: 35 | // type: apiKey 36 | // name: token 37 | // in: query 38 | // appTokenHeader: 39 | // type: apiKey 40 | // name: X-Gotify-Key 41 | // in: header 42 | // clientTokenHeader: 43 | // type: apiKey 44 | // name: X-Gotify-Key 45 | // in: header 46 | // appTokenAuthorizationHeader: 47 | // type: apiKey 48 | // name: Authorization 49 | // in: header 50 | // description: >- 51 | // Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`. 52 | // clientTokenAuthorizationHeader: 53 | // type: apiKey 54 | // name: Authorization 55 | // in: header 56 | // description: >- 57 | // Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`. 58 | // basicAuth: 59 | // type: basic 60 | // 61 | // swagger:meta 62 | package docs 63 | -------------------------------------------------------------------------------- /docs/swagger.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | _ "embed" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/location" 9 | ) 10 | 11 | //go:embed spec.json 12 | var spec string 13 | 14 | // Serve serves the documentation. 15 | func Serve(ctx *gin.Context) { 16 | base := location.Get(ctx).Host 17 | if basePathFromQuery := ctx.Query("base"); basePathFromQuery != "" { 18 | base = basePathFromQuery 19 | } 20 | ctx.Writer.WriteString(getSwaggerJSON(base)) 21 | } 22 | 23 | func getSwaggerJSON(base string) string { 24 | return strings.Replace(spec, "localhost", base, 1) 25 | } 26 | -------------------------------------------------------------------------------- /docs/swagger_test.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/gotify/server/v2/mode" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestServe(t *testing.T) { 14 | mode.Set(mode.TestDev) 15 | recorder := httptest.NewRecorder() 16 | ctx, _ := gin.CreateTestContext(recorder) 17 | withURL(ctx, "http", "example.com") 18 | 19 | ctx.Request = httptest.NewRequest("GET", "/swagger?base="+url.QueryEscape("127.0.0.1/proxy/"), nil) 20 | 21 | Serve(ctx) 22 | 23 | content := recorder.Body.String() 24 | assert.NotEmpty(t, content) 25 | assert.Contains(t, content, "127.0.0.1/proxy/") 26 | } 27 | 28 | func withURL(ctx *gin.Context, scheme, host string) { 29 | ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) 30 | } 31 | -------------------------------------------------------------------------------- /docs/ui.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | var ui = ` 6 | 7 | 8 | 9 | 10 | 11 | Swagger UI 12 | 13 | 14 | 15 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | 66 | 67 | 68 | ` 69 | 70 | // UI serves the swagger ui. 71 | func UI(ctx *gin.Context) { 72 | ctx.Writer.WriteString(ui) 73 | } 74 | -------------------------------------------------------------------------------- /docs/ui_test.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/server/v2/mode" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestUI(t *testing.T) { 13 | mode.Set(mode.TestDev) 14 | recorder := httptest.NewRecorder() 15 | ctx, _ := gin.CreateTestContext(recorder) 16 | withURL(ctx, "http", "example.com") 17 | 18 | ctx.Request = httptest.NewRequest("GET", "/swagger", nil) 19 | 20 | UI(ctx) 21 | 22 | content := recorder.Body.String() 23 | assert.NotEmpty(t, content) 24 | } 25 | -------------------------------------------------------------------------------- /error/handler.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "unicode" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/go-playground/validator/v10" 11 | "github.com/gotify/server/v2/model" 12 | ) 13 | 14 | // Handler creates a gin middleware for handling errors. 15 | func Handler() gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | c.Next() 18 | 19 | if len(c.Errors) > 0 { 20 | for _, e := range c.Errors { 21 | switch e.Type { 22 | case gin.ErrorTypeBind: 23 | errs, ok := e.Err.(validator.ValidationErrors) 24 | 25 | if !ok { 26 | writeError(c, e.Error()) 27 | return 28 | } 29 | 30 | var stringErrors []string 31 | for _, err := range errs { 32 | stringErrors = append(stringErrors, validationErrorToText(err)) 33 | } 34 | writeError(c, strings.Join(stringErrors, "; ")) 35 | default: 36 | writeError(c, e.Err.Error()) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | func validationErrorToText(e validator.FieldError) string { 44 | runes := []rune(e.Field()) 45 | runes[0] = unicode.ToLower(runes[0]) 46 | fieldName := string(runes) 47 | switch e.Tag() { 48 | case "required": 49 | return fmt.Sprintf("Field '%s' is required", fieldName) 50 | case "max": 51 | return fmt.Sprintf("Field '%s' must be less or equal to %s", fieldName, e.Param()) 52 | case "min": 53 | return fmt.Sprintf("Field '%s' must be more or equal to %s", fieldName, e.Param()) 54 | } 55 | return fmt.Sprintf("Field '%s' is not valid", fieldName) 56 | } 57 | 58 | func writeError(ctx *gin.Context, errString string) { 59 | status := http.StatusBadRequest 60 | if ctx.Writer.Status() != http.StatusOK { 61 | status = ctx.Writer.Status() 62 | } 63 | ctx.JSON(status, &model.Error{Error: http.StatusText(status), ErrorCode: status, ErrorDescription: errString}) 64 | } 65 | -------------------------------------------------------------------------------- /error/handler_test.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/gotify/server/v2/mode" 12 | "github.com/gotify/server/v2/model" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestDefaultErrorInternal(t *testing.T) { 17 | mode.Set(mode.TestDev) 18 | rec := httptest.NewRecorder() 19 | ctx, _ := gin.CreateTestContext(rec) 20 | ctx.AbortWithError(500, errors.New("something went wrong")) 21 | 22 | Handler()(ctx) 23 | 24 | assertJSONResponse(t, rec, 500, `{"errorCode":500, "errorDescription":"something went wrong", "error":"Internal Server Error"}`) 25 | } 26 | 27 | func TestBindingErrorDefault(t *testing.T) { 28 | mode.Set(mode.TestDev) 29 | rec := httptest.NewRecorder() 30 | ctx, _ := gin.CreateTestContext(rec) 31 | ctx.AbortWithError(400, errors.New("you need todo something")).SetType(gin.ErrorTypeBind) 32 | 33 | Handler()(ctx) 34 | 35 | assertJSONResponse(t, rec, 400, `{"errorCode":400, "errorDescription":"you need todo something", "error":"Bad Request"}`) 36 | } 37 | 38 | func TestDefaultErrorBadRequest(t *testing.T) { 39 | mode.Set(mode.TestDev) 40 | rec := httptest.NewRecorder() 41 | ctx, _ := gin.CreateTestContext(rec) 42 | ctx.AbortWithError(400, errors.New("you need todo something")) 43 | 44 | Handler()(ctx) 45 | 46 | assertJSONResponse(t, rec, 400, `{"errorCode":400, "errorDescription":"you need todo something", "error":"Bad Request"}`) 47 | } 48 | 49 | type testValidate struct { 50 | Username string `json:"username" binding:"required"` 51 | Mail string `json:"mail" binding:"email"` 52 | Age int `json:"age" binding:"max=100"` 53 | Limit int `json:"limit" binding:"min=50"` 54 | } 55 | 56 | func TestValidationError(t *testing.T) { 57 | mode.Set(mode.TestDev) 58 | rec := httptest.NewRecorder() 59 | ctx, _ := gin.CreateTestContext(rec) 60 | ctx.Request = httptest.NewRequest("GET", "/uri", nil) 61 | 62 | assert.Error(t, ctx.Bind(&testValidate{Age: 150, Limit: 20})) 63 | Handler()(ctx) 64 | 65 | err := new(model.Error) 66 | json.NewDecoder(rec.Body).Decode(err) 67 | assert.Equal(t, 400, rec.Code) 68 | assert.Equal(t, "Bad Request", err.Error) 69 | assert.Equal(t, 400, err.ErrorCode) 70 | assert.Contains(t, err.ErrorDescription, "Field 'username' is required") 71 | assert.Contains(t, err.ErrorDescription, "Field 'mail' is not valid") 72 | assert.Contains(t, err.ErrorDescription, "Field 'age' must be less or equal to 100") 73 | assert.Contains(t, err.ErrorDescription, "Field 'limit' must be more or equal to 50") 74 | } 75 | 76 | func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, code int, json string) { 77 | bytes, _ := io.ReadAll(rec.Body) 78 | assert.Equal(t, code, rec.Code) 79 | assert.JSONEq(t, json, string(bytes)) 80 | } 81 | -------------------------------------------------------------------------------- /error/notfound.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gotify/server/v2/model" 8 | ) 9 | 10 | // NotFound creates a gin middleware for handling page not found. 11 | func NotFound() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | c.JSON(http.StatusNotFound, &model.Error{ 14 | Error: http.StatusText(http.StatusNotFound), 15 | ErrorCode: http.StatusNotFound, 16 | ErrorDescription: "page not found", 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /error/notfound_test.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/server/v2/mode" 9 | ) 10 | 11 | func TestNotFound(t *testing.T) { 12 | mode.Set(mode.TestDev) 13 | rec := httptest.NewRecorder() 14 | ctx, _ := gin.CreateTestContext(rec) 15 | 16 | NotFound()(ctx) 17 | 18 | assertJSONResponse(t, rec, 404, `{"errorCode":404, "errorDescription":"page not found", "error":"Not Found"}`) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gotify/server/v2 2 | 3 | require ( 4 | github.com/fortytw2/leaktest v1.3.0 5 | github.com/gin-contrib/cors v1.7.5 6 | github.com/gin-contrib/gzip v1.2.3 7 | github.com/gin-gonic/gin v1.10.1 8 | github.com/go-playground/validator/v10 v10.26.0 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 11 | github.com/gotify/plugin-api v1.0.0 12 | github.com/h2non/filetype v1.1.3 13 | github.com/jinzhu/configor v1.2.2 14 | github.com/jinzhu/gorm v1.9.16 15 | github.com/robfig/cron v1.2.0 16 | github.com/stretchr/testify v1.10.0 17 | golang.org/x/crypto v0.38.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/BurntSushi/toml v1.2.0 // indirect 23 | github.com/bytedance/sonic v1.13.2 // indirect 24 | github.com/bytedance/sonic/loader v0.2.4 // indirect 25 | github.com/cloudwego/base64x v0.1.5 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 28 | github.com/gin-contrib/sse v1.0.0 // indirect 29 | github.com/go-playground/locales v0.14.1 // indirect 30 | github.com/go-playground/universal-translator v0.18.1 // indirect 31 | github.com/go-sql-driver/mysql v1.5.0 // indirect 32 | github.com/goccy/go-json v0.10.5 // indirect 33 | github.com/jinzhu/inflection v1.0.0 // indirect 34 | github.com/json-iterator/go v1.1.12 // indirect 35 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 36 | github.com/kr/text v0.2.0 // indirect 37 | github.com/leodido/go-urn v1.4.0 // indirect 38 | github.com/lib/pq v1.10.0 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mattn/go-sqlite3 v1.14.7 // indirect 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 42 | github.com/modern-go/reflect2 v1.0.2 // indirect 43 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 44 | github.com/pmezard/go-difflib v1.0.0 // indirect 45 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 46 | github.com/ugorji/go/codec v1.2.12 // indirect 47 | golang.org/x/arch v0.15.0 // indirect 48 | golang.org/x/net v0.38.0 // indirect 49 | golang.org/x/sys v0.33.0 // indirect 50 | golang.org/x/text v0.25.0 // indirect 51 | google.golang.org/protobuf v1.36.6 // indirect 52 | ) 53 | 54 | go 1.23.0 55 | 56 | toolchain go1.24.1 57 | -------------------------------------------------------------------------------- /mode/mode.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | const ( 6 | // Dev for development mode. 7 | Dev = "dev" 8 | // Prod for production mode. 9 | Prod = "prod" 10 | // TestDev used for tests. 11 | TestDev = "testdev" 12 | ) 13 | 14 | var mode = Dev 15 | 16 | // Set sets the new mode. 17 | func Set(newMode string) { 18 | mode = newMode 19 | updateGinMode() 20 | } 21 | 22 | // Get returns the current mode. 23 | func Get() string { 24 | return mode 25 | } 26 | 27 | // IsDev returns true if the current mode is dev mode. 28 | func IsDev() bool { 29 | return Get() == Dev || Get() == TestDev 30 | } 31 | 32 | func updateGinMode() { 33 | switch Get() { 34 | case Dev: 35 | gin.SetMode(gin.DebugMode) 36 | case TestDev: 37 | gin.SetMode(gin.TestMode) 38 | case Prod: 39 | gin.SetMode(gin.ReleaseMode) 40 | default: 41 | panic("unknown mode") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /mode/mode_test.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDevMode(t *testing.T) { 11 | Set(Dev) 12 | assert.Equal(t, Get(), Dev) 13 | assert.True(t, IsDev()) 14 | assert.Equal(t, gin.Mode(), gin.DebugMode) 15 | } 16 | 17 | func TestTestDevMode(t *testing.T) { 18 | Set(TestDev) 19 | assert.Equal(t, Get(), TestDev) 20 | assert.True(t, IsDev()) 21 | assert.Equal(t, gin.Mode(), gin.TestMode) 22 | } 23 | 24 | func TestProdMode(t *testing.T) { 25 | Set(Prod) 26 | assert.Equal(t, Get(), Prod) 27 | assert.False(t, IsDev()) 28 | assert.Equal(t, gin.Mode(), gin.ReleaseMode) 29 | } 30 | 31 | func TestInvalidMode(t *testing.T) { 32 | assert.Panics(t, func() { 33 | Set("asdasda") 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /model/application.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // Application Model 6 | // 7 | // The Application holds information about an app which can send notifications. 8 | // 9 | // swagger:model Application 10 | type Application struct { 11 | // The application id. 12 | // 13 | // read only: true 14 | // required: true 15 | // example: 5 16 | ID uint `gorm:"primary_key;unique_index;AUTO_INCREMENT" json:"id"` 17 | // The application token. Can be used as `appToken`. See Authentication. 18 | // 19 | // read only: true 20 | // required: true 21 | // example: AWH0wZ5r0Mbac.r 22 | Token string `gorm:"type:varchar(180);unique_index" json:"token"` 23 | UserID uint `gorm:"index" json:"-"` 24 | // The application name. This is how the application should be displayed to the user. 25 | // 26 | // required: true 27 | // example: Backup Server 28 | Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` 29 | // The description of the application. 30 | // 31 | // required: true 32 | // example: Backup server for the interwebs 33 | Description string `gorm:"type:text" form:"description" query:"description" json:"description"` 34 | // Whether the application is an internal application. Internal applications should not be deleted. 35 | // 36 | // read only: true 37 | // required: true 38 | // example: false 39 | Internal bool `form:"internal" query:"internal" json:"internal"` 40 | // The image of the application. 41 | // 42 | // read only: true 43 | // required: true 44 | // example: image/image.jpeg 45 | Image string `gorm:"type:text" json:"image"` 46 | Messages []MessageExternal `json:"-"` 47 | // The default priority of messages sent by this application. Defaults to 0. 48 | // 49 | // required: false 50 | // example: 4 51 | DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` 52 | // The last time the application token was used. 53 | // 54 | // read only: true 55 | // example: 2019-01-01T00:00:00Z 56 | LastUsed *time.Time `json:"lastUsed"` 57 | } 58 | -------------------------------------------------------------------------------- /model/client.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // Client Model 6 | // 7 | // The Client holds information about a device which can receive notifications (and other stuff). 8 | // 9 | // swagger:model Client 10 | type Client struct { 11 | // The client id. 12 | // 13 | // read only: true 14 | // required: true 15 | // example: 5 16 | ID uint `gorm:"primary_key;unique_index;AUTO_INCREMENT" json:"id"` 17 | // The client token. Can be used as `clientToken`. See Authentication. 18 | // 19 | // read only: true 20 | // required: true 21 | // example: CWH0wZ5r0Mbac.r 22 | Token string `gorm:"type:varchar(180);unique_index" json:"token"` 23 | UserID uint `gorm:"index" json:"-"` 24 | // The client name. This is how the client should be displayed to the user. 25 | // 26 | // required: true 27 | // example: Android Phone 28 | Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` 29 | // The last time the client token was used. 30 | // 31 | // read only: true 32 | // example: 2019-01-01T00:00:00Z 33 | LastUsed *time.Time `json:"lastUsed"` 34 | } 35 | -------------------------------------------------------------------------------- /model/error.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Error Model 4 | // 5 | // The Error contains error relevant information. 6 | // 7 | // swagger:model Error 8 | type Error struct { 9 | // The general error message 10 | // 11 | // required: true 12 | // example: Unauthorized 13 | Error string `json:"error"` 14 | // The http error code. 15 | // 16 | // required: true 17 | // example: 401 18 | ErrorCode int `json:"errorCode"` 19 | // The http error code. 20 | // 21 | // required: true 22 | // example: you need to provide a valid access token or user credentials to access this api 23 | ErrorDescription string `json:"errorDescription"` 24 | } 25 | -------------------------------------------------------------------------------- /model/health.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Health Model 4 | // 5 | // Health represents how healthy the application is. 6 | // 7 | // swagger:model Health 8 | type Health struct { 9 | // The health of the overall application. 10 | // 11 | // required: true 12 | // example: green 13 | Health string `json:"health"` 14 | // The health of the database connection. 15 | // 16 | // required: true 17 | // example: green 18 | Database string `json:"database"` 19 | } 20 | 21 | const ( 22 | // StatusGreen everything is alright. 23 | StatusGreen = "green" 24 | // StatusOrange some things are alright. 25 | StatusOrange = "orange" 26 | // StatusRed nothing is alright. 27 | StatusRed = "red" 28 | ) 29 | -------------------------------------------------------------------------------- /model/message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Message holds information about a message. 8 | type Message struct { 9 | ID uint `gorm:"AUTO_INCREMENT;primary_key;index"` 10 | ApplicationID uint 11 | Message string `gorm:"type:text"` 12 | Title string `gorm:"type:text"` 13 | Priority int 14 | Extras []byte 15 | Date time.Time 16 | } 17 | 18 | // MessageExternal Model 19 | // 20 | // The MessageExternal holds information about a message which was sent by an Application. 21 | // 22 | // swagger:model Message 23 | type MessageExternal struct { 24 | // The message id. 25 | // 26 | // read only: true 27 | // required: true 28 | // example: 25 29 | ID uint `json:"id"` 30 | // The application id that send this message. 31 | // 32 | // read only: true 33 | // required: true 34 | // example: 5 35 | ApplicationID uint `json:"appid"` 36 | // The message. Markdown (excluding html) is allowed. 37 | // 38 | // required: true 39 | // example: **Backup** was successfully finished. 40 | Message string `form:"message" query:"message" json:"message" binding:"required"` 41 | // The title of the message. 42 | // 43 | // example: Backup 44 | Title string `form:"title" query:"title" json:"title"` 45 | // The priority of the message. If unset, then the default priority of the 46 | // application will be used. 47 | // 48 | // example: 2 49 | Priority *int `form:"priority" query:"priority" json:"priority"` 50 | // The extra data sent along the message. 51 | // 52 | // The extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type. 53 | // 54 | // The keys should be in the following format: <top-namespace>::[<sub-namespace>::]<action> 55 | // 56 | // These namespaces are reserved and might be used in the official clients: gotify android ios web server client. Do not use them for other purposes. 57 | // 58 | // example: {"home::appliances::thermostat::change_temperature":{"temperature":23},"home::appliances::lighting::on":{"brightness":15}} 59 | Extras map[string]interface{} `form:"-" query:"-" json:"extras,omitempty"` 60 | // The date the message was created. 61 | // 62 | // read only: true 63 | // required: true 64 | // example: 2018-02-27T19:36:10.5045044+01:00 65 | Date time.Time `json:"date"` 66 | } 67 | -------------------------------------------------------------------------------- /model/paging.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Paging Model 4 | // 5 | // The Paging holds information about the limit and making requests to the next page. 6 | // 7 | // swagger:model Paging 8 | type Paging struct { 9 | // The request url for the next page. Empty/Null when no next page is available. 10 | // 11 | // read only: true 12 | // required: false 13 | // example: http://example.com/message?limit=50&since=123456 14 | Next string `json:"next,omitempty"` 15 | // The amount of messages that got returned in the current request. 16 | // 17 | // read only: true 18 | // required: true 19 | // example: 5 20 | Size int `json:"size"` 21 | // The ID of the last message returned in the current request. Use this as alternative to the next link. 22 | // 23 | // read only: true 24 | // required: true 25 | // example: 5 26 | // min: 0 27 | Since uint `json:"since"` 28 | // The limit of the messages for the current request. 29 | // 30 | // read only: true 31 | // required: true 32 | // min: 1 33 | // max: 200 34 | // example: 123 35 | Limit int `json:"limit"` 36 | } 37 | 38 | // PagedMessages Model 39 | // 40 | // Wrapper for the paging and the messages. 41 | // 42 | // swagger:model PagedMessages 43 | type PagedMessages struct { 44 | // The paging of the messages. 45 | // 46 | // read only: true 47 | // required: true 48 | Paging Paging `json:"paging"` 49 | // The messages. 50 | // 51 | // read only: true 52 | // required: true 53 | Messages []*MessageExternal `json:"messages"` 54 | } 55 | -------------------------------------------------------------------------------- /model/pluginconf.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // PluginConf holds information about the plugin. 4 | type PluginConf struct { 5 | ID uint `gorm:"primary_key;AUTO_INCREMENT;index"` 6 | UserID uint 7 | ModulePath string `gorm:"type:text"` 8 | Token string `gorm:"type:varchar(180);unique_index"` 9 | ApplicationID uint 10 | Enabled bool 11 | Config []byte 12 | Storage []byte 13 | } 14 | 15 | // PluginConfExternal Model 16 | // 17 | // Holds information about a plugin instance for one user. 18 | // 19 | // swagger:model PluginConf 20 | type PluginConfExternal struct { 21 | // The plugin id. 22 | // 23 | // read only: true 24 | // required: true 25 | // example: 25 26 | ID uint `json:"id"` 27 | // The plugin name. 28 | // 29 | // read only: true 30 | // required: true 31 | // example: RSS poller 32 | Name string `json:"name"` 33 | // The user name. For login. 34 | // 35 | // required: true 36 | // example: P1234 37 | Token string `binding:"required" json:"token" query:"token" form:"token"` 38 | // The module path of the plugin. 39 | // 40 | // example: github.com/gotify/server/plugin/example/echo 41 | // read only: true 42 | // required: true 43 | ModulePath string `json:"modulePath" form:"modulePath" query:"modulePath"` 44 | // The author of the plugin. 45 | // 46 | // example: jmattheis 47 | // read only: true 48 | Author string `json:"author,omitempty" form:"author" query:"author"` 49 | // The website of the plugin. 50 | // 51 | // example: gotify.net 52 | // read only: true 53 | Website string `json:"website,omitempty" form:"website" query:"website"` 54 | // The license of the plugin. 55 | // 56 | // example: MIT 57 | // read only: true 58 | License string `json:"license,omitempty" form:"license" query:"license"` 59 | // Whether the plugin instance is enabled. 60 | // 61 | // example: true 62 | // required: true 63 | Enabled bool `json:"enabled"` 64 | // Capabilities the plugin provides 65 | // 66 | // example: ["webhook","display"] 67 | // required: true 68 | Capabilities []string `json:"capabilities"` 69 | } 70 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // The User holds information about the credentials of a user and its application and client tokens. 4 | type User struct { 5 | ID uint `gorm:"primary_key;unique_index;AUTO_INCREMENT"` 6 | Name string `gorm:"type:varchar(180);unique_index"` 7 | Pass []byte 8 | Admin bool 9 | Applications []Application 10 | Clients []Client 11 | Plugins []PluginConf 12 | } 13 | 14 | // UserExternal Model 15 | // 16 | // The User holds information about permission and other stuff. 17 | // 18 | // swagger:model User 19 | type UserExternal struct { 20 | // The user id. 21 | // 22 | // read only: true 23 | // required: true 24 | // example: 25 25 | ID uint `json:"id"` 26 | // The user name. For login. 27 | // 28 | // required: true 29 | // example: unicorn 30 | Name string `binding:"required" json:"name" query:"name" form:"name"` 31 | // If the user is an administrator. 32 | // 33 | // required: true 34 | // example: true 35 | Admin bool `json:"admin" form:"admin" query:"admin"` 36 | } 37 | 38 | // CreateUserExternal Model 39 | // 40 | // Used for user creation. 41 | // 42 | // swagger:model CreateUserExternal 43 | type CreateUserExternal struct { 44 | // The user name. For login. 45 | // 46 | // required: true 47 | // example: unicorn 48 | Name string `binding:"required" json:"name" query:"name" form:"name"` 49 | // If the user is an administrator. 50 | // 51 | // required: true 52 | // example: true 53 | Admin bool `json:"admin" form:"admin" query:"admin"` 54 | // The user password. For login. 55 | // 56 | // required: true 57 | // example: nrocinu 58 | Pass string `json:"pass,omitempty" form:"pass" query:"pass" binding:"required"` 59 | } 60 | 61 | // UpdateUserExternal Model 62 | // 63 | // Used for updating a user. 64 | // 65 | // swagger:model UpdateUserExternal 66 | type UpdateUserExternal struct { 67 | // The user name. For login. 68 | // 69 | // required: true 70 | // example: unicorn 71 | Name string `binding:"required" json:"name" query:"name" form:"name"` 72 | // If the user is an administrator. 73 | // 74 | // required: true 75 | // example: true 76 | Admin bool `json:"admin" form:"admin" query:"admin"` 77 | // The user password. For login. Empty for using old password 78 | // 79 | // example: nrocinu 80 | Pass string `json:"pass,omitempty" form:"pass" query:"pass"` 81 | } 82 | 83 | // UserExternalPass Model 84 | // 85 | // The Password for updating the user. 86 | // 87 | // swagger:model UserPass 88 | type UserExternalPass struct { 89 | // The user password. For login. 90 | // 91 | // required: true 92 | // example: nrocinu 93 | Pass string `json:"pass,omitempty" form:"pass" query:"pass" binding:"required"` 94 | } 95 | -------------------------------------------------------------------------------- /model/version.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // VersionInfo Model 4 | // 5 | // swagger:model VersionInfo 6 | type VersionInfo struct { 7 | // The current version. 8 | // 9 | // required: true 10 | // example: 5.2.6 11 | Version string `json:"version"` 12 | // The git commit hash on which this binary was built. 13 | // 14 | // required: true 15 | // example: ae9512b6b6feea56a110d59a3353ea3b9c293864 16 | Commit string `json:"commit"` 17 | // The date on which this binary was built. 18 | // 19 | // required: true 20 | // example: 2018-02-27T19:36:10.5045044+01:00 21 | BuildDate string `json:"buildDate"` 22 | } 23 | -------------------------------------------------------------------------------- /plugin/compat/instance.go: -------------------------------------------------------------------------------- 1 | package compat 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // Capability is a capability the plugin provides. 10 | type Capability string 11 | 12 | const ( 13 | // Messenger sends notifications. 14 | Messenger = Capability("messenger") 15 | // Configurer are consigurables. 16 | Configurer = Capability("configurer") 17 | // Storager stores data. 18 | Storager = Capability("storager") 19 | // Webhooker registers webhooks. 20 | Webhooker = Capability("webhooker") 21 | // Displayer displays instructions. 22 | Displayer = Capability("displayer") 23 | ) 24 | 25 | // PluginInstance is an encapsulation layer of plugin instances of different backends. 26 | type PluginInstance interface { 27 | Enable() error 28 | Disable() error 29 | 30 | // GetDisplay see Displayer 31 | GetDisplay(location *url.URL) string 32 | 33 | // DefaultConfig see Configurer 34 | DefaultConfig() interface{} 35 | // ValidateAndSetConfig see Configurer 36 | ValidateAndSetConfig(c interface{}) error 37 | 38 | // SetMessageHandler see Messenger#SetMessageHandler 39 | SetMessageHandler(h MessageHandler) 40 | 41 | // RegisterWebhook see Webhooker#RegisterWebhook 42 | RegisterWebhook(basePath string, mux *gin.RouterGroup) 43 | 44 | // SetStorageHandler see Storager#SetStorageHandler. 45 | SetStorageHandler(handler StorageHandler) 46 | 47 | // Returns the supported modules, f.ex. storager 48 | Supports() Capabilities 49 | } 50 | 51 | // HasSupport tests a PluginInstance for a capability. 52 | func HasSupport(p PluginInstance, toCheck Capability) bool { 53 | for _, module := range p.Supports() { 54 | if module == toCheck { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | 61 | // Capabilities is a slice of module. 62 | type Capabilities []Capability 63 | 64 | // Strings converts []Module to []string. 65 | func (m Capabilities) Strings() []string { 66 | var result []string 67 | for _, module := range m { 68 | result = append(result, string(module)) 69 | } 70 | return result 71 | } 72 | 73 | // MessageHandler see plugin.MessageHandler. 74 | type MessageHandler interface { 75 | // SendMessage see plugin.MessageHandler 76 | SendMessage(msg Message) error 77 | } 78 | 79 | // StorageHandler see plugin.StorageHandler. 80 | type StorageHandler interface { 81 | Save(b []byte) error 82 | Load() ([]byte, error) 83 | } 84 | 85 | // Message describes a message to be send by MessageHandler#SendMessage. 86 | type Message struct { 87 | Message string 88 | Title string 89 | Priority int 90 | Extras map[string]interface{} 91 | } 92 | -------------------------------------------------------------------------------- /plugin/compat/plugin.go: -------------------------------------------------------------------------------- 1 | package compat 2 | 3 | // Plugin is an abstraction of plugin handler. 4 | type Plugin interface { 5 | PluginInfo() Info 6 | NewPluginInstance(ctx UserContext) PluginInstance 7 | APIVersion() string 8 | } 9 | 10 | // Info is the plugin info. 11 | type Info struct { 12 | Version string 13 | Author string 14 | Name string 15 | Website string 16 | Description string 17 | License string 18 | ModulePath string 19 | } 20 | 21 | func (c Info) String() string { 22 | if c.Name != "" { 23 | return c.Name 24 | } 25 | return c.ModulePath 26 | } 27 | 28 | // UserContext is the user context used to create plugin instance. 29 | type UserContext struct { 30 | ID uint 31 | Name string 32 | Admin bool 33 | } 34 | -------------------------------------------------------------------------------- /plugin/compat/plugin_test.go: -------------------------------------------------------------------------------- 1 | package compat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | const examplePluginPath = "github.com/gotify/server/v2/plugin/example/echo" 10 | 11 | func TestPluginInfoStringer(t *testing.T) { 12 | info := Info{ 13 | ModulePath: examplePluginPath, 14 | } 15 | assert.Equal(t, examplePluginPath, info.String()) 16 | info.Name = "test name" 17 | assert.Equal(t, "test name", info.String()) 18 | } 19 | -------------------------------------------------------------------------------- /plugin/compat/wrap.go: -------------------------------------------------------------------------------- 1 | package compat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "plugin" 7 | 8 | papiv1 "github.com/gotify/plugin-api" 9 | ) 10 | 11 | // Wrap wraps around a raw go plugin to provide typesafe access. 12 | func Wrap(p *plugin.Plugin) (Plugin, error) { 13 | getInfoHandle, err := p.Lookup("GetGotifyPluginInfo") 14 | if err != nil { 15 | return nil, errors.New("missing GetGotifyPluginInfo symbol") 16 | } 17 | switch getInfoHandle := getInfoHandle.(type) { 18 | case func() papiv1.Info: 19 | v1 := PluginV1{} 20 | 21 | v1.Info = getInfoHandle() 22 | newInstanceHandle, err := p.Lookup("NewGotifyPluginInstance") 23 | if err != nil { 24 | return nil, errors.New("missing NewGotifyPluginInstance symbol") 25 | } 26 | constructor, ok := newInstanceHandle.(func(ctx papiv1.UserContext) papiv1.Plugin) 27 | if !ok { 28 | return nil, fmt.Errorf("NewGotifyPluginInstance signature mismatch, func(ctx plugin.UserContext) plugin.Plugin expected, got %T", newInstanceHandle) 29 | } 30 | v1.Constructor = constructor 31 | return v1, nil 32 | default: 33 | return nil, fmt.Errorf("unknown plugin version (unrecogninzed GetGotifyPluginInfo signature %T)", getInfoHandle) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /plugin/compat/wrap_test_norace.go: -------------------------------------------------------------------------------- 1 | //go:build !race 2 | // +build !race 3 | 4 | package compat 5 | 6 | var extraGoBuildFlags = []string{} 7 | -------------------------------------------------------------------------------- /plugin/compat/wrap_test_race.go: -------------------------------------------------------------------------------- 1 | // +build race 2 | 3 | package compat 4 | 5 | var extraGoBuildFlags = []string{"-race"} 6 | -------------------------------------------------------------------------------- /plugin/example/clock/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/plugin-api" 7 | "github.com/robfig/cron" 8 | ) 9 | 10 | // GetGotifyPluginInfo returns gotify plugin info 11 | func GetGotifyPluginInfo() plugin.Info { 12 | return plugin.Info{ 13 | Name: "clock", 14 | Description: "Sends an hourly reminder", 15 | ModulePath: "github.com/gotify/server/v2/example/clock", 16 | } 17 | } 18 | 19 | // Plugin is plugin instance 20 | type Plugin struct { 21 | msgHandler plugin.MessageHandler 22 | enabled bool 23 | cronHandler *cron.Cron 24 | } 25 | 26 | // Enable implements plugin.Plugin 27 | func (c *Plugin) Enable() error { 28 | c.enabled = true 29 | c.cronHandler = cron.New() 30 | c.cronHandler.AddFunc("0 0 * * *", func() { 31 | c.msgHandler.SendMessage(plugin.Message{ 32 | Title: "Tick Tock!", 33 | Message: time.Now().Format("It is 15:04:05 now."), 34 | }) 35 | }) 36 | c.cronHandler.Start() 37 | return nil 38 | } 39 | 40 | // Disable implements plugin.Plugin 41 | func (c *Plugin) Disable() error { 42 | if c.cronHandler != nil { 43 | c.cronHandler.Stop() 44 | } 45 | c.enabled = false 46 | return nil 47 | } 48 | 49 | // SetMessageHandler implements plugin.Messenger. 50 | func (c *Plugin) SetMessageHandler(h plugin.MessageHandler) { 51 | c.msgHandler = h 52 | } 53 | 54 | // NewGotifyPluginInstance creates a plugin instance for a user context. 55 | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { 56 | p := &Plugin{} 57 | 58 | return p 59 | } 60 | 61 | func main() { 62 | panic("this should be built as go plugin") 63 | } 64 | -------------------------------------------------------------------------------- /plugin/example/echo/echo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/gotify/plugin-api" 11 | ) 12 | 13 | // GetGotifyPluginInfo returns gotify plugin info. 14 | func GetGotifyPluginInfo() plugin.Info { 15 | return plugin.Info{ 16 | ModulePath: "github.com/gotify/server/v2/plugin/example/echo", 17 | Name: "test plugin", 18 | } 19 | } 20 | 21 | // EchoPlugin is the gotify plugin instance. 22 | type EchoPlugin struct { 23 | msgHandler plugin.MessageHandler 24 | storageHandler plugin.StorageHandler 25 | config *Config 26 | basePath string 27 | } 28 | 29 | // SetStorageHandler implements plugin.Storager 30 | func (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) { 31 | c.storageHandler = h 32 | } 33 | 34 | // SetMessageHandler implements plugin.Messenger. 35 | func (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) { 36 | c.msgHandler = h 37 | } 38 | 39 | // Storage defines the plugin storage scheme 40 | type Storage struct { 41 | CalledTimes int `json:"called_times"` 42 | } 43 | 44 | // Config defines the plugin config scheme 45 | type Config struct { 46 | MagicString string `yaml:"magic_string"` 47 | } 48 | 49 | // DefaultConfig implements plugin.Configurer 50 | func (c *EchoPlugin) DefaultConfig() interface{} { 51 | return &Config{ 52 | MagicString: "hello world", 53 | } 54 | } 55 | 56 | // ValidateAndSetConfig implements plugin.Configurer 57 | func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error { 58 | c.config = config.(*Config) 59 | return nil 60 | } 61 | 62 | // Enable enables the plugin. 63 | func (c *EchoPlugin) Enable() error { 64 | log.Println("echo plugin enabled") 65 | return nil 66 | } 67 | 68 | // Disable disables the plugin. 69 | func (c *EchoPlugin) Disable() error { 70 | log.Println("echo plugin disbled") 71 | return nil 72 | } 73 | 74 | // RegisterWebhook implements plugin.Webhooker. 75 | func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) { 76 | c.basePath = baseURL 77 | g.GET("/echo", func(ctx *gin.Context) { 78 | 79 | storage, _ := c.storageHandler.Load() 80 | conf := new(Storage) 81 | json.Unmarshal(storage, conf) 82 | conf.CalledTimes++ 83 | newStorage, _ := json.Marshal(conf) 84 | c.storageHandler.Save(newStorage) 85 | 86 | c.msgHandler.SendMessage(plugin.Message{ 87 | Title: "Hello received", 88 | Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes), 89 | Priority: 2, 90 | Extras: map[string]interface{}{ 91 | "plugin::name": "echo", 92 | }, 93 | }) 94 | ctx.Writer.WriteString(fmt.Sprintf("Magic string is: %s\r\nEcho server running at %secho", c.config.MagicString, c.basePath)) 95 | }) 96 | } 97 | 98 | // GetDisplay implements plugin.Displayer. 99 | func (c *EchoPlugin) GetDisplay(location *url.URL) string { 100 | loc := &url.URL{ 101 | Path: c.basePath, 102 | } 103 | if location != nil { 104 | loc.Scheme = location.Scheme 105 | loc.Host = location.Host 106 | } 107 | loc = loc.ResolveReference(&url.URL{ 108 | Path: "echo", 109 | }) 110 | return "Echo plugin running at: " + loc.String() 111 | } 112 | 113 | // NewGotifyPluginInstance creates a plugin instance for a user context. 114 | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { 115 | return &EchoPlugin{} 116 | } 117 | 118 | func main() { 119 | panic("this should be built as go plugin") 120 | } 121 | -------------------------------------------------------------------------------- /plugin/example/minimal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gotify/plugin-api" 5 | ) 6 | 7 | // GetGotifyPluginInfo returns gotify plugin info 8 | func GetGotifyPluginInfo() plugin.Info { 9 | return plugin.Info{ 10 | Name: "minimal plugin", 11 | ModulePath: "github.com/gotify/server/v2/example/minimal", 12 | } 13 | } 14 | 15 | // Plugin is plugin instance 16 | type Plugin struct{} 17 | 18 | // Enable implements plugin.Plugin 19 | func (c *Plugin) Enable() error { 20 | return nil 21 | } 22 | 23 | // Disable implements plugin.Plugin 24 | func (c *Plugin) Disable() error { 25 | return nil 26 | } 27 | 28 | // NewGotifyPluginInstance creates a plugin instance for a user context. 29 | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { 30 | return &Plugin{} 31 | } 32 | 33 | func main() { 34 | panic("this should be built as go plugin") 35 | } 36 | -------------------------------------------------------------------------------- /plugin/manager_test_norace.go: -------------------------------------------------------------------------------- 1 | //go:build !race 2 | // +build !race 3 | 4 | package plugin 5 | 6 | var extraGoBuildFlags = []string{} 7 | -------------------------------------------------------------------------------- /plugin/manager_test_race.go: -------------------------------------------------------------------------------- 1 | // +build race 2 | 3 | package plugin 4 | 5 | var extraGoBuildFlags = []string{"-race"} 6 | -------------------------------------------------------------------------------- /plugin/messagehandler.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gotify/server/v2/model" 7 | "github.com/gotify/server/v2/plugin/compat" 8 | ) 9 | 10 | type redirectToChannel struct { 11 | ApplicationID uint 12 | UserID uint 13 | Messages chan MessageWithUserID 14 | } 15 | 16 | // MessageWithUserID encapsulates a message with a given user ID. 17 | type MessageWithUserID struct { 18 | Message model.MessageExternal 19 | UserID uint 20 | } 21 | 22 | // SendMessage sends a message to the underlying message channel. 23 | func (c redirectToChannel) SendMessage(msg compat.Message) error { 24 | c.Messages <- MessageWithUserID{ 25 | Message: model.MessageExternal{ 26 | ApplicationID: c.ApplicationID, 27 | Message: msg.Message, 28 | Title: msg.Title, 29 | Priority: &msg.Priority, 30 | Date: time.Now(), 31 | Extras: msg.Extras, 32 | }, 33 | UserID: c.UserID, 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /plugin/pluginenabled.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func requirePluginEnabled(id uint, db Database) gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | conf, err := db.GetPluginConfByID(id) 12 | if err != nil { 13 | c.AbortWithError(500, err) 14 | return 15 | } 16 | if conf == nil || !conf.Enabled { 17 | c.AbortWithError(400, errors.New("plugin is disabled")) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /plugin/pluginenabled_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gotify/server/v2/model" 9 | "github.com/gotify/server/v2/test/testdb" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRequirePluginEnabled(t *testing.T) { 14 | db := testdb.NewDBWithDefaultUser(t) 15 | conf := &model.PluginConf{ 16 | ID: 1, 17 | UserID: 1, 18 | Enabled: true, 19 | } 20 | db.CreatePluginConf(conf) 21 | 22 | g := gin.New() 23 | 24 | mux := g.Group("/", requirePluginEnabled(1, db)) 25 | 26 | mux.GET("/", func(c *gin.Context) { 27 | c.Status(200) 28 | }) 29 | 30 | getCode := func() int { 31 | r := httptest.NewRequest("GET", "/", nil) 32 | w := httptest.NewRecorder() 33 | g.ServeHTTP(w, r) 34 | return w.Code 35 | } 36 | 37 | assert.Equal(t, 200, getCode()) 38 | 39 | conf.Enabled = false 40 | db.UpdatePluginConf(conf) 41 | assert.Equal(t, 400, getCode()) 42 | } 43 | -------------------------------------------------------------------------------- /plugin/storagehandler.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type dbStorageHandler struct { 4 | pluginID uint 5 | db Database 6 | } 7 | 8 | func (c dbStorageHandler) Save(b []byte) error { 9 | conf, err := c.db.GetPluginConfByID(c.pluginID) 10 | if err != nil { 11 | return err 12 | } 13 | conf.Storage = b 14 | return c.db.UpdatePluginConf(conf) 15 | } 16 | 17 | func (c dbStorageHandler) Load() ([]byte, error) { 18 | pluginConf, err := c.db.GetPluginConfByID(c.pluginID) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return pluginConf.Storage, nil 23 | } 24 | -------------------------------------------------------------------------------- /plugin/testing/broken/cantinstantiate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gotify/plugin-api" 7 | ) 8 | 9 | // GetGotifyPluginInfo returns gotify plugin info 10 | func GetGotifyPluginInfo() plugin.Info { 11 | return plugin.Info{ 12 | ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/noinstance", 13 | } 14 | } 15 | 16 | // Plugin is plugin instance 17 | type Plugin struct{} 18 | 19 | // Enable implements plugin.Plugin 20 | func (c *Plugin) Enable() error { 21 | return errors.New("cannot instantiate") 22 | } 23 | 24 | // Disable implements plugin.Plugin 25 | func (c *Plugin) Disable() error { 26 | return nil 27 | } 28 | 29 | // NewGotifyPluginInstance creates a plugin instance for a user context. 30 | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { 31 | return &Plugin{} 32 | } 33 | 34 | func main() { 35 | panic("this is a broken plugin for testing purposes") 36 | } 37 | -------------------------------------------------------------------------------- /plugin/testing/broken/malformedconstructor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gotify/plugin-api" 5 | ) 6 | 7 | // GetGotifyPluginInfo returns gotify plugin info 8 | func GetGotifyPluginInfo() plugin.Info { 9 | return plugin.Info{ 10 | ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/malformedconstructor", 11 | } 12 | } 13 | 14 | // Plugin is plugin instance 15 | type Plugin struct{} 16 | 17 | // Enable implements plugin.Plugin 18 | func (c *Plugin) Enable() error { 19 | return nil 20 | } 21 | 22 | // Disable implements plugin.Plugin 23 | func (c *Plugin) Disable() error { 24 | return nil 25 | } 26 | 27 | // NewGotifyPluginInstance creates a plugin instance for a user context. 28 | func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} { 29 | return &Plugin{} 30 | } 31 | 32 | func main() { 33 | panic("this is a broken plugin for testing purposes") 34 | } 35 | -------------------------------------------------------------------------------- /plugin/testing/broken/noinstance/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gotify/plugin-api" 5 | ) 6 | 7 | // GetGotifyPluginInfo returns gotify plugin info 8 | func GetGotifyPluginInfo() plugin.Info { 9 | return plugin.Info{ 10 | ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/noinstance", 11 | } 12 | } 13 | 14 | func main() { 15 | panic("this is a broken plugin for testing purposes") 16 | } 17 | -------------------------------------------------------------------------------- /plugin/testing/broken/nothing/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | panic("this is a broken plugin for testing purposes") 5 | } 6 | -------------------------------------------------------------------------------- /plugin/testing/broken/unknowninfo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // GetGotifyPluginInfo returns gotify plugin info 4 | func GetGotifyPluginInfo() string { 5 | return "github.com/gotify/server/v2/plugin/testing/broken/unknowninfo" 6 | } 7 | 8 | func main() { 9 | panic("this is a broken plugin for testing purposes") 10 | } 11 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticCommits", 6 | ":semanticCommitTypeAll(chore)" 7 | ], 8 | "labels": [ 9 | "dependencies" 10 | ], 11 | "reviewersFromCodeOwners": true, 12 | "enabledManagers": [ 13 | "gomod", 14 | "github-actions", 15 | "dockerfile", 16 | "custom.regex" 17 | ], 18 | "customManagers": [ 19 | { 20 | "customType": "regex", 21 | "fileMatch": [ 22 | "^GO_VERSION$" 23 | ], 24 | "depTypeTemplate": "language", 25 | "matchStrings": [ 26 | "^(?[0-9.]+)" 27 | ], 28 | "extractVersionTemplate": "^(?.+)-linux-amd64$", 29 | "depNameTemplate": "docker.io/gotify/build", 30 | "autoReplaceStringTemplate": "{{{newValue}}}", 31 | "datasourceTemplate": "docker", 32 | "versioningTemplate": "docker" 33 | }, 34 | { 35 | "customType": "regex", 36 | "fileMatch": [ 37 | "^go.mod$" 38 | ], 39 | "depTypeTemplate": "language", 40 | "matchStrings": [ 41 | "toolchain go(?[0-9.]+)\\n" 42 | ], 43 | "extractVersionTemplate": "^(?.+)-linux-amd64$", 44 | "depNameTemplate": "docker.io/gotify/build", 45 | "autoReplaceStringTemplate": "toolchain go{{{newValue}}}\n", 46 | "datasourceTemplate": "docker", 47 | "versioningTemplate": "docker" 48 | } 49 | ], 50 | "ignoreDeps": [ 51 | "go" 52 | ], 53 | "packageRules": [ 54 | { 55 | "matchManagers": [ 56 | "gomod" 57 | ], 58 | "matchUpdateTypes": [ 59 | "minor", 60 | "patch" 61 | ], 62 | "groupName": "Bump Go dependencies", 63 | "groupSlug": "bump-dependencies-go" 64 | }, 65 | { 66 | "matchDepNames": [ 67 | "github.com/gotify/build" 68 | ], 69 | "groupName": "Bump gotify/build", 70 | "groupSlug": "bump-gotify-build" 71 | } 72 | ], 73 | "postUpdateOptions": [ 74 | "gomodTidy" 75 | ] 76 | } -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/gotify/server/v2/config" 16 | "golang.org/x/crypto/acme/autocert" 17 | ) 18 | 19 | // Run starts the http server and if configured a https server. 20 | func Run(router http.Handler, conf *config.Configuration) error { 21 | shutdown := make(chan error) 22 | go doShutdownOnSignal(shutdown) 23 | 24 | httpListener, err := startListening("plain connection", conf.Server.ListenAddr, conf.Server.Port, conf.Server.KeepAlivePeriodSeconds) 25 | if err != nil { 26 | return err 27 | } 28 | defer httpListener.Close() 29 | 30 | s := &http.Server{Handler: router} 31 | if conf.Server.SSL.Enabled { 32 | if conf.Server.SSL.LetsEncrypt.Enabled { 33 | applyLetsEncrypt(s, conf) 34 | } 35 | 36 | httpsListener, err := startListening("TLS connection", conf.Server.SSL.ListenAddr, conf.Server.SSL.Port, conf.Server.KeepAlivePeriodSeconds) 37 | if err != nil { 38 | return err 39 | } 40 | defer httpsListener.Close() 41 | 42 | go func() { 43 | err := s.ServeTLS(httpsListener, conf.Server.SSL.CertFile, conf.Server.SSL.CertKey) 44 | doShutdown(shutdown, err) 45 | }() 46 | } 47 | go func() { 48 | err := s.Serve(httpListener) 49 | doShutdown(shutdown, err) 50 | }() 51 | 52 | err = <-shutdown 53 | fmt.Println("Shutting down:", err) 54 | 55 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 56 | defer cancel() 57 | return s.Shutdown(ctx) 58 | } 59 | 60 | func doShutdownOnSignal(shutdown chan<- error) { 61 | onSignal := make(chan os.Signal, 1) 62 | signal.Notify(onSignal, os.Interrupt, syscall.SIGTERM) 63 | sig := <-onSignal 64 | doShutdown(shutdown, fmt.Errorf("received signal %s", sig)) 65 | } 66 | 67 | func doShutdown(shutdown chan<- error, err error) { 68 | select { 69 | case shutdown <- err: 70 | default: 71 | // If there is no one listening on the shutdown channel, then the 72 | // shutdown is already initiated and we can ignore these errors. 73 | } 74 | } 75 | 76 | func startListening(connectionType, listenAddr string, port, keepAlive int) (net.Listener, error) { 77 | network, addr := getNetworkAndAddr(listenAddr, port) 78 | lc := net.ListenConfig{KeepAlive: time.Duration(keepAlive) * time.Second} 79 | 80 | oldMask := umask(0) 81 | defer umask(oldMask) 82 | 83 | l, err := lc.Listen(context.Background(), network, addr) 84 | if err == nil { 85 | fmt.Println("Started listening for", connectionType, "on", l.Addr().Network(), l.Addr().String()) 86 | } 87 | return l, err 88 | } 89 | 90 | func getNetworkAndAddr(listenAddr string, port int) (string, string) { 91 | if strings.HasPrefix(listenAddr, "unix:") { 92 | return "unix", strings.TrimPrefix(listenAddr, "unix:") 93 | } 94 | return "tcp", fmt.Sprintf("%s:%d", listenAddr, port) 95 | } 96 | 97 | func applyLetsEncrypt(s *http.Server, conf *config.Configuration) { 98 | certManager := autocert.Manager{ 99 | Prompt: func(tosURL string) bool { return conf.Server.SSL.LetsEncrypt.AcceptTOS }, 100 | HostPolicy: autocert.HostWhitelist(conf.Server.SSL.LetsEncrypt.Hosts...), 101 | Cache: autocert.DirCache(conf.Server.SSL.LetsEncrypt.Cache), 102 | } 103 | s.Handler = certManager.HTTPHandler(s.Handler) 104 | s.TLSConfig = &tls.Config{GetCertificate: certManager.GetCertificate} 105 | } 106 | -------------------------------------------------------------------------------- /runner/umask.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package runner 4 | 5 | import "syscall" 6 | 7 | var umask = syscall.Umask 8 | -------------------------------------------------------------------------------- /runner/umask_fallback.go: -------------------------------------------------------------------------------- 1 | //go:build !unix 2 | 3 | package runner 4 | 5 | func umask(_ int) int { 6 | return 0 7 | } 8 | -------------------------------------------------------------------------------- /test/asserts.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http/httptest" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // BodyEquals asserts the content from the response recorder with the encoded json of the provided instance. 13 | func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) { 14 | bytes, err := io.ReadAll(recorder.Body) 15 | assert.Nil(t, err) 16 | actual := string(bytes) 17 | 18 | JSONEquals(t, obj, actual) 19 | } 20 | 21 | // JSONEquals asserts the content of the string with the encoded json of the provided instance. 22 | func JSONEquals(t assert.TestingT, obj interface{}, expected string) { 23 | bytes, err := json.Marshal(obj) 24 | assert.Nil(t, err) 25 | objJSON := string(bytes) 26 | 27 | assert.JSONEq(t, expected, objJSON) 28 | } 29 | 30 | type unreadableReader struct{} 31 | 32 | func (c unreadableReader) Read([]byte) (int, error) { 33 | return 0, errors.New("this reader cannot be read") 34 | } 35 | 36 | // UnreadableReader returns an unreadable reader, used to mock IO issues. 37 | func UnreadableReader() io.Reader { 38 | return unreadableReader{} 39 | } 40 | -------------------------------------------------------------------------------- /test/asserts_test.go: -------------------------------------------------------------------------------- 1 | package test_test 2 | 3 | import ( 4 | "io" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gotify/server/v2/test" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type obj struct { 13 | Test string 14 | ID int 15 | } 16 | 17 | type fakeTesting struct { 18 | hasErrors bool 19 | } 20 | 21 | func (t *fakeTesting) Errorf(format string, args ...interface{}) { 22 | t.hasErrors = true 23 | } 24 | 25 | func Test_BodyEquals(t *testing.T) { 26 | recorder := httptest.NewRecorder() 27 | recorder.WriteString(`{"ID": 2, "Test": "asd"}`) 28 | 29 | fakeTesting := &fakeTesting{} 30 | 31 | test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) 32 | assert.False(t, fakeTesting.hasErrors) 33 | } 34 | 35 | func Test_BodyEquals_failing(t *testing.T) { 36 | recorder := httptest.NewRecorder() 37 | recorder.WriteString(`{"ID": 3, "Test": "asd"}`) 38 | 39 | fakeTesting := &fakeTesting{} 40 | 41 | test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) 42 | assert.True(t, fakeTesting.hasErrors) 43 | } 44 | 45 | func Test_UnreaableReader(t *testing.T) { 46 | _, err := io.ReadAll(test.UnreadableReader()) 47 | assert.Error(t, err) 48 | } 49 | -------------------------------------------------------------------------------- /test/assets/image-header-with.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/test/assets/image-header-with.html -------------------------------------------------------------------------------- /test/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/test/assets/image.png -------------------------------------------------------------------------------- /test/assets/text.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/test/assets/text.txt -------------------------------------------------------------------------------- /test/auth.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gotify/server/v2/model" 6 | ) 7 | 8 | // WithUser fake an authentication for testing. 9 | func WithUser(ctx *gin.Context, userID uint) { 10 | ctx.Set("user", &model.User{ID: userID}) 11 | ctx.Set("userid", userID) 12 | } 13 | -------------------------------------------------------------------------------- /test/auth_test.go: -------------------------------------------------------------------------------- 1 | package test_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gotify/server/v2/auth" 8 | "github.com/gotify/server/v2/mode" 9 | "github.com/gotify/server/v2/test" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestFakeAuth(t *testing.T) { 14 | mode.Set(mode.TestDev) 15 | 16 | ctx, _ := gin.CreateTestContext(nil) 17 | test.WithUser(ctx, 5) 18 | assert.Equal(t, uint(5), auth.GetUserID(ctx)) 19 | } 20 | -------------------------------------------------------------------------------- /test/filepath.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | ) 9 | 10 | // GetProjectDir returns the correct absolute path of this project. 11 | func GetProjectDir() string { 12 | _, f, _, _ := runtime.Caller(0) 13 | projectDir, _ := filepath.Abs(path.Join(filepath.Dir(f), "../")) 14 | return projectDir 15 | } 16 | 17 | // WithWd executes a function with the specified working directory. 18 | func WithWd(chDir string, f func(origWd string)) { 19 | wd, err := os.Getwd() 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | if err := os.Chdir(chDir); err != nil { 25 | panic(err) 26 | } 27 | defer os.Chdir(wd) 28 | f(wd) 29 | } 30 | -------------------------------------------------------------------------------- /test/filepath_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestProjectPath(t *testing.T) { 12 | _, err := os.Stat(path.Join(GetProjectDir(), "./README.md")) 13 | assert.Nil(t, err) 14 | } 15 | 16 | func TestWithWd(t *testing.T) { 17 | wd1, _ := os.Getwd() 18 | tmpDir := NewTmpDir("gotify_withwd") 19 | defer tmpDir.Clean() 20 | var wd2 string 21 | WithWd(tmpDir.Path(), func(origWd string) { 22 | assert.Equal(t, wd1, origWd) 23 | wd2, _ = os.Getwd() 24 | }) 25 | wd3, _ := os.Getwd() 26 | assert.Equal(t, wd1, wd3) 27 | assert.Equal(t, tmpDir.Path(), wd2) 28 | assert.Nil(t, os.RemoveAll(tmpDir.Path())) 29 | 30 | assert.Panics(t, func() { 31 | WithWd("non_exist", func(string) {}) 32 | }) 33 | 34 | assert.Nil(t, os.Mkdir(tmpDir.Path(), 0o644)) 35 | if os.Getuid() != 0 { // root is not subject to this check 36 | assert.Panics(t, func() { 37 | WithWd(tmpDir.Path(), func(string) {}) 38 | }) 39 | } 40 | assert.Nil(t, os.Remove(tmpDir.Path())) 41 | 42 | assert.Nil(t, os.Mkdir(tmpDir.Path(), 0o755)) 43 | assert.Panics(t, func() { 44 | WithWd(tmpDir.Path(), func(string) { 45 | assert.Nil(t, os.RemoveAll(tmpDir.Path())) 46 | WithWd(".", func(string) {}) 47 | }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /test/tmpdir.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | // TmpDir is a handler to temporary directory. 9 | type TmpDir struct { 10 | path string 11 | } 12 | 13 | // Path returns the path to the temporary directory joined by the elements provided. 14 | func (c TmpDir) Path(elem ...string) string { 15 | return path.Join(append([]string{c.path}, elem...)...) 16 | } 17 | 18 | // Clean removes the TmpDir. 19 | func (c TmpDir) Clean() error { 20 | return os.RemoveAll(c.path) 21 | } 22 | 23 | // NewTmpDir returns a new handle to a tmp dir. 24 | func NewTmpDir(prefix string) TmpDir { 25 | dir, _ := os.MkdirTemp("", prefix) 26 | return TmpDir{dir} 27 | } 28 | -------------------------------------------------------------------------------- /test/tmpdir_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTmpDir(t *testing.T) { 11 | dir := NewTmpDir("test_prefix") 12 | assert.NotEmpty(t, dir) 13 | 14 | assert.Contains(t, dir.Path(), "test_prefix") 15 | testFilePath := dir.Path("testfile.txt") 16 | assert.Contains(t, testFilePath, "test_prefix") 17 | assert.Contains(t, testFilePath, "testfile.txt") 18 | assert.True(t, strings.HasPrefix(testFilePath, dir.Path())) 19 | } 20 | -------------------------------------------------------------------------------- /test/token.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import "sync" 4 | 5 | // Tokens returns a token generation function with takes a series of tokens and output them in order. 6 | func Tokens(tokens ...string) func() string { 7 | var i int 8 | lock := sync.Mutex{} 9 | return func() string { 10 | lock.Lock() 11 | defer lock.Unlock() 12 | res := tokens[i%len(tokens)] 13 | i++ 14 | return res 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/token_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTokenGeneration(t *testing.T) { 10 | mockTokenFunc := Tokens("a", "b", "c") 11 | 12 | for _, expected := range []string{"a", "b", "c", "a", "b", "c"} { 13 | assert.Equal(t, expected, mockTokenFunc()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui.png -------------------------------------------------------------------------------- /ui/.eslintignore: -------------------------------------------------------------------------------- 1 | src/setupTests.ts 2 | src/registerServiceWorker.ts 3 | -------------------------------------------------------------------------------- /ui/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - eslint:recommended 4 | - plugin:@typescript-eslint/eslint-recommended 5 | - plugin:@typescript-eslint/recommended 6 | - plugin:@typescript-eslint/recommended-requiring-type-checking 7 | - plugin:react/recommended 8 | - plugin:import/errors 9 | - plugin:import/typescript 10 | - plugin:jest/recommended 11 | - prettier 12 | env: 13 | browser: true 14 | es6: true 15 | node: true 16 | parser: "@typescript-eslint/parser" 17 | parserOptions: 18 | project: tsconfig.json 19 | sourceType: module 20 | plugins: 21 | - "@typescript-eslint" 22 | - react 23 | - import 24 | - unicorn 25 | settings: 26 | react: 27 | version: detect 28 | rules: 29 | consistent-return: error 30 | default-case: error 31 | default-param-last: error 32 | no-loop-func: off 33 | arrow-body-style: [error, as-needed] 34 | 35 | import/no-useless-path-segments: error 36 | import/group-exports: off 37 | import/extensions: [error, never] 38 | import/no-duplicates: error 39 | import/first: error 40 | import/no-unused-modules: error 41 | 42 | unicorn/no-abusive-eslint-disable: off 43 | unicorn/no-array-instanceof: error 44 | unicorn/no-unreadable-array-destructuring: error 45 | unicorn/no-zero-fractions: error 46 | 47 | react/jsx-key: error 48 | react/jsx-pascal-case: error 49 | react/destructuring-assignment: off 50 | react/function-component-definition: off 51 | react/no-array-index-key: error 52 | react/no-deprecated: off 53 | react/no-string-refs: error 54 | react/no-this-in-sfc: error 55 | react/no-typos: error 56 | react/no-unknown-property: error 57 | react/prefer-stateless-function: off 58 | react/prop-types: off 59 | 60 | jest/expect-expect: off 61 | jest/no-jasmine-globals: off 62 | "@typescript-eslint/require-await": off 63 | "@typescript-eslint/restrict-template-expressions": off 64 | 65 | "@typescript-eslint/array-type": [error, {default: array-simple}] 66 | "@typescript-eslint/await-thenable": error 67 | "@typescript-eslint/no-unused-vars": error 68 | "@typescript-eslint/no-use-before-define": off 69 | "@typescript-eslint/no-unsafe-call": off 70 | "@typescript-eslint/consistent-type-assertions": [error, {assertionStyle: as}] 71 | 72 | "@typescript-eslint/no-extra-non-null-assertion": error 73 | "@typescript-eslint/no-inferrable-types": error 74 | "@typescript-eslint/no-this-alias": error 75 | "@typescript-eslint/no-throw-literal": error 76 | "@typescript-eslint/no-non-null-assertion": off 77 | "@typescript-eslint/prefer-nullish-coalescing": error 78 | "@typescript-eslint/prefer-optional-chain": error 79 | "@typescript-eslint/prefer-readonly": off 80 | "@typescript-eslint/unbound-method": error 81 | "@typescript-eslint/no-empty-function": off 82 | "@typescript-eslint/explicit-module-boundary-types": off 83 | "@typescript-eslint/ban-ts-comment": off 84 | "@typescript-eslint/no-floating-promises": off 85 | "@typescript-eslint/no-unsafe-member-access": off 86 | "@typescript-eslint/no-unsafe-return": off 87 | "@typescript-eslint/no-unsafe-assignment": off 88 | "@typescript-eslint/restrict-plus-operands": off 89 | "@typescript-eslint/no-misused-promises": off 90 | 91 | "@typescript-eslint/no-explicit-any": error 92 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": false, 9 | "bracketSameLine": true, 10 | "arrowParens": "always", 11 | "parser": "typescript" 12 | } 13 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gotify-ui", 3 | "version": "0.2.0", 4 | "private": true, 5 | "homepage": ".", 6 | "proxy": "http://localhost:80", 7 | "dependencies": { 8 | "@material-ui/core": "^4.11.4", 9 | "@material-ui/icons": "^4.9.1", 10 | "axios": "^0.21.1", 11 | "codemirror": "^5.61.1", 12 | "detect-browser": "^5.2.0", 13 | "js-base64": "^3.6.1", 14 | "mobx": "^5.15.6", 15 | "mobx-react": "^6.3.0", 16 | "mobx-utils": "^5.6.1", 17 | "notifyjs": "^3.0.0", 18 | "prop-types": "^15.6.2", 19 | "react": "^16.4.2", 20 | "react-codemirror2": "^7.2.1", 21 | "react-dom": "^16.4.2", 22 | "react-infinite": "^0.13.0", 23 | "react-markdown": "^6.0.2", 24 | "react-router": "^5.2.0", 25 | "react-router-dom": "^5.2.0", 26 | "react-timeago": "^6.2.1", 27 | "remark-gfm": "^1.0.0", 28 | "remove-markdown": "^0.3.0", 29 | "typeface-roboto": "1.1.13" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test --env=node", 35 | "eject": "react-scripts eject", 36 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 37 | "format": "prettier \"src/**/*.{ts,tsx}\" --write", 38 | "testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different" 39 | }, 40 | "devDependencies": { 41 | "@types/codemirror": "5.60.0", 42 | "@types/detect-browser": "^4.0.0", 43 | "@types/get-port": "^4.0.0", 44 | "@types/jest": "^26.0.23", 45 | "@types/js-base64": "^3.3.1", 46 | "@types/node": "^15.12.2", 47 | "@types/notifyjs": "^3.0.2", 48 | "@types/puppeteer": "^5.4.6", 49 | "@types/react": "^16.9.49", 50 | "@types/react-dom": "^16.9.8", 51 | "@types/react-infinite": "0.0.35", 52 | "@types/react-router-dom": "^5.1.7", 53 | "@types/remove-markdown": "^0.3.0", 54 | "@types/rimraf": "^3.0.0", 55 | "@typescript-eslint/eslint-plugin": "^4.1.0", 56 | "@typescript-eslint/parser": "^4.1.0", 57 | "eslint-config-prettier": "^6.11.0", 58 | "eslint-plugin-import": "^2.22.0", 59 | "eslint-plugin-jest": "^24.0.0", 60 | "eslint-plugin-prefer-arrow": "^1.2.2", 61 | "eslint-plugin-react": "^7.20.6", 62 | "eslint-plugin-unicorn": "^21.0.0", 63 | "get-port": "^5.1.1", 64 | "prettier": "^2.3.1", 65 | "puppeteer": "^17.1.3", 66 | "react-scripts": "^4.0.3", 67 | "rimraf": "^3.0.2", 68 | "tree-kill": "^1.2.0", 69 | "typescript": "4.0.2", 70 | "wait-on": "^5.3.0" 71 | }, 72 | "eslintConfig": { 73 | "extends": "react-app" 74 | }, 75 | "browserslist": { 76 | "production": [ 77 | ">0.2%", 78 | "not dead", 79 | "not op_mini all" 80 | ], 81 | "development": [ 82 | "last 1 chrome version", 83 | "last 1 firefox version", 84 | "last 1 safari version" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Gotify 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 |
38 | <% if (process.env.NODE_ENV === 'production') { %> 39 | 40 | <% } %> 41 | 42 | 43 | -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Gotify", 3 | "name": "Gotify WebApp", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#3f51b5", 7 | "background_color": "#303030" 8 | } 9 | -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /ui/public/static/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /ui/public/static/defaultapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/defaultapp.png -------------------------------------------------------------------------------- /ui/public/static/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon-128.png -------------------------------------------------------------------------------- /ui/public/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/static/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon-196x196.png -------------------------------------------------------------------------------- /ui/public/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon-96x96.png -------------------------------------------------------------------------------- /ui/public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/favicon.ico -------------------------------------------------------------------------------- /ui/public/static/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/mstile-144x144.png -------------------------------------------------------------------------------- /ui/public/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/mstile-150x150.png -------------------------------------------------------------------------------- /ui/public/static/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/mstile-310x150.png -------------------------------------------------------------------------------- /ui/public/static/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/mstile-310x310.png -------------------------------------------------------------------------------- /ui/public/static/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/mstile-70x70.png -------------------------------------------------------------------------------- /ui/public/static/notification.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotify/server/2498e6e19f9a796910ab75326e435ac92efaa2a2/ui/public/static/notification.ogg -------------------------------------------------------------------------------- /ui/serve.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "io/fs" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/gin-contrib/gzip" 11 | "github.com/gin-gonic/gin" 12 | "github.com/gotify/server/v2/model" 13 | ) 14 | 15 | //go:embed build/* 16 | var box embed.FS 17 | 18 | type uiConfig struct { 19 | Register bool `json:"register"` 20 | Version model.VersionInfo `json:"version"` 21 | } 22 | 23 | // Register registers the ui on the root path. 24 | func Register(r *gin.Engine, version model.VersionInfo, register bool) { 25 | uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register}) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | replaceConfig := func(content string) string { 31 | return strings.Replace(content, "%CONFIG%", string(uiConfigBytes), 1) 32 | } 33 | 34 | ui := r.Group("/", gzip.Gzip(gzip.DefaultCompression)) 35 | ui.GET("/", serveFile("index.html", "text/html", replaceConfig)) 36 | ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig)) 37 | ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop)) 38 | ui.GET("/asset-manifest.json", serveFile("asset-manifest.json", "application/json", noop)) 39 | 40 | subBox, err := fs.Sub(box, "build") 41 | if err != nil { 42 | panic(err) 43 | } 44 | ui.GET("/static/*any", gin.WrapH(http.FileServer(http.FS(subBox)))) 45 | } 46 | 47 | func noop(s string) string { 48 | return s 49 | } 50 | 51 | func serveFile(name, contentType string, convert func(string) string) gin.HandlerFunc { 52 | content, err := box.ReadFile("build/" + name) 53 | if err != nil { 54 | panic(err) 55 | } 56 | converted := convert(string(content)) 57 | return func(ctx *gin.Context) { 58 | ctx.Header("Content-Type", contentType) 59 | ctx.String(200, converted) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ui/src/apiAuth.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {CurrentUser} from './CurrentUser'; 3 | import {SnackReporter} from './snack/SnackManager'; 4 | 5 | export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => { 6 | axios.interceptors.request.use((config) => { 7 | config.headers['X-Gotify-Key'] = currentUser.token(); 8 | return config; 9 | }); 10 | 11 | axios.interceptors.response.use(undefined, (error) => { 12 | if (!error.response) { 13 | snack('Gotify server is not reachable, try refreshing the page.'); 14 | return Promise.reject(error); 15 | } 16 | 17 | const status = error.response.status; 18 | 19 | if (status === 401) { 20 | currentUser.tryAuthenticate().then(() => snack('Could not complete request.')); 21 | } 22 | 23 | if (status === 400 || status === 403 || status === 500) { 24 | snack(error.response.data.error + ': ' + error.response.data.errorDescription); 25 | } 26 | 27 | return Promise.reject(error); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /ui/src/application/AppStore.ts: -------------------------------------------------------------------------------- 1 | import {BaseStore} from '../common/BaseStore'; 2 | import axios from 'axios'; 3 | import * as config from '../config'; 4 | import {action} from 'mobx'; 5 | import {SnackReporter} from '../snack/SnackManager'; 6 | import {IApplication} from '../types'; 7 | 8 | export class AppStore extends BaseStore { 9 | public onDelete: () => void = () => {}; 10 | 11 | public constructor(private readonly snack: SnackReporter) { 12 | super(); 13 | } 14 | 15 | protected requestItems = (): Promise => 16 | axios 17 | .get(`${config.get('url')}application`) 18 | .then((response) => response.data); 19 | 20 | protected requestDelete = (id: number): Promise => 21 | axios.delete(`${config.get('url')}application/${id}`).then(() => { 22 | this.onDelete(); 23 | return this.snack('Application deleted'); 24 | }); 25 | 26 | @action 27 | public uploadImage = async (id: number, file: Blob): Promise => { 28 | const formData = new FormData(); 29 | formData.append('file', file); 30 | await axios.post(`${config.get('url')}application/${id}/image`, formData, { 31 | headers: {'content-type': 'multipart/form-data'}, 32 | }); 33 | await this.refresh(); 34 | this.snack('Application image updated'); 35 | }; 36 | 37 | @action 38 | public update = async ( 39 | id: number, 40 | name: string, 41 | description: string, 42 | defaultPriority: number 43 | ): Promise => { 44 | await axios.put(`${config.get('url')}application/${id}`, { 45 | name, 46 | description, 47 | defaultPriority, 48 | }); 49 | await this.refresh(); 50 | this.snack('Application updated'); 51 | }; 52 | 53 | @action 54 | public create = async ( 55 | name: string, 56 | description: string, 57 | defaultPriority: number 58 | ): Promise => { 59 | await axios.post(`${config.get('url')}application`, { 60 | name, 61 | description, 62 | defaultPriority, 63 | }); 64 | await this.refresh(); 65 | this.snack('Application created'); 66 | }; 67 | 68 | public getName = (id: number): string => { 69 | const app = this.getByIDOrUndefined(id); 70 | return id === -1 ? 'All Messages' : app !== undefined ? app.name : 'unknown'; 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /ui/src/client/AddClientDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogTitle from '@material-ui/core/DialogTitle'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import Tooltip from '@material-ui/core/Tooltip'; 8 | import React, {Component} from 'react'; 9 | 10 | interface IProps { 11 | fClose: VoidFunction; 12 | fOnSubmit: (name: string) => void; 13 | } 14 | 15 | export default class AddDialog extends Component { 16 | public state = {name: ''}; 17 | 18 | public render() { 19 | const {fClose, fOnSubmit} = this.props; 20 | const {name} = this.state; 21 | const submitEnabled = this.state.name.length !== 0; 22 | const submitAndClose = () => { 23 | fOnSubmit(name); 24 | fClose(); 25 | }; 26 | return ( 27 | 32 | Create a client 33 | 34 | 44 | 45 | 46 | 47 | 50 |
51 | 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | private handleChange(propertyName: string, event: React.ChangeEvent) { 67 | const state = this.state; 68 | state[propertyName] = event.target.value; 69 | this.setState(state); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/client/ClientStore.ts: -------------------------------------------------------------------------------- 1 | import {BaseStore} from '../common/BaseStore'; 2 | import axios from 'axios'; 3 | import * as config from '../config'; 4 | import {action} from 'mobx'; 5 | import {SnackReporter} from '../snack/SnackManager'; 6 | import {IClient} from '../types'; 7 | 8 | export class ClientStore extends BaseStore { 9 | public constructor(private readonly snack: SnackReporter) { 10 | super(); 11 | } 12 | 13 | protected requestItems = (): Promise => 14 | axios.get(`${config.get('url')}client`).then((response) => response.data); 15 | 16 | protected requestDelete(id: number): Promise { 17 | return axios 18 | .delete(`${config.get('url')}client/${id}`) 19 | .then(() => this.snack('Client deleted')); 20 | } 21 | 22 | @action 23 | public update = async (id: number, name: string): Promise => { 24 | await axios.put(`${config.get('url')}client/${id}`, {name}); 25 | await this.refresh(); 26 | this.snack('Client updated'); 27 | }; 28 | 29 | @action 30 | public createNoNotifcation = async (name: string): Promise => { 31 | const client = await axios.post(`${config.get('url')}client`, {name}); 32 | await this.refresh(); 33 | return client.data; 34 | }; 35 | 36 | @action 37 | public create = async (name: string): Promise => { 38 | await this.createNoNotifcation(name); 39 | this.snack('Client added'); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/client/UpdateClientDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogContentText from '@material-ui/core/DialogContentText'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import Tooltip from '@material-ui/core/Tooltip'; 9 | import React, {Component} from 'react'; 10 | 11 | interface IProps { 12 | fClose: VoidFunction; 13 | fOnSubmit: (name: string) => void; 14 | initialName: string; 15 | } 16 | 17 | interface IState { 18 | name: string; 19 | } 20 | 21 | export default class UpdateDialog extends Component { 22 | public state = {name: ''}; 23 | 24 | constructor(props: IProps) { 25 | super(props); 26 | this.state = { 27 | name: props.initialName, 28 | }; 29 | } 30 | 31 | public render() { 32 | const {fClose, fOnSubmit} = this.props; 33 | const {name} = this.state; 34 | const submitEnabled = this.state.name.length !== 0; 35 | const submitAndClose = () => { 36 | fOnSubmit(name); 37 | fClose(); 38 | }; 39 | return ( 40 | 45 | Update a Client 46 | 47 | 48 | A client manages messages, clients, applications and users (with admin 49 | permissions). 50 | 51 | 61 | 62 | 63 | 64 | 65 |
66 | 74 |
75 |
76 |
77 |
78 | ); 79 | } 80 | 81 | private handleChange(propertyName: string, event: React.ChangeEvent) { 82 | const state = {}; 83 | state[propertyName] = event.target.value; 84 | this.setState(state); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ui/src/common/BaseStore.ts: -------------------------------------------------------------------------------- 1 | import {action, observable} from 'mobx'; 2 | 3 | interface HasID { 4 | id: number; 5 | } 6 | 7 | export interface IClearable { 8 | clear(): void; 9 | } 10 | 11 | /** 12 | * Base implementation for handling items with ids. 13 | */ 14 | export abstract class BaseStore implements IClearable { 15 | @observable 16 | protected items: T[] = []; 17 | 18 | protected abstract requestItems(): Promise; 19 | 20 | protected abstract requestDelete(id: number): Promise; 21 | 22 | @action 23 | public remove = async (id: number): Promise => { 24 | await this.requestDelete(id); 25 | await this.refresh(); 26 | }; 27 | 28 | @action 29 | public refresh = async (): Promise => { 30 | this.items = await this.requestItems().then((items) => items || []); 31 | }; 32 | 33 | @action 34 | public refreshIfMissing = async (id: number): Promise => { 35 | if (this.getByIDOrUndefined(id) === undefined) { 36 | await this.refresh(); 37 | } 38 | }; 39 | 40 | public getByID = (id: number): T => { 41 | const item = this.getByIDOrUndefined(id); 42 | if (item === undefined) { 43 | throw new Error('cannot find item with id ' + id); 44 | } 45 | return item; 46 | }; 47 | 48 | public getByIDOrUndefined = (id: number): T | undefined => 49 | this.items.find((hasId: HasID) => hasId.id === id); 50 | 51 | public getItems = (): T[] => this.items; 52 | 53 | @action 54 | public clear = (): void => { 55 | this.items = []; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /ui/src/common/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogContentText from '@material-ui/core/DialogContentText'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import React from 'react'; 8 | 9 | interface IProps { 10 | title: string; 11 | text: string; 12 | fClose: VoidFunction; 13 | fOnSubmit: VoidFunction; 14 | } 15 | 16 | export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) { 17 | const submitAndClose = () => { 18 | fOnSubmit(); 19 | fClose(); 20 | }; 21 | return ( 22 | 27 | {title} 28 | 29 | {text} 30 | 31 | 32 | 35 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /ui/src/common/ConnectionErrorBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | interface ConnectionErrorBannerProps { 6 | height: number; 7 | retry: () => void; 8 | message: string; 9 | } 10 | 11 | export const ConnectionErrorBanner = ({height, retry, message}: ConnectionErrorBannerProps) => ( 12 |
20 | 21 | {message}{' '} 22 | 25 | 26 |
27 | ); 28 | -------------------------------------------------------------------------------- /ui/src/common/Container.tsx: -------------------------------------------------------------------------------- 1 | import Paper from '@material-ui/core/Paper'; 2 | import {withStyles, WithStyles} from '@material-ui/core/styles'; 3 | import * as React from 'react'; 4 | 5 | const styles = () => ({ 6 | paper: { 7 | padding: 16, 8 | }, 9 | }); 10 | 11 | interface IProps extends WithStyles<'paper'> { 12 | style?: React.CSSProperties; 13 | } 14 | 15 | const Container: React.FC = ({classes, children, style}) => ( 16 | 17 | {children} 18 | 19 | ); 20 | 21 | export default withStyles(styles)(Container); 22 | -------------------------------------------------------------------------------- /ui/src/common/CopyableSecret.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from '@material-ui/core/IconButton'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import Visibility from '@material-ui/icons/Visibility'; 4 | import Copy from '@material-ui/icons/FileCopyOutlined'; 5 | import VisibilityOff from '@material-ui/icons/VisibilityOff'; 6 | import React, {Component, CSSProperties} from 'react'; 7 | import {Stores, inject} from '../inject'; 8 | 9 | interface IProps { 10 | value: string; 11 | style?: CSSProperties; 12 | } 13 | 14 | interface IState { 15 | visible: boolean; 16 | } 17 | 18 | class CopyableSecret extends Component, IState> { 19 | public state = {visible: false}; 20 | 21 | public render() { 22 | const {value, style} = this.props; 23 | const text = this.state.visible ? value : '•••••••••••••••'; 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | {this.state.visible ? : } 31 | 32 | {text} 33 |
34 | ); 35 | } 36 | 37 | private toggleVisibility = () => this.setState({visible: !this.state.visible}); 38 | private copyToClipboard = async () => { 39 | const {snackManager, value} = this.props; 40 | try { 41 | await navigator.clipboard.writeText(value); 42 | snackManager.snack('Copied to clipboard'); 43 | } catch (error) { 44 | console.error('Failed to copy to clipboard:', error); 45 | snackManager.snack('Failed to copy to clipboard'); 46 | } 47 | }; 48 | } 49 | 50 | export default inject('snackManager')(CopyableSecret); 51 | -------------------------------------------------------------------------------- /ui/src/common/DefaultPage.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@material-ui/core/Grid'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import React, {FC} from 'react'; 4 | 5 | interface IProps { 6 | title: string; 7 | rightControl?: React.ReactNode; 8 | maxWidth?: number; 9 | } 10 | 11 | const DefaultPage: FC = ({title, rightControl, maxWidth = 700, children}) => ( 12 |
13 | 14 | 15 | 16 | {title} 17 | 18 | {rightControl} 19 | 20 | {children} 21 | 22 |
23 | ); 24 | export default DefaultPage; 25 | -------------------------------------------------------------------------------- /ui/src/common/LastUsedCell.tsx: -------------------------------------------------------------------------------- 1 | import {Typography} from '@material-ui/core'; 2 | import React from 'react'; 3 | import TimeAgo from 'react-timeago'; 4 | 5 | export const LastUsedCell: React.FC<{lastUsed: string | null}> = ({lastUsed}) => { 6 | if (lastUsed === null) { 7 | return Never; 8 | } 9 | 10 | if (+new Date(lastUsed) + 300000 > Date.now()) { 11 | return Recently; 12 | } 13 | 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /ui/src/common/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from '@material-ui/core/CircularProgress'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import React from 'react'; 4 | import DefaultPage from './DefaultPage'; 5 | 6 | export default function LoadingSpinner() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/common/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import gfm from 'remark-gfm'; 4 | 5 | export const Markdown = ({children}: {children: string}) => ( 6 | {children} 7 | ); 8 | -------------------------------------------------------------------------------- /ui/src/common/NumberField.tsx: -------------------------------------------------------------------------------- 1 | import {TextField, TextFieldProps} from '@material-ui/core'; 2 | import React from 'react'; 3 | 4 | export interface NumberFieldProps { 5 | value: number; 6 | onChange: (value: number) => void; 7 | } 8 | 9 | export const NumberField = ({ 10 | value, 11 | onChange, 12 | ...props 13 | }: NumberFieldProps & Omit) => { 14 | const [stringValue, setStringValue] = React.useState(value.toString()); 15 | const [error, setError] = React.useState(''); 16 | 17 | return ( 18 | { 24 | setStringValue(event.target.value); 25 | const i = parseInt(event.target.value, 10); 26 | if (!Number.isNaN(i)) { 27 | onChange(i); 28 | setError(''); 29 | } else { 30 | setError('Invalid number'); 31 | } 32 | }} 33 | {...props} 34 | /> 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /ui/src/common/ScrollUpButton.tsx: -------------------------------------------------------------------------------- 1 | import Fab from '@material-ui/core/Fab'; 2 | import KeyboardArrowUp from '@material-ui/icons/KeyboardArrowUp'; 3 | import React, {Component} from 'react'; 4 | 5 | class ScrollUpButton extends Component { 6 | state = { 7 | display: 'none', 8 | opacity: 0, 9 | }; 10 | componentDidMount() { 11 | window.addEventListener('scroll', this.scrollHandler); 12 | } 13 | 14 | componentWillUnmount() { 15 | window.removeEventListener('scroll', this.scrollHandler); 16 | } 17 | 18 | scrollHandler = () => { 19 | const currentScrollPos = window.pageYOffset; 20 | const opacity = Math.min(currentScrollPos / 500, 1); 21 | const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; 22 | if (this.state.display !== nextState.display || this.state.opacity !== nextState.opacity) { 23 | this.setState(nextState); 24 | } 25 | }; 26 | 27 | public render() { 28 | return ( 29 | 40 | 41 | 42 | ); 43 | } 44 | 45 | private scrollUp = () => window.scrollTo(0, 0); 46 | } 47 | 48 | export default ScrollUpButton; 49 | -------------------------------------------------------------------------------- /ui/src/common/SettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogTitle from '@material-ui/core/DialogTitle'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import Tooltip from '@material-ui/core/Tooltip'; 8 | import React, {Component} from 'react'; 9 | import {observable} from 'mobx'; 10 | import {observer} from 'mobx-react'; 11 | import {inject, Stores} from '../inject'; 12 | 13 | interface IProps { 14 | fClose: VoidFunction; 15 | } 16 | 17 | @observer 18 | class SettingsDialog extends Component> { 19 | @observable 20 | private pass = ''; 21 | 22 | public render() { 23 | const {pass} = this; 24 | const {fClose, currentUser} = this.props; 25 | const submitAndClose = () => { 26 | currentUser.changePassword(pass); 27 | fClose(); 28 | }; 29 | return ( 30 | 35 | Change Password 36 | 37 | (this.pass = e.target.value)} 45 | fullWidth 46 | /> 47 | 48 | 49 | 50 | 51 |
52 | 60 |
61 |
62 |
63 |
64 | ); 65 | } 66 | } 67 | 68 | export default inject('currentUser')(SettingsDialog); 69 | -------------------------------------------------------------------------------- /ui/src/config.ts: -------------------------------------------------------------------------------- 1 | import {IVersion} from './types'; 2 | 3 | export interface IConfig { 4 | url: string; 5 | register: boolean; 6 | version: IVersion; 7 | } 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | declare global { 11 | interface Window { 12 | config?: Partial; 13 | } 14 | } 15 | 16 | const config: IConfig = { 17 | url: 'unset', 18 | register: false, 19 | version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, 20 | ...window.config, 21 | }; 22 | 23 | export function set(key: Key, value: IConfig[Key]): void { 24 | config[key] = value; 25 | } 26 | 27 | export function get(key: K): IConfig[K] { 28 | return config[key]; 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import 'typeface-roboto'; 4 | import {initAxios} from './apiAuth'; 5 | import * as config from './config'; 6 | import Layout from './layout/Layout'; 7 | import {unregister} from './registerServiceWorker'; 8 | import {CurrentUser} from './CurrentUser'; 9 | import {AppStore} from './application/AppStore'; 10 | import {WebSocketStore} from './message/WebSocketStore'; 11 | import {SnackManager} from './snack/SnackManager'; 12 | import {InjectProvider, StoreMapping} from './inject'; 13 | import {UserStore} from './user/UserStore'; 14 | import {MessagesStore} from './message/MessagesStore'; 15 | import {ClientStore} from './client/ClientStore'; 16 | import {PluginStore} from './plugin/PluginStore'; 17 | import {registerReactions} from './reactions'; 18 | 19 | const devUrl = 'http://localhost:3000/'; 20 | 21 | const {port, hostname, protocol, pathname} = window.location; 22 | const slashes = protocol.concat('//'); 23 | const path = pathname.endsWith('/') ? pathname : pathname.substring(0, pathname.lastIndexOf('/')); 24 | const url = slashes.concat(port ? hostname.concat(':', port) : hostname) + path; 25 | const urlWithSlash = url.endsWith('/') ? url : url.concat('/'); 26 | 27 | const prodUrl = urlWithSlash; 28 | 29 | const initStores = (): StoreMapping => { 30 | const snackManager = new SnackManager(); 31 | const appStore = new AppStore(snackManager.snack); 32 | const userStore = new UserStore(snackManager.snack); 33 | const messagesStore = new MessagesStore(appStore, snackManager.snack); 34 | const currentUser = new CurrentUser(snackManager.snack); 35 | const clientStore = new ClientStore(snackManager.snack); 36 | const wsStore = new WebSocketStore(snackManager.snack, currentUser); 37 | const pluginStore = new PluginStore(snackManager.snack); 38 | appStore.onDelete = () => messagesStore.clearAll(); 39 | 40 | return { 41 | appStore, 42 | snackManager, 43 | userStore, 44 | messagesStore, 45 | currentUser, 46 | clientStore, 47 | wsStore, 48 | pluginStore, 49 | }; 50 | }; 51 | 52 | (function clientJS() { 53 | if (process.env.NODE_ENV === 'production') { 54 | config.set('url', prodUrl); 55 | } else { 56 | config.set('url', devUrl); 57 | config.set('register', true); 58 | } 59 | const stores = initStores(); 60 | initAxios(stores.currentUser, stores.snackManager.snack); 61 | 62 | registerReactions(stores); 63 | 64 | stores.currentUser.tryAuthenticate().catch(() => {}); 65 | 66 | window.onbeforeunload = () => { 67 | stores.wsStore.close(); 68 | }; 69 | 70 | ReactDOM.render( 71 | 72 | 73 | , 74 | document.getElementById('root') 75 | ); 76 | unregister(); 77 | })(); 78 | -------------------------------------------------------------------------------- /ui/src/inject.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {UserStore} from './user/UserStore'; 3 | import {SnackManager} from './snack/SnackManager'; 4 | import {MessagesStore} from './message/MessagesStore'; 5 | import {CurrentUser} from './CurrentUser'; 6 | import {ClientStore} from './client/ClientStore'; 7 | import {AppStore} from './application/AppStore'; 8 | import {inject as mobxInject, Provider} from 'mobx-react'; 9 | import {WebSocketStore} from './message/WebSocketStore'; 10 | import {PluginStore} from './plugin/PluginStore'; 11 | 12 | export interface StoreMapping { 13 | userStore: UserStore; 14 | snackManager: SnackManager; 15 | messagesStore: MessagesStore; 16 | currentUser: CurrentUser; 17 | clientStore: ClientStore; 18 | appStore: AppStore; 19 | pluginStore: PluginStore; 20 | wsStore: WebSocketStore; 21 | } 22 | 23 | export type AllStores = Extract; 24 | export type Stores = Pick; 25 | 26 | export const inject = 27 | (...stores: I[]) => 28 | // eslint-disable-next-line @typescript-eslint/ban-types 29 |

( 30 | node: React.ComponentType

31 | ): React.ComponentType>> => 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | mobxInject(...stores)(node) as any; 34 | 35 | export const InjectProvider: React.FC<{stores: StoreMapping}> = ({children, stores}) => ( 36 | {children} 37 | ); 38 | -------------------------------------------------------------------------------- /ui/src/message/WebSocketStore.ts: -------------------------------------------------------------------------------- 1 | import {SnackReporter} from '../snack/SnackManager'; 2 | import {CurrentUser} from '../CurrentUser'; 3 | import * as config from '../config'; 4 | import {AxiosError} from 'axios'; 5 | import {IMessage} from '../types'; 6 | 7 | export class WebSocketStore { 8 | private wsActive = false; 9 | private ws: WebSocket | null = null; 10 | 11 | public constructor( 12 | private readonly snack: SnackReporter, 13 | private readonly currentUser: CurrentUser 14 | ) {} 15 | 16 | public listen = (callback: (msg: IMessage) => void) => { 17 | if (!this.currentUser.token() || this.wsActive) { 18 | return; 19 | } 20 | this.wsActive = true; 21 | 22 | const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss'); 23 | const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token()); 24 | 25 | ws.onerror = (e) => { 26 | this.wsActive = false; 27 | console.log('WebSocket connection errored', e); 28 | }; 29 | 30 | ws.onmessage = (data) => callback(JSON.parse(data.data)); 31 | 32 | ws.onclose = () => { 33 | this.wsActive = false; 34 | this.currentUser 35 | .tryAuthenticate() 36 | .then(() => { 37 | this.snack('WebSocket connection closed, trying again in 30 seconds.'); 38 | setTimeout(() => this.listen(callback), 30000); 39 | }) 40 | .catch((error: AxiosError) => { 41 | if (error?.response?.status === 401) { 42 | this.snack('Could not authenticate with client token, logging out.'); 43 | } 44 | }); 45 | }; 46 | 47 | this.ws = ws; 48 | }; 49 | 50 | public close = () => this.ws?.close(1000, 'WebSocketStore#close'); 51 | } 52 | -------------------------------------------------------------------------------- /ui/src/message/extras.ts: -------------------------------------------------------------------------------- 1 | import {IMessageExtras} from '../types'; 2 | 3 | export enum RenderMode { 4 | Markdown = 'text/markdown', 5 | Plain = 'text/plain', 6 | } 7 | 8 | export const contentType = (extras?: IMessageExtras): RenderMode => { 9 | const type = extract(extras, 'client::display', 'contentType'); 10 | const valid = Object.keys(RenderMode) 11 | .map((k) => RenderMode[k]) 12 | .some((mode) => mode === type); 13 | return valid ? type : RenderMode.Plain; 14 | }; 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | const extract = (extras: IMessageExtras | undefined, key: string, path: string): any => { 18 | if (!extras) { 19 | return null; 20 | } 21 | 22 | if (!extras[key]) { 23 | return null; 24 | } 25 | 26 | if (!extras[key][path]) { 27 | return null; 28 | } 29 | 30 | return extras[key][path]; 31 | }; 32 | -------------------------------------------------------------------------------- /ui/src/plugin/PluginStore.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {action} from 'mobx'; 3 | import {BaseStore} from '../common/BaseStore'; 4 | import * as config from '../config'; 5 | import {SnackReporter} from '../snack/SnackManager'; 6 | import {IPlugin} from '../types'; 7 | 8 | export class PluginStore extends BaseStore { 9 | public onDelete: () => void = () => {}; 10 | 11 | public constructor(private readonly snack: SnackReporter) { 12 | super(); 13 | } 14 | 15 | public requestConfig = (id: number): Promise => 16 | axios.get(`${config.get('url')}plugin/${id}/config`).then((response) => response.data); 17 | 18 | public requestDisplay = (id: number): Promise => 19 | axios.get(`${config.get('url')}plugin/${id}/display`).then((response) => response.data); 20 | 21 | protected requestItems = (): Promise => 22 | axios.get(`${config.get('url')}plugin`).then((response) => response.data); 23 | 24 | protected requestDelete = (): Promise => { 25 | this.snack('Cannot delete plugin'); 26 | throw new Error('Cannot delete plugin'); 27 | }; 28 | 29 | public getName = (id: number): string => { 30 | const plugin = this.getByIDOrUndefined(id); 31 | return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown'; 32 | }; 33 | 34 | @action 35 | public changeConfig = async (id: number, newConfig: string): Promise => { 36 | await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, { 37 | headers: {'content-type': 'application/x-yaml'}, 38 | }); 39 | this.snack(`Plugin config updated`); 40 | await this.refresh(); 41 | }; 42 | 43 | @action 44 | public changeEnabledState = async (id: number, enabled: boolean): Promise => { 45 | await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`); 46 | this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`); 47 | await this.refresh(); 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /ui/src/reactions.ts: -------------------------------------------------------------------------------- 1 | import {StoreMapping} from './inject'; 2 | import {reaction} from 'mobx'; 3 | import * as Notifications from './snack/browserNotification'; 4 | 5 | export const registerReactions = (stores: StoreMapping) => { 6 | const clearAll = () => { 7 | stores.messagesStore.clearAll(); 8 | stores.appStore.clear(); 9 | stores.clientStore.clear(); 10 | stores.userStore.clear(); 11 | stores.wsStore.close(); 12 | }; 13 | const loadAll = () => { 14 | stores.wsStore.listen((message) => { 15 | stores.messagesStore.publishSingleMessage(message); 16 | Notifications.notifyNewMessage(message); 17 | if (message.priority >= 4) { 18 | const src = 'static/notification.ogg'; 19 | const audio = new Audio(src); 20 | audio.play(); 21 | } 22 | }); 23 | stores.appStore.refresh(); 24 | }; 25 | 26 | reaction( 27 | () => stores.currentUser.loggedIn, 28 | (loggedIn) => { 29 | if (loggedIn) { 30 | loadAll(); 31 | } else { 32 | clearAll(); 33 | } 34 | } 35 | ); 36 | 37 | reaction( 38 | () => stores.currentUser.connectionErrorMessage, 39 | (connectionErrorMessage) => { 40 | if (!connectionErrorMessage) { 41 | clearAll(); 42 | loadAll(); 43 | } 44 | } 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /ui/src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | export function unregister() { 2 | if ('serviceWorker' in navigator) { 3 | navigator.serviceWorker.ready.then((registration) => { 4 | registration.unregister(); 5 | }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(process.env.CI === 'true' ? 50000 : 20000); 2 | -------------------------------------------------------------------------------- /ui/src/snack/SnackBarHandler.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from '@material-ui/core/IconButton'; 2 | import Snackbar from '@material-ui/core/Snackbar'; 3 | import Close from '@material-ui/icons/Close'; 4 | import React, {Component} from 'react'; 5 | import {observable, reaction} from 'mobx'; 6 | import {observer} from 'mobx-react'; 7 | import {inject, Stores} from '../inject'; 8 | 9 | @observer 10 | class SnackBarHandler extends Component> { 11 | private static MAX_VISIBLE_SNACK_TIME_IN_MS = 6000; 12 | private static MIN_VISIBLE_SNACK_TIME_IN_MS = 1000; 13 | 14 | @observable 15 | private open = false; 16 | @observable 17 | private openWhen = 0; 18 | 19 | private dispose: () => void = () => {}; 20 | 21 | public componentDidMount = () => 22 | (this.dispose = reaction(() => this.props.snackManager.counter, this.onNewSnack)); 23 | 24 | public componentWillUnmount = () => this.dispose(); 25 | 26 | public render() { 27 | const {message: current, hasNext} = this.props.snackManager; 28 | const duration = hasNext() 29 | ? SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS 30 | : SnackBarHandler.MAX_VISIBLE_SNACK_TIME_IN_MS; 31 | 32 | return ( 33 | {current}} 40 | action={ 41 | 46 | 47 | 48 | } 49 | /> 50 | ); 51 | } 52 | 53 | private onNewSnack = () => { 54 | const {open, openWhen} = this; 55 | 56 | if (!open) { 57 | this.openNextSnack(); 58 | return; 59 | } 60 | 61 | const snackOpenSince = Date.now() - openWhen; 62 | if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) { 63 | this.closeCurrentSnack(); 64 | } else { 65 | setTimeout( 66 | this.closeCurrentSnack, 67 | SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince 68 | ); 69 | } 70 | }; 71 | 72 | private openNextSnack = () => { 73 | if (this.props.snackManager.hasNext()) { 74 | this.open = true; 75 | this.openWhen = Date.now(); 76 | this.props.snackManager.next(); 77 | } 78 | }; 79 | 80 | private closeCurrentSnack = () => (this.open = false); 81 | } 82 | 83 | export default inject('snackManager')(SnackBarHandler); 84 | -------------------------------------------------------------------------------- /ui/src/snack/SnackManager.ts: -------------------------------------------------------------------------------- 1 | import {action, observable} from 'mobx'; 2 | 3 | export interface SnackReporter { 4 | (message: string): void; 5 | } 6 | 7 | export class SnackManager { 8 | @observable 9 | private messages: string[] = []; 10 | @observable 11 | public message: string | null = null; 12 | @observable 13 | public counter = 0; 14 | 15 | @action 16 | public next = (): void => { 17 | if (!this.hasNext()) { 18 | throw new Error('There is nothing here :('); 19 | } 20 | this.message = this.messages.shift() as string; 21 | }; 22 | 23 | public hasNext = () => this.messages.length > 0; 24 | 25 | @action 26 | public snack: SnackReporter = (message: string): void => { 27 | this.messages.push(message); 28 | this.counter++; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/snack/browserNotification.ts: -------------------------------------------------------------------------------- 1 | import Notify from 'notifyjs'; 2 | import removeMarkdown from 'remove-markdown'; 3 | import {IMessage} from '../types'; 4 | 5 | export function mayAllowPermission(): boolean { 6 | return Notify.needsPermission && Notify.isSupported() && Notification.permission !== 'denied'; 7 | } 8 | 9 | export function requestPermission() { 10 | if (Notify.needsPermission && Notify.isSupported()) { 11 | Notify.requestPermission( 12 | () => console.log('granted notification permissions'), 13 | () => console.log('notification permission denied') 14 | ); 15 | } 16 | } 17 | 18 | export function notifyNewMessage(msg: IMessage) { 19 | const notify = new Notify(msg.title, { 20 | body: removeMarkdown(msg.message), 21 | icon: msg.image, 22 | silent: true, 23 | notifyClick: closeAndFocus, 24 | notifyShow: closeAfterTimeout, 25 | }); 26 | notify.show(); 27 | } 28 | 29 | function closeAndFocus(event: Event) { 30 | if (window.parent) { 31 | window.parent.focus(); 32 | } 33 | window.focus(); 34 | window.location.href = '/'; 35 | const target = event.target as Notification; 36 | target.close(); 37 | } 38 | 39 | function closeAfterTimeout(event: Event) { 40 | setTimeout(() => { 41 | const target = event.target as Notification; 42 | target.close(); 43 | }, 5000); 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/tests/authentication.ts: -------------------------------------------------------------------------------- 1 | import {Page} from 'puppeteer'; 2 | import {waitForExists} from './utils'; 3 | import * as selector from './selector'; 4 | 5 | const $loginForm = selector.form('#login-form'); 6 | 7 | export const login = async (page: Page, user = 'admin', pass = 'admin'): Promise => { 8 | await waitForExists(page, selector.heading(), 'Login'); 9 | expect(page.url()).toContain('/login'); 10 | await page.type($loginForm.input('.name'), user); 11 | await page.type($loginForm.input('.password'), pass); 12 | await page.click($loginForm.button('.login')); 13 | await waitForExists(page, selector.heading(), 'All Messages'); 14 | await waitForExists(page, 'button', 'logout'); 15 | }; 16 | 17 | export const logout = async (page: Page): Promise => { 18 | await page.click('#logout'); 19 | await waitForExists(page, selector.heading(), 'Login'); 20 | expect(page.url()).toContain('/login'); 21 | }; 22 | -------------------------------------------------------------------------------- /ui/src/tests/selector.ts: -------------------------------------------------------------------------------- 1 | export const heading = () => `main h4`; 2 | 3 | export const table = (tableSelector: string) => ({ 4 | selector: () => tableSelector, 5 | rows: () => `${tableSelector} tbody tr`, 6 | row: (index: number) => `${tableSelector} tbody tr:nth-child(${index})`, 7 | cell: (index: number, col: number, suffix = '') => 8 | `${tableSelector} tbody tr:nth-child(${index}) td:nth-child(${col}) ${suffix}`, 9 | }); 10 | 11 | export const form = (dialogSelector: string) => ({ 12 | selector: () => dialogSelector, 13 | input: (selector: string) => `${dialogSelector} ${selector} input`, 14 | textarea: (selector: string) => `${dialogSelector} ${selector} textarea`, 15 | button: (selector: string) => `${dialogSelector} button${selector}`, 16 | }); 17 | 18 | export const $confirmDialog = form('.confirm-dialog'); 19 | -------------------------------------------------------------------------------- /ui/src/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import {ElementHandle, JSHandle, Page} from 'puppeteer'; 2 | 3 | export const innerText = async (page: ElementHandle | Page, selector: string): Promise => { 4 | const element = await page.$(selector); 5 | const handle = await element!.getProperty('innerText'); 6 | const value = await handle.jsonValue(); 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | return (value as any).toString().trim(); 9 | }; 10 | 11 | export const clickByText = async (page: Page, selector: string, text: string): Promise => { 12 | await waitForExists(page, selector, text); 13 | text = text.toLowerCase(); 14 | await page.evaluate( 15 | (_selector, _text) => { 16 | ( 17 | Array.from(document.querySelectorAll(_selector)).filter( 18 | (element) => element.textContent?.toLowerCase().trim() === _text 19 | )[0] as HTMLButtonElement 20 | ).click(); 21 | }, 22 | selector, 23 | text 24 | ); 25 | }; 26 | 27 | export const count = async (page: Page, selector: string): Promise => 28 | page.$$(selector).then((elements) => elements.length); 29 | 30 | export const waitToDisappear = async (page: Page, selector: string): Promise => 31 | page.waitForFunction((_selector: string) => !document.querySelector(_selector), {}, selector); 32 | 33 | export const waitForCount = async ( 34 | page: Page, 35 | selector: string, 36 | amount: number 37 | ): Promise => 38 | page.waitForFunction( 39 | (_selector: string, _amount: number) => 40 | document.querySelectorAll(_selector).length === _amount, 41 | {}, 42 | selector, 43 | amount 44 | ); 45 | 46 | export const waitForExists = async (page: Page, selector: string, text: string): Promise => { 47 | text = text.toLowerCase(); 48 | await page.waitForFunction( 49 | (_selector: string, _text: string) => 50 | Array.from(document.querySelectorAll(_selector)).filter( 51 | (element) => element.textContent!.toLowerCase().trim() === _text 52 | ).length > 0, 53 | {}, 54 | selector, 55 | text 56 | ); 57 | }; 58 | 59 | export const clearField = async (element: ElementHandle | Page, selector: string) => { 60 | const elementHandle = await element.$(selector); 61 | if (!elementHandle) { 62 | fail(); 63 | } 64 | await elementHandle.click(); 65 | await elementHandle.focus(); 66 | // click three times to select all 67 | await elementHandle.click({clickCount: 3}); 68 | await elementHandle.press('Backspace'); 69 | }; 70 | -------------------------------------------------------------------------------- /ui/src/typedef/notifyjs.d.ts: -------------------------------------------------------------------------------- 1 | import Notify = require('notifyjs'); 2 | export as namespace notifyjs; 3 | export = Notify; 4 | -------------------------------------------------------------------------------- /ui/src/typedef/react-timeago.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-timeago' { 2 | import React from 'react'; 3 | 4 | export interface ITimeAgoProps { 5 | date: string; 6 | } 7 | 8 | export default class TimeAgo extends React.Component {} 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IApplication { 2 | id: number; 3 | token: string; 4 | name: string; 5 | description: string; 6 | image: string; 7 | internal: boolean; 8 | defaultPriority: number; 9 | lastUsed: string | null; 10 | } 11 | 12 | export interface IClient { 13 | id: number; 14 | token: string; 15 | name: string; 16 | lastUsed: string | null; 17 | } 18 | 19 | export interface IPlugin { 20 | id: number; 21 | token: string; 22 | name: string; 23 | modulePath: string; 24 | enabled: boolean; 25 | author?: string; 26 | website?: string; 27 | license?: string; 28 | capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>; 29 | } 30 | 31 | export interface IMessage { 32 | id: number; 33 | appid: number; 34 | message: string; 35 | title: string; 36 | priority: number; 37 | date: string; 38 | image?: string; 39 | extras?: IMessageExtras; 40 | } 41 | 42 | export interface IMessageExtras { 43 | [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any 44 | } 45 | 46 | export interface IPagedMessages { 47 | paging: IPaging; 48 | messages: IMessage[]; 49 | } 50 | 51 | export interface IPaging { 52 | next?: string; 53 | since?: number; 54 | size: number; 55 | limit: number; 56 | } 57 | 58 | export interface IUser { 59 | id: number; 60 | name: string; 61 | admin: boolean; 62 | } 63 | 64 | export interface IVersion { 65 | version: string; 66 | commit: string; 67 | buildDate: string; 68 | } 69 | -------------------------------------------------------------------------------- /ui/src/user/UserStore.ts: -------------------------------------------------------------------------------- 1 | import {BaseStore} from '../common/BaseStore'; 2 | import axios from 'axios'; 3 | import * as config from '../config'; 4 | import {action} from 'mobx'; 5 | import {SnackReporter} from '../snack/SnackManager'; 6 | import {IUser} from '../types'; 7 | 8 | export class UserStore extends BaseStore { 9 | constructor(private readonly snack: SnackReporter) { 10 | super(); 11 | } 12 | 13 | protected requestItems = (): Promise => 14 | axios.get(`${config.get('url')}user`).then((response) => response.data); 15 | 16 | protected requestDelete(id: number): Promise { 17 | return axios 18 | .delete(`${config.get('url')}user/${id}`) 19 | .then(() => this.snack('User deleted')); 20 | } 21 | 22 | @action 23 | public create = async (name: string, pass: string, admin: boolean) => { 24 | await axios.post(`${config.get('url')}user`, {name, pass, admin}); 25 | await this.refresh(); 26 | this.snack('User created'); 27 | }; 28 | 29 | @action 30 | public update = async (id: number, name: string, pass: string | null, admin: boolean) => { 31 | await axios.post(config.get('url') + 'user/' + id, {name, pass, admin}); 32 | await this.refresh(); 33 | this.snack('User updated'); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "outDir": "build/dist", 5 | "target": "es5", 6 | "lib": [ 7 | "es6", 8 | "dom" 9 | ], 10 | "sourceMap": true, 11 | "allowJs": true, 12 | "jsx": "react", 13 | "moduleResolution": "node", 14 | "rootDir": "src", 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "noUnusedLocals": true, 22 | "allowSyntheticDefaultImports": true, 23 | "experimentalDecorators": true, 24 | "skipLibCheck": true, 25 | "esModuleInterop": true, 26 | "strict": true, 27 | "isolatedModules": true, 28 | "noEmit": true, 29 | "module": "esnext", 30 | "resolveJsonModule": true, 31 | "keyofStringsOnly": true, 32 | "noFallthroughCasesInSwitch": true 33 | }, 34 | "exclude": [ 35 | "node_modules", 36 | "build", 37 | "scripts", 38 | "acceptance-tests", 39 | "webpack", 40 | "jest", 41 | "src/setupTests.ts" 42 | ], 43 | "include": [ 44 | "src" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /ui/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /ui/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } --------------------------------------------------------------------------------