Repository: gotify/server Branch: master Commit: 061053711ffa Files: 216 Total size: 670.4 KB Directory structure: gitextract_5ctt_wna/ ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── questions.md │ └── workflows/ │ └── build.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── GO_VERSION ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── api/ │ ├── application.go │ ├── application_test.go │ ├── client.go │ ├── client_test.go │ ├── errorHandling.go │ ├── errorHandling_test.go │ ├── health.go │ ├── health_test.go │ ├── internalutil.go │ ├── message.go │ ├── message_test.go │ ├── plugin.go │ ├── plugin_test.go │ ├── stream/ │ │ ├── client.go │ │ ├── once.go │ │ ├── once_test.go │ │ ├── stream.go │ │ └── stream_test.go │ ├── tokens.go │ ├── tokens_test.go │ ├── user.go │ └── user_test.go ├── app.go ├── auth/ │ ├── authentication.go │ ├── authentication_test.go │ ├── cors.go │ ├── cors_test.go │ ├── password/ │ │ ├── password.go │ │ └── password_test.go │ ├── token.go │ ├── token_test.go │ ├── util.go │ └── util_test.go ├── config/ │ ├── config.go │ └── config_test.go ├── config.example.yml ├── database/ │ ├── application.go │ ├── application_test.go │ ├── client.go │ ├── client_test.go │ ├── database.go │ ├── database_test.go │ ├── message.go │ ├── message_test.go │ ├── migration_test.go │ ├── ping.go │ ├── ping_test.go │ ├── plugin.go │ ├── plugin_test.go │ ├── user.go │ └── user_test.go ├── docker/ │ └── Dockerfile ├── docs/ │ ├── package.go │ ├── spec.json │ ├── swagger.go │ ├── swagger_test.go │ ├── ui.go │ └── ui_test.go ├── error/ │ ├── handler.go │ ├── handler_test.go │ ├── notfound.go │ └── notfound_test.go ├── fracdex/ │ ├── fracdex.go │ └── fracdex_test.go ├── go.mod ├── go.sum ├── mode/ │ ├── mode.go │ └── mode_test.go ├── model/ │ ├── application.go │ ├── client.go │ ├── error.go │ ├── health.go │ ├── message.go │ ├── paging.go │ ├── pluginconf.go │ ├── user.go │ └── version.go ├── plugin/ │ ├── compat/ │ │ ├── instance.go │ │ ├── plugin.go │ │ ├── plugin_test.go │ │ ├── v1.go │ │ ├── v1_test.go │ │ ├── wrap.go │ │ ├── wrap_test.go │ │ ├── wrap_test_norace.go │ │ └── wrap_test_race.go │ ├── example/ │ │ ├── clock/ │ │ │ └── main.go │ │ ├── echo/ │ │ │ └── echo.go │ │ └── minimal/ │ │ └── main.go │ ├── manager.go │ ├── manager_test.go │ ├── manager_test_norace.go │ ├── manager_test_race.go │ ├── messagehandler.go │ ├── pluginenabled.go │ ├── pluginenabled_test.go │ ├── storagehandler.go │ └── testing/ │ ├── broken/ │ │ ├── cantinstantiate/ │ │ │ └── main.go │ │ ├── malformedconstructor/ │ │ │ └── main.go │ │ ├── noinstance/ │ │ │ └── main.go │ │ ├── nothing/ │ │ │ └── main.go │ │ └── unknowninfo/ │ │ └── main.go │ └── mock/ │ └── mock.go ├── renovate.json ├── router/ │ ├── router.go │ └── router_test.go ├── runner/ │ ├── runner.go │ ├── umask.go │ └── umask_fallback.go ├── test/ │ ├── asserts.go │ ├── asserts_test.go │ ├── assets/ │ │ ├── image-header-with.html │ │ └── text.txt │ ├── auth.go │ ├── auth_test.go │ ├── filepath.go │ ├── filepath_test.go │ ├── testdb/ │ │ ├── database.go │ │ └── database_test.go │ ├── tmpdir.go │ ├── tmpdir_test.go │ ├── token.go │ └── token_test.go └── ui/ ├── .gitignore ├── .prettierrc ├── .yarnrc ├── eslint.config.mjs ├── index.html ├── package.json ├── public/ │ ├── manifest.json │ └── static/ │ └── notification.ogg ├── serve.go ├── src/ │ ├── CurrentUser.ts │ ├── apiAuth.ts │ ├── application/ │ │ ├── AddApplicationDialog.tsx │ │ ├── AppStore.ts │ │ ├── Applications.tsx │ │ └── UpdateApplicationDialog.tsx │ ├── client/ │ │ ├── AddClientDialog.tsx │ │ ├── ClientStore.ts │ │ ├── Clients.tsx │ │ └── UpdateClientDialog.tsx │ ├── common/ │ │ ├── BaseStore.ts │ │ ├── ConfirmDialog.tsx │ │ ├── ConnectionErrorBanner.tsx │ │ ├── Container.tsx │ │ ├── CopyableSecret.tsx │ │ ├── DefaultPage.tsx │ │ ├── LastUsedCell.tsx │ │ ├── LoadingSpinner.tsx │ │ ├── Markdown.tsx │ │ ├── NumberField.tsx │ │ ├── ScrollUpButton.tsx │ │ ├── SettingsDialog.tsx │ │ └── TimeAgoFormatter.ts │ ├── config.ts │ ├── index.tsx │ ├── layout/ │ │ ├── Header.tsx │ │ ├── Layout.tsx │ │ ├── Navigation.tsx │ │ └── theme.ts │ ├── message/ │ │ ├── Message.tsx │ │ ├── Messages.tsx │ │ ├── MessagesStore.ts │ │ ├── PushMessageDialog.tsx │ │ ├── WebSocketStore.ts │ │ └── extras.ts │ ├── plugin/ │ │ ├── PluginDetailView.tsx │ │ ├── PluginStore.ts │ │ └── Plugins.tsx │ ├── react-app-env.d.ts │ ├── reactions.ts │ ├── registerServiceWorker.ts │ ├── snack/ │ │ ├── SnackManager.ts │ │ └── browserNotification.ts │ ├── stores.tsx │ ├── tests/ │ │ ├── application.test.ts │ │ ├── authentication.ts │ │ ├── client.test.ts │ │ ├── message.test.ts │ │ ├── plugin.test.ts │ │ ├── selector.ts │ │ ├── setup.ts │ │ ├── user.test.ts │ │ └── utils.ts │ ├── typedef/ │ │ ├── notifyjs.d.ts │ │ └── react-timeago.d.ts │ ├── types.ts │ └── user/ │ ├── AddEditUserDialog.tsx │ ├── Login.tsx │ ├── Register.tsx │ ├── UserStore.ts │ └── Users.tsx ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json ├── vite-env.d.ts ├── vite.config.ts └── vitest.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ vendor/ .idea/ build/ licenses/ coverage.txt data/ images/ .git/ */node_modules/ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_size = 4 trim_trailing_whitespace = true [*.go] indent_style = tab [*.{js,ts,tsx}] indent_style = space quote_type = single [*.json] indent_style = space [*.html] indent_style = space [*.md] indent_style = space trim_trailing_whitespace = false [Makefile] indent_style = tab ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: jmattheis patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: https://jmattheis.de/donate ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Found a bug? Tell us and help us improve title: '' labels: a:bug assignees: '' --- **Can the issue be reproduced with the latest available release? (y/n)** **Which one is the environment gotify server is running in?** - [ ] Docker - [ ] Linux machine - [ ] Windows machine
Docker startup command or config file here (please mask sensitive information)
``` ```
**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)** - [ ] None - [ ] Nginx - [ ] Apache - [ ] Caddy
Reverse proxy configuration (please mask sensitive information)
``` ```
**On which client do you experience problems? (Select as many as you can see)** - [ ] WebUI - [ ] gotify-cli - [ ] Android Client - [ ] 3rd-party API call (Please include your code) **What did you do?** **What did you expect to see?** **What did you see instead? (Include screenshots, android logcat/request dumps if possible)** ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: a:feature assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/questions.md ================================================ --- name: Questions about: Having difficulties with gotify? Feel free to ask here title: '' labels: question assignees: '' --- **Have you read the documentation?** - [ ] Yes, but it does not include related information regarding my question. - [ ] Yes, but the steps described in the documentation do not work on my machine. - [ ] Yes, but I am having difficulty understanding it and want clarification. **You are setting up gotify in** - [ ] Docker - [ ] Linux native platform - [ ] Windows native platform **Describe your problem** **Any errors, logs, or other information that might help us identify your problem** Ex: `docker-compose.yml`, `nginx.conf`, android logcat, browser requests, etc.
Name of the information here

contents here

================================================ FILE: .github/workflows/build.yml ================================================ name: build on: [push, pull_request] jobs: gotify: runs-on: ubuntu-latest steps: - uses: actions/setup-go@v6 with: go-version: 1.26.x - uses: actions/setup-node@v6 with: node-version: '24' - uses: actions/checkout@v6 - run: (cd ui && yarn) - run: make build-js - uses: golangci/golangci-lint-action@v9 with: version: v2.11.3 args: --timeout=5m skip-cache: true - run: go mod download - run: make download-tools - run: make test - run: make check-ci - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - if: startsWith(github.ref, 'refs/tags/v') run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV - if: startsWith(github.ref, 'refs/tags/v') run: | 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" echo "LD_FLAGS=$LD_FLAGS" >> $GITHUB_ENV make build sudo chown -R $UID build make package-zip ls -lath build - if: startsWith(github.ref, 'refs/tags/v') name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - if: startsWith(github.ref, 'refs/tags/v') name: Set up QEMU uses: docker/setup-qemu-action@v4 - if: startsWith(github.ref, 'refs/tags/v') uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - if: startsWith(github.ref, 'refs/tags/v') uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ secrets.DOCKER_GHCR_USER }} password: ${{ secrets.DOCKER_GHCR_PASS }} - if: startsWith(github.ref, 'refs/tags/v') run: | make DOCKER_BUILD_PUSH=true build-docker - if: startsWith(github.ref, 'refs/tags/v') uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: build/*.zip tag: ${{ github.ref }} overwrite: true file_glob: true ================================================ FILE: .gitignore ================================================ vendor/ .idea/ build/ certs/ build/ licenses/ coverage.txt */node_modules/ **/*-packr.go config.yml data/ images/ ================================================ FILE: .golangci.yml ================================================ version: "2" linters: enable: - asciicheck - copyloopvar - godot - gomodguard - goprintffuncname - misspell - nakedret - nolintlint - sqlclosecheck - staticcheck - unconvert - whitespace disable: - err113 - errcheck - funlen - gochecknoglobals - gocognit - goconst - gocyclo - godox - lll - nestif - nlreturn - noctx - testpackage - wsl settings: misspell: locale: US exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - plugin/example - plugin/testing - third_party$ - builtin$ - examples$ formatters: enable: - gofmt - gofumpt - goimports settings: gofumpt: extra-rules: true exclusions: generated: lax paths: - plugin/example - plugin/testing - third_party$ - builtin$ - examples$ ================================================ FILE: CODEOWNERS ================================================ * @gotify/committers ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge 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. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities 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. 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. ## Scope 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. ## Enforcement 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. 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. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Thanks for your interest in Gotify! 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. If you have any questions you can join the chat on [#gotify:matrix.org](https://matrix.to/#/#gotify:matrix.org). ## Where to Contribute | Repository| Description| Technology| | ---| ---| ---| |[gotify/server](https://github.com/gotify/server) |server implementation and WebUI code |`Go` `Typescript` `React`| |[gotify/android](https://github.com/gotify/android) |android client implementation |`Java` `Android` | |[gotify/plugin-template](https://github.com/gotify/plugin-template)|official gotify plugin template |`Go` | |[gotify/cli](https://github.com/gotify/cli) |official CLI client |`Go` | |[gotify/website](https://github.com/gotify/website) |documentaion [gotify.net](https://gotify.net/)|`Markdown` `Docusaurus` | |[gotify/contrib](https://github.com/gotify/contrib) |community-contributed projects |`misc` | ## Ways to Contribute ### Document Refinements _Keywords: **Documentation**, **Writing**_ 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. ### Feature Request and implementation _Keywords: **Features**, **Coding**_ When proposing features to gotify/\*, please first discuss the change you wish to make via issue, chat or any other method with the maintainers. 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. ### Bug Reports and Fixes _Keywords: **Bug Hunt**, **Coding**_ 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. 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. 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. ### Community Contribution Projects _Keywords:_ **Features**, **Coding**, **Writing** Make gotify more powerful and easy-to-use than ever by: - writing a [plugin](https://gotify.net/docs/plugin) - writing a client (smartphones, Windows, Linux, Browser Add-on, etc.) - writing about how you have used gotify for your applications 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. ================================================ FILE: GO_VERSION ================================================ 1.26.0 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 jmattheis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- The Gotify logo is licensed under the Creative Commons Attribution 4.0 International Public License. http://creativecommons.org/licenses/by/4.0/ ================================================ FILE: Makefile ================================================ LICENSE_DIR=./licenses/ BUILD_DIR=./build DOCKER_DIR=./docker/ SHELL := /bin/bash GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//') DOCKER_BUILD_IMAGE=docker.io/gotify/build DOCKER_WORKDIR=/proj DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR} DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS" DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build test: test-coverage test-js check: check-go check-swagger check-js check-ci: check-swagger check-js require-version: if [ -n ${VERSION} ] && [[ $$VERSION == "v"* ]]; then echo "The version may not start with v" && exit 1; fi if [ -z ${VERSION} ]; then echo "Need to set VERSION" && exit 1; fi; test-coverage: go test --race -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./... format: goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*") test-js: go build -ldflags="-s -w -X main.Mode=prod" -o removeme/gotify app.go (cd ui && CI=true GOTIFY_EXE=../removeme/gotify yarn test) rm -rf removeme check-go: golangci-lint run check-js: (cd ui && yarn lint) (cd ui && yarn testformat) download-tools: go install github.com/go-swagger/go-swagger/cmd/swagger@717e3cb29becaaf00e56953556c6d80f8a01b286 update-swagger: swagger generate spec --scan-models -o docs/spec.json sed -i 's/"uint64"/"int64"/g' docs/spec.json check-swagger: update-swagger ## add the docs to git, this changes line endings in git, otherwise this does not work on windows git add docs if [ -n "$(shell git status --porcelain | grep docs)" ]; then \ echo Swagger Spec is not up-to-date; \ exit 1; \ fi extract-licenses: mkdir ${LICENSE_DIR} || true for LICENSE in $(shell find vendor/* -name LICENSE); do \ DIR=`echo $$LICENSE | tr "/" _ | sed -e 's/vendor_//; s/_LICENSE//'` ; \ cp $$LICENSE ${LICENSE_DIR}$$DIR ; \ done package-zip: extract-licenses for BUILD in $(shell find ${BUILD_DIR}/*); do \ zip -j $$BUILD.zip $$BUILD ./LICENSE; \ zip -ur $$BUILD.zip ${LICENSE_DIR}; \ done build-docker-multiarch: require-version docker buildx build --sbom=true --provenance=true \ $(if $(DOCKER_BUILD_PUSH),--push) \ --label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \ --label org.opencontainers.image.version=$(VERSION) \ --label org.opencontainers.image.created=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \ -t gotify/server:latest \ -t gotify/server:${VERSION} \ -t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t ghcr.io/gotify/server:latest \ -t ghcr.io/gotify/server:${VERSION} \ -t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t gotify/server-arm64:latest \ -t gotify/server-arm64:${VERSION} \ -t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t ghcr.io/gotify/server-arm64:latest \ -t ghcr.io/gotify/server-arm64:${VERSION} \ -t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t gotify/server-arm7:latest \ -t gotify/server-arm7:${VERSION} \ -t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t ghcr.io/gotify/server-arm7:latest \ -t ghcr.io/gotify/server-arm7:${VERSION} \ -t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t gotify/server-riscv64:latest \ -t gotify/server-riscv64:${VERSION} \ -t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \ -t ghcr.io/gotify/server-riscv64:latest \ -t ghcr.io/gotify/server-riscv64:${VERSION} \ -t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \ -t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \ --build-arg RUN_TESTS=$(DOCKER_TEST_LEVEL) \ --build-arg GO_VERSION=$(GO_VERSION) \ --build-arg LD_FLAGS="$$LD_FLAGS" \ --platform linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/riscv64 \ -f docker/Dockerfile . build-docker: build-docker-multiarch _build_within_docker: OUTPUT = gotify-app _build_within_docker: ${DOCKER_GO_BUILD} -o ${OUTPUT} build-js: (cd ui && yarn build) build-linux-amd64: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64 build-linux-386: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-386 build-linux-arm-7: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm-7 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm-7 build-linux-arm64: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm64 build-linux-riscv64: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-riscv64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-riscv64 build-windows-amd64: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-amd64.exe build-windows-386: ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-386.exe build: build-linux-arm-7 build-linux-amd64 build-linux-386 build-linux-arm64 build-linux-riscv64 build-windows-amd64 build-windows-386 .PHONY: test-coverage test check-go check-js verify-swagger check download-tools update-swagger package-zip build-docker build-js build ================================================ FILE: README.md ================================================

gotify/server

Build Status codecov Go Report Card Matrix Docker Pulls latest release

## Intro 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. ## Features Gotify UI screenshot * send messages via REST-API * receive messages via WebSocket * manage users, clients and applications * [Plugins](https://gotify.net/docs/plugin) * Web-UI -> [./ui](ui) * CLI for sending messages -> [gotify/cli](https://github.com/gotify/cli) * Android-App -> [gotify/android](https://github.com/gotify/android) [Get it on Google Play][playstore] [Get it on F-Droid][fdroid] (Google Play and the Google Play logo are trademarks of Google LLC.) --- **[Documentation](https://gotify.net/docs)** [Install](https://gotify.net/docs/install) ᛫ [Configuration](https://gotify.net/docs/config) ᛫ [REST-API](https://gotify.net/api-docs) ᛫ [Setup Dev Environment](https://gotify.net/docs/dev-setup) ## Contributing We welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## Versioning We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/gotify/server/tags). ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details [playstore]: https://play.google.com/store/apps/details?id=com.github.gotify [fdroid]: https://f-droid.org/de/packages/com.github.gotify/ ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Only the latest version. ## Reporting a Vulnerability Please report (suspected) security vulnerabilities to **[gotify@protonmail.com](mailto:gotify@protonmail.com)**. You will receive a response from us within a few days. If the issue is confirmed, we will release a patch as soon as possible. ================================================ FILE: api/application.go ================================================ package api import ( "errors" "fmt" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" "github.com/h2non/filetype" "gorm.io/gorm" ) // The ApplicationDatabase interface for encapsulating database access. type ApplicationDatabase interface { CreateApplication(application *model.Application) error GetApplicationByToken(token string) (*model.Application, error) GetApplicationByID(id uint) (*model.Application, error) GetApplicationsByUser(userID uint) ([]*model.Application, error) DeleteApplicationByID(id uint) error UpdateApplication(application *model.Application) error } // The ApplicationAPI provides handlers for managing applications. type ApplicationAPI struct { DB ApplicationDatabase ImageDir string } // Application Params Model // // Params allowed to create or update Applications. // // swagger:model ApplicationParams type ApplicationParams struct { // The application name. This is how the application should be displayed to the user. // // required: true // example: Backup Server Name string `form:"name" query:"name" json:"name" binding:"required"` // The description of the application. // // example: Backup server for the interwebs Description string `form:"description" query:"description" json:"description"` // The default priority of messages sent by this application. Defaults to 0. // // example: 5 DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` // The sortKey for the application. Uses fractional indexing. // // example: a1 SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"` } // CreateApplication creates an application and returns the access token. // swagger:operation POST /application application createApp // // Create an application. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the application to add // required: true // schema: // $ref: "#/definitions/ApplicationParams" // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Application" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { app := model.Application{ Name: applicationParams.Name, Description: applicationParams.Description, DefaultPriority: applicationParams.DefaultPriority, SortKey: applicationParams.SortKey, Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), UserID: auth.GetUserID(ctx), Internal: false, } if err := a.DB.CreateApplication(&app); err != nil { handleApplicationError(ctx, err) return } ctx.JSON(200, withResolvedImage(&app)) } } // GetApplications returns all applications a user has. // swagger:operation GET /application application getApps // // Return all applications. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: array // items: // $ref: "#/definitions/Application" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) GetApplications(ctx *gin.Context) { userID := auth.GetUserID(ctx) apps, err := a.DB.GetApplicationsByUser(userID) if success := successOrAbort(ctx, 500, err); !success { return } for _, app := range apps { withResolvedImage(app) } ctx.JSON(200, apps) } // DeleteApplication deletes an application by its id. // swagger:operation DELETE /application/{id} application deleteApp // // Delete an application. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { app, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { if app.Internal { ctx.AbortWithError(400, errors.New("cannot delete internal application")) return } if success := successOrAbort(ctx, 500, a.DB.DeleteApplicationByID(id)); !success { return } if app.Image != "" { os.Remove(a.ImageDir + app.Image) } } else { ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id)) } }) } // UpdateApplication updates an application info by its id. // swagger:operation PUT /application/{id} application updateApplication // // Update an application. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the application to update // required: true // schema: // $ref: "#/definitions/ApplicationParams" // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Application" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { app, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { app.Description = applicationParams.Description app.Name = applicationParams.Name app.DefaultPriority = applicationParams.DefaultPriority if applicationParams.SortKey != "" { app.SortKey = applicationParams.SortKey } if err := a.DB.UpdateApplication(app); err != nil { handleApplicationError(ctx, err) return } ctx.JSON(200, withResolvedImage(app)) } } else { ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id)) } }) } // UploadApplicationImage uploads an image for an application. // swagger:operation POST /application/{id}/image application uploadAppImage // // Upload an image for an application. // // --- // consumes: // - multipart/form-data // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: file // in: formData // description: the application image // required: true // type: file // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Application" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Server Error // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) { withID(ctx, "id", func(id uint) { app, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { file, err := ctx.FormFile("file") if err == http.ErrMissingFile { ctx.AbortWithError(400, errors.New("file with key 'file' must be present")) return } else if err != nil { ctx.AbortWithError(500, err) return } head := make([]byte, 261) open, _ := file.Open() open.Read(head) if !filetype.IsImage(head) { ctx.AbortWithError(400, errors.New("file must be an image")) return } ext := filepath.Ext(file.Filename) if !ValidApplicationImageExt(ext) { ctx.AbortWithError(400, errors.New("invalid file extension")) return } name := generateNonExistingImageName(a.ImageDir, func() string { return generateImageName() + ext }) err = ctx.SaveUploadedFile(file, a.ImageDir+name) if err != nil { ctx.AbortWithError(500, err) return } if app.Image != "" { os.Remove(a.ImageDir + app.Image) } app.Image = name if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success { return } ctx.JSON(200, withResolvedImage(app)) } else { ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id)) } }) } // RemoveApplicationImage deletes an image of an application. // swagger:operation DELETE /application/{id}/image application removeAppImage // // Deletes an image of an application. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Server Error // schema: // $ref: "#/definitions/Error" func (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) { withID(ctx, "id", func(id uint) { app, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { if app.Image == "" { ctx.AbortWithError(400, fmt.Errorf("app with id %d does not have a customized image", id)) return } image := app.Image app.Image = "" if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success { return } os.Remove(a.ImageDir + image) ctx.JSON(200, withResolvedImage(app)) } else { ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id)) } }) } func withResolvedImage(app *model.Application) *model.Application { if app.Image == "" { // This must stay in sync with the isDefaultImage check in ui/src/application/Applications.tsx. app.Image = "static/defaultapp.png" } else { app.Image = "image/" + app.Image } return app } func (a *ApplicationAPI) applicationExists(token string) bool { app, _ := a.DB.GetApplicationByToken(token) return app != nil } func exist(path string) bool { if _, err := os.Stat(path); os.IsNotExist(err) { return false } return true } func generateNonExistingImageName(imgDir string, gen func() string) string { for { name := gen() if !exist(imgDir + name) { return name } } } func ValidApplicationImageExt(ext string) bool { switch strings.ToLower(ext) { case ".gif", ".png", ".jpg", ".jpeg": return true default: return false } } func handleApplicationError(ctx *gin.Context, err error) { if errors.Is(err, gorm.ErrDuplicatedKey) { ctx.AbortWithError(400, errors.New("sort key is not unique")) } else { ctx.AbortWithError(500, err) } } ================================================ FILE: api/application_test.go ================================================ package api import ( "bytes" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http/httptest" "os" "strings" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) var ( firstApplicationToken = "Aaaaaaaaaaaaaaa" secondApplicationToken = "Abbbbbbbbbbbbbb" thirdApplicationToken = "Acccccccccccccc" ) func TestApplicationSuite(t *testing.T) { suite.Run(t, new(ApplicationSuite)) } type ApplicationSuite struct { suite.Suite db *testdb.Database a *ApplicationAPI ctx *gin.Context recorder *httptest.ResponseRecorder } var ( originalGenerateApplicationToken func() string originalGenerateImageName func() string ) func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { originalGenerateApplicationToken = generateApplicationToken originalGenerateImageName = generateImageName generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken) generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:]) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") s.a = &ApplicationAPI{DB: s.db} } func (s *ApplicationSuite) AfterTest(suiteName, testName string) { generateApplicationToken = originalGenerateApplicationToken generateImageName = originalGenerateImageName s.db.Close() } func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name&description=description_text&sortKey=a5") s.a.CreateApplication(s.ctx) expected := &model.Application{ ID: 1, Token: firstApplicationToken, UserID: 5, Name: "custom_name", Description: "description_text", SortKey: "a5", } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), expected, app) } } func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() { actual := &model.Application{ ID: 1, UserID: 2, Token: "Aasdasfgeeg", Name: "myapp", Description: "mydesc", Image: "asd", Internal: true, LastUsed: nil, SortKey: "a1", } test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=&description=description_text") s.a.CreateApplication(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { assert.Empty(s.T(), app) } } func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInParams() { s.db.User(5) test.WithUser(s.ctx, 5) s.withJSON(&model.Application{ Name: "name", Description: "description", ID: 333, Internal: true, Token: "token", Image: "adfdf", SortKey: "a5", }) s.a.CreateApplication(s.ctx) expectedJSONValue, _ := json.Marshal(&model.Application{ ID: 1, Token: firstApplicationToken, UserID: 5, Name: "name", Description: "description", Internal: false, Image: "static/defaultapp.png", SortKey: "a5", }) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), string(expectedJSONValue), s.recorder.Body.String()) } func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { s.db.User(2) s.db.User(5).App(5) test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("DELETE", "/token/5", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "5"}} s.a.DeleteApplication(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.db.AssertAppExist(5) } func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"} assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), app, expected) } } func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) expected := &model.Application{ ID: 1, Token: firstApplicationToken, Name: "custom_name", Image: "static/defaultapp.png", UserID: 5, SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.db.User(5) s.db.User(6).AppWithToken(1, firstApplicationToken) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"} assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), app, expected) } } func (s *ApplicationSuite) Test_Sorting() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=one") s.a.CreateApplication(s.ctx) test.WithUser(s.ctx, 5) s.withFormData("name=two") s.a.CreateApplication(s.ctx) test.WithUser(s.ctx, 5) s.withFormData("name=three") s.a.CreateApplication(s.ctx) apps, err := s.db.GetApplicationsByUser(5) require.NoError(s.T(), err) require.Len(s.T(), apps, 3) assert.Equal(s.T(), apps[0].Name, "one") assert.Equal(s.T(), apps[0].SortKey, "a0") assert.Equal(s.T(), apps[1].Name, "two") assert.Equal(s.T(), apps[1].SortKey, "a1") assert.Equal(s.T(), apps[2].Name, "three") assert.Equal(s.T(), apps[2].SortKey, "a2") s.withFormData("name=one&description=&sortKey=a1V") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(apps[0].ID)}} s.a.UpdateApplication(s.ctx) apps, err = s.db.GetApplicationsByUser(5) require.NoError(s.T(), err) require.Len(s.T(), apps, 3) assert.Equal(s.T(), apps[0].Name, "two") assert.Equal(s.T(), apps[0].SortKey, "a1") assert.Equal(s.T(), apps[1].Name, "one") assert.Equal(s.T(), apps[1].SortKey, "a1V") assert.Equal(s.T(), apps[2].Name, "three") assert.Equal(s.T(), apps[2].SortKey, "a2") } func (s *ApplicationSuite) Test_GetApplications() { userBuilder := s.db.User(5) first := userBuilder.NewAppWithToken(1, "perfper") second := userBuilder.NewAppWithToken(2, "asdasd") test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) s.a.GetApplications(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) first.Image = "static/defaultapp.png" second.Image = "static/defaultapp.png" test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } func (s *ApplicationSuite) Test_GetApplications_WithImage() { userBuilder := s.db.User(5) first := userBuilder.NewAppWithToken(1, "perfper") second := userBuilder.NewAppWithToken(2, "asdasd") first.Image = "abcd.jpg" s.db.UpdateApplication(first) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) s.a.GetApplications(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) first.Image = "image/abcd.jpg" second.Image = "static/defaultapp.png" test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() { s.db.User(5).InternalApp(10) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) s.ctx.Params = gin.Params{{Key: "id", Value: "10"}} s.a.DeleteApplication(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} s.a.DeleteApplication(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ApplicationSuite) Test_DeleteApplication() { s.db.User(5).App(1) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DeleteApplication(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertAppNotExist(1) } func (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() { s.db.User(5).App(1) var b bytes.Buffer writer := multipart.NewWriter(&b) writer.Close() s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b) s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file with key 'file' must be present")) } func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() { s.db.User(5).App(1) var b bytes.Buffer writer := multipart.NewWriter(&b) defer writer.Close() s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b) s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) assert.Error(s.T(), s.ctx.Errors[0].Err, "multipart: NextPart: EOF") } func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) assert.Nil(s.T(), err) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { imgName := app.Image assert.Equal(s.T(), 200, s.recorder.Code) _, err = os.Stat(imgName) assert.Nil(s.T(), err) s.a.DeleteApplication(s.ctx) _, err = os.Stat(imgName) assert.True(s.T(), os.IsNotExist(err)) } } func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() { existingImageName := "2lHMAel6BDHLL-HrwphcviX-l.png" firstGeneratedImageName := firstApplicationToken[1:] + ".png" secondGeneratedImageName := secondApplicationToken[1:] + ".png" s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName}) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) assert.Nil(s.T(), err) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} fakeImage(s.T(), existingImageName) fakeImage(s.T(), firstGeneratedImageName) s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) _, err = os.Stat(existingImageName) assert.True(s.T(), os.IsNotExist(err)) _, err = os.Stat(secondGeneratedImageName) assert.Nil(s.T(), err) assert.Nil(s.T(), os.Remove(secondGeneratedImageName)) assert.Nil(s.T(), os.Remove(firstGeneratedImageName)) } func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"}) fakeImage(s.T(), "existing.png") cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) assert.Nil(s.T(), err) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) _, err = os.Stat("existing.png") assert.True(s.T(), os.IsNotExist(err)) os.Remove(firstApplicationToken[1:] + ".png") } func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/text.txt")}) assert.Nil(s.T(), err) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file must be an image")) } func (s *ApplicationSuite) Test_UploadAppImage_WithHtmlFileHavingImageHeader() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image-header-with.html")}) assert.Nil(s.T(), err) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("invalid file extension")) } func (s *ApplicationSuite) Test_UploadAppImage_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ApplicationSuite) Test_RemoveAppImage_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} s.a.RemoveApplicationImage(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ApplicationSuite) Test_RemoveAppImage_noCustomizedImage() { s.db.User(5).App(1) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.RemoveApplicationImage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() { s.db.User(5) imageFile := "existing.png" s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile}) fakeImage(s.T(), imageFile) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.RemoveApplicationImage(s.ctx) _, err := os.Stat(imageFile) assert.True(s.T(), os.IsNotExist(err)) assert.Equal(s.T(), 200, s.recorder.Code) } func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSuccess() { s.db.User(5).NewAppWithToken(2, "app-2") test.WithUser(s.ctx, 5) s.withFormData("name=new_name&description=new_description_text") s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) expected := &model.Application{ ID: 2, Token: "app-2", UserID: 5, Name: "new_name", Description: "new_description_text", SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), expected, app) } } func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() { s.db.User(5).NewAppWithToken(2, "app-2") test.WithUser(s.ctx, 5) s.withFormData("name=new_name") s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) expected := &model.Application{ ID: 2, Token: "app-2", UserID: 5, Name: "new_name", Description: "", SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), expected, app) } } func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() { s.db.User(5).NewAppWithToken(2, "app-2") test.WithUser(s.ctx, 5) s.withFormData("name=name&description=&defaultPriority=4") s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) expected := &model.Application{ ID: 2, Token: "app-2", UserID: 5, Name: "name", Description: "", DefaultPriority: 4, SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), expected, app) } } func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() { app := s.db.User(5).NewAppWithToken(2, "app-2") app.Image = "existing.png" app.SortKey = "a5" assert.Nil(s.T(), s.db.UpdateApplication(app)) test.WithUser(s.ctx, 5) s.withFormData("name=new_name") s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), "existing.png", app.Image) assert.Equal(s.T(), "a5", app.SortKey) } } func (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() { app := s.db.User(5).NewAppWithToken(2, "app-2") app.Description = "my desc" assert.Nil(s.T(), s.db.UpdateApplication(app)) test.WithUser(s.ctx, 5) s.withFormData("name=new_name&desc=") s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), "", app.Description) } } func (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() { test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttributes_expectBadRequest() { test.WithUser(s.ctx, 5) s.a.UpdateApplication(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFound() { s.db.User(5).NewAppWithToken(2, "app-2") test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() { user := s.db.User(5) user.App(1) // sortKey=a0 user.App(2) // sortKey=a1 s.withFormData("name=new_name&sortKey=a0") test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateApplication(s.ctx) assert.EqualError(s.T(), s.ctx.Errors[0].Err, "sort key is not unique") assert.Equal(s.T(), 400, s.recorder.Code) } func (s *ApplicationSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } func (s *ApplicationSuite) withJSON(value interface{}) { jsonVal, _ := json.Marshal(value) s.ctx.Request = httptest.NewRequest("POST", "/application", bytes.NewBuffer(jsonVal)) s.ctx.Request.Header.Set("Content-Type", "application/json") } // A modified version of https://stackoverflow.com/a/20397167/4244993 from Attila O. func upload(values map[string]*os.File) (contentType string, buffer bytes.Buffer, err error) { w := multipart.NewWriter(&buffer) for key, r := range values { var fw io.Writer if fw, err = w.CreateFormFile(key, r.Name()); err != nil { return contentType, buffer, err } if _, err = io.Copy(fw, r); err != nil { return contentType, buffer, err } } contentType = w.FormDataContentType() w.Close() return contentType, buffer, err } func mustOpen(f string) *os.File { r, err := os.Open(f) if err != nil { panic(err) } return r } func fakeImage(t *testing.T, path string) { data, err := os.ReadFile("../test/assets/image.png") assert.Nil(t, err) // Write data to dst err = os.WriteFile(path, data, 0o644) assert.Nil(t, err) } ================================================ FILE: api/client.go ================================================ package api import ( "fmt" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" ) // The ClientDatabase interface for encapsulating database access. type ClientDatabase interface { CreateClient(client *model.Client) error GetClientByToken(token string) (*model.Client, error) GetClientByID(id uint) (*model.Client, error) GetClientsByUser(userID uint) ([]*model.Client, error) DeleteClientByID(id uint) error UpdateClient(client *model.Client) error } // The ClientAPI provides handlers for managing clients and applications. type ClientAPI struct { DB ClientDatabase ImageDir string NotifyDeleted func(uint, string) } // Client Params Model // // Params allowed to create or update Clients. // // swagger:model ClientParams type ClientParams struct { // The client name // // required: true // example: My Client Name string `form:"name" query:"name" json:"name" binding:"required"` } // UpdateClient updates a client by its id. // swagger:operation PUT /client/{id} client updateClient // // Update a client. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the client to update // required: true // schema: // $ref: "#/definitions/ClientParams" // - name: id // in: path // description: the client id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Client" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *ClientAPI) UpdateClient(ctx *gin.Context) { withID(ctx, "id", func(id uint) { client, err := a.DB.GetClientByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if client != nil && client.UserID == auth.GetUserID(ctx) { newValues := ClientParams{} if err := ctx.Bind(&newValues); err == nil { client.Name = newValues.Name if success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success { return } ctx.JSON(200, client) } } else { ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) } }) } // CreateClient creates a client and returns the access token. // swagger:operation POST /client client createClient // // Create a client. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the client to add // required: true // schema: // $ref: "#/definitions/ClientParams" // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Client" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *ClientAPI) CreateClient(ctx *gin.Context) { clientParams := ClientParams{} if err := ctx.Bind(&clientParams); err == nil { client := model.Client{ Name: clientParams.Name, Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), UserID: auth.GetUserID(ctx), } if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return } ctx.JSON(200, client) } } // GetClients returns all clients a user has. // swagger:operation GET /client client getClients // // Return all clients. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: array // items: // $ref: "#/definitions/Client" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *ClientAPI) GetClients(ctx *gin.Context) { userID := auth.GetUserID(ctx) clients, err := a.DB.GetClientsByUser(userID) if success := successOrAbort(ctx, 500, err); !success { return } ctx.JSON(200, clients) } // DeleteClient deletes a client by its id. // swagger:operation DELETE /client/{id} client deleteClient // // Delete a client. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the client id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *ClientAPI) DeleteClient(ctx *gin.Context) { withID(ctx, "id", func(id uint) { client, err := a.DB.GetClientByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if client != nil && client.UserID == auth.GetUserID(ctx) { a.NotifyDeleted(client.UserID, client.Token) successOrAbort(ctx, 500, a.DB.DeleteClientByID(id)) } else { ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) } }) } func (a *ClientAPI) clientExists(token string) bool { client, _ := a.DB.GetClientByToken(token) return client != nil } ================================================ FILE: api/client_test.go ================================================ package api import ( "net/http/httptest" "net/url" "strings" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) var ( firstClientToken = "Caaaaaaaaaaaaaa" secondClientToken = "Cbbbbbbbbbbbbbb" ) func TestClientSuite(t *testing.T) { suite.Run(t, new(ClientSuite)) } type ClientSuite struct { suite.Suite db *testdb.Database a *ClientAPI ctx *gin.Context recorder *httptest.ResponseRecorder notified bool } var originalGenerateClientToken func() string func (s *ClientSuite) BeforeTest(suiteName, testName string) { originalGenerateClientToken = generateClientToken generateClientToken = test.Tokens(firstClientToken, secondClientToken) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") s.notified = false s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify} } func (s *ClientSuite) notify(uint, string) { s.notified = true } func (s *ClientSuite) AfterTest(suiteName, testName string) { generateClientToken = originalGenerateClientToken s.db.Close() } func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() { actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"} test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`) } func (s *ClientSuite) Test_CreateClient_mapAllParameters() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name&description=description_text") s.a.CreateClient(s.ctx) expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"} assert.Equal(s.T(), 200, s.recorder.Code) if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), clients, expected) } } func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=myclient&ID=45&Token=12341234&UserID=333") s.a.CreateClient(s.ctx) expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: "myclient"} assert.Equal(s.T(), 200, s.recorder.Code) if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), clients, expected) } } func (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=&description=description_text") s.a.CreateClient(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { assert.Empty(s.T(), clients) } } func (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() { s.db.User(5).Client(7) s.db.User(2) test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("DELETE", "/token/7", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "7"}} s.a.DeleteClient(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.db.AssertClientExist(7) } func (s *ClientSuite) Test_CreateClient_returnsClientWithID() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateClient(s.ctx) expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5} assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } func (s *ClientSuite) Test_CreateClient_withExistingToken() { s.db.User(5).ClientWithToken(1, firstClientToken) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateClient(s.ctx) expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5} assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } func (s *ClientSuite) Test_GetClients() { userBuilder := s.db.User(5) first := userBuilder.NewClientWithToken(1, "perfper") second := userBuilder.NewClientWithToken(2, "asdasd") test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) s.a.GetClients(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder) } func (s *ClientSuite) Test_DeleteClient_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} s.a.DeleteClient(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ClientSuite) Test_DeleteClient() { s.db.User(5).Client(8) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} assert.False(s.T(), s.notified) s.a.DeleteClient(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertClientNotExist(8) assert.True(s.T(), s.notified) } func (s *ClientSuite) Test_UpdateClient_expectSuccess() { s.db.User(5).NewClientWithToken(1, firstClientToken) test.WithUser(s.ctx, 5) s.withFormData("name=firefox") s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.UpdateClient(s.ctx) expected := &model.Client{ ID: 1, Token: firstClientToken, UserID: 5, Name: "firefox", } assert.Equal(s.T(), 200, s.recorder.Code) if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), expected, client) } } func (s *ClientSuite) Test_UpdateClient_expectNotFound() { test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.UpdateClient(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() { test.WithUser(s.ctx, 5) s.a.UpdateClient(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *ClientSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } func withURL(ctx *gin.Context, scheme, host string) { ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) } ================================================ FILE: api/errorHandling.go ================================================ package api import "github.com/gin-gonic/gin" func successOrAbort(ctx *gin.Context, code int, err error) (success bool) { if err != nil { ctx.AbortWithError(code, err) } return err == nil } ================================================ FILE: api/errorHandling_test.go ================================================ package api import ( "errors" "net/http/httptest" "testing" "github.com/gin-gonic/gin" ) func TestErrorHandling(t *testing.T) { rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) successOrAbort(ctx, 500, errors.New("err")) if rec.Code != 500 { t.Fail() } } ================================================ FILE: api/health.go ================================================ package api import ( "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" ) // The HealthDatabase interface for encapsulating database access. type HealthDatabase interface { Ping() error } // The HealthAPI provides handlers for the health information. type HealthAPI struct { DB HealthDatabase } // Health returns health information. // swagger:operation GET /health health getHealth // // Get health information. // // --- // produces: [application/json] // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Health" // 500: // description: Ok // schema: // $ref: "#/definitions/Health" func (a *HealthAPI) Health(ctx *gin.Context) { if err := a.DB.Ping(); err != nil { ctx.JSON(500, model.Health{ Health: model.StatusOrange, Database: model.StatusRed, }) return } ctx.JSON(200, model.Health{ Health: model.StatusGreen, Database: model.StatusGreen, }) } ================================================ FILE: api/health_test.go ================================================ package api import ( "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/suite" ) func TestHealthSuite(t *testing.T) { suite.Run(t, new(HealthSuite)) } type HealthSuite struct { suite.Suite db *testdb.Database a *HealthAPI ctx *gin.Context recorder *httptest.ResponseRecorder } func (s *HealthSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") s.a = &HealthAPI{DB: s.db} } func (s *HealthSuite) AfterTest(suiteName, testName string) { s.db.Close() } func (s *HealthSuite) TestHealthSuccess() { s.a.Health(s.ctx) test.BodyEquals(s.T(), model.Health{Health: model.StatusGreen, Database: model.StatusGreen}, s.recorder) } func (s *HealthSuite) TestDatabaseFailure() { s.db.Close() s.a.Health(s.ctx) test.BodyEquals(s.T(), model.Health{Health: model.StatusOrange, Database: model.StatusRed}, s.recorder) } ================================================ FILE: api/internalutil.go ================================================ package api import ( "errors" "math/bits" "strconv" "github.com/gin-gonic/gin" ) func withID(ctx *gin.Context, name string, f func(id uint)) { if id, err := strconv.ParseUint(ctx.Param(name), 10, bits.UintSize); err == nil { f(uint(id)) } else { ctx.AbortWithError(400, errors.New("invalid id")) } } ================================================ FILE: api/message.go ================================================ package api import ( "encoding/json" "errors" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/gotify/location" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" ) // The MessageDatabase interface for encapsulating database access. type MessageDatabase interface { GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error) GetApplicationByID(id uint) (*model.Application, error) GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error) DeleteMessageByID(id uint) error GetMessageByID(id uint) (*model.Message, error) DeleteMessagesByUser(userID uint) error DeleteMessagesByApplication(applicationID uint) error CreateMessage(message *model.Message) error GetApplicationByToken(token string) (*model.Application, error) } var timeNow = time.Now // Notifier notifies when a new message was created. type Notifier interface { Notify(userID uint, message *model.MessageExternal) } // The MessageAPI provides handlers for managing messages. type MessageAPI struct { DB MessageDatabase Notifier Notifier } type pagingParams struct { Limit int `form:"limit" binding:"min=1,max=200"` Since uint `form:"since" binding:"min=0"` } // GetMessages returns all messages from a user. // swagger:operation GET /message message getMessages // // Return all messages. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: limit // in: query // description: the maximal amount of messages to return // required: false // maximum: 200 // minimum: 1 // default: 100 // type: integer // - name: since // in: query // description: return all messages with an ID less than this value // minimum: 0 // required: false // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/PagedMessages" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) GetMessages(ctx *gin.Context) { userID := auth.GetUserID(ctx) withPaging(ctx, func(params *pagingParams) { // the +1 is used to check if there are more messages and will be removed on buildWithPaging messages, err := a.DB.GetMessagesByUserSince(userID, params.Limit+1, params.Since) if success := successOrAbort(ctx, 500, err); !success { return } ctx.JSON(200, buildWithPaging(ctx, params, messages)) }) } func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages { next := "" since := uint(0) useMessages := messages if len(messages) > paging.Limit { useMessages = messages[:len(messages)-1] since = useMessages[len(useMessages)-1].ID url := location.Get(ctx) url.Path = ctx.Request.URL.Path query := url.Query() query.Add("limit", strconv.Itoa(paging.Limit)) query.Add("since", strconv.FormatUint(uint64(since), 10)) url.RawQuery = query.Encode() next = url.String() } return &model.PagedMessages{ Paging: model.Paging{Size: len(useMessages), Limit: paging.Limit, Next: next, Since: since}, Messages: toExternalMessages(useMessages), } } func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) { params := &pagingParams{Limit: 100} if err := ctx.MustBindWith(params, binding.Query); err == nil { f(params) } } // GetMessagesWithApplication returns all messages from a specific application. // swagger:operation GET /application/{id}/message message getAppMessages // // Return all messages from a specific application. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // - name: limit // in: query // description: the maximal amount of messages to return // required: false // maximum: 200 // minimum: 1 // default: 100 // type: integer // - name: since // in: query // description: return all messages with an ID less than this value // minimum: 0 // required: false // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/PagedMessages" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { withPaging(ctx, func(params *pagingParams) { app, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { // the +1 is used to check if there are more messages and will be removed on buildWithPaging messages, err := a.DB.GetMessagesByApplicationSince(id, params.Limit+1, params.Since) if success := successOrAbort(ctx, 500, err); !success { return } ctx.JSON(200, buildWithPaging(ctx, params, messages)) } else { ctx.AbortWithError(404, errors.New("application does not exist")) } }) }) } // DeleteMessages delete all messages from a user. // swagger:operation DELETE /message message deleteMessages // // Delete all messages. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) DeleteMessages(ctx *gin.Context) { userID := auth.GetUserID(ctx) successOrAbort(ctx, 500, a.DB.DeleteMessagesByUser(userID)) } // DeleteMessageWithApplication deletes all messages from a specific application. // swagger:operation DELETE /application/{id}/message message deleteAppMessages // // Delete all messages from a specific application. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the application id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) DeleteMessageWithApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { application, err := a.DB.GetApplicationByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if application != nil && application.UserID == auth.GetUserID(ctx) { successOrAbort(ctx, 500, a.DB.DeleteMessagesByApplication(id)) } else { ctx.AbortWithError(404, errors.New("application does not exists")) } }) } // DeleteMessage deletes a message with an id. // swagger:operation DELETE /message/{id} message deleteMessage // // Deletes a message with an id. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the message id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { withID(ctx, "id", func(id uint) { msg, err := a.DB.GetMessageByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if msg == nil { ctx.AbortWithError(404, errors.New("message does not exist")) return } app, err := a.DB.GetApplicationByID(msg.ApplicationID) if success := successOrAbort(ctx, 500, err); !success { return } if app != nil && app.UserID == auth.GetUserID(ctx) { successOrAbort(ctx, 500, a.DB.DeleteMessageByID(id)) } else { ctx.AbortWithError(404, errors.New("message does not exist")) } }) } // CreateMessage creates a message, authentication via application-token is required. // swagger:operation POST /message message createMessage // // Create a message. // // __NOTE__: This API ONLY accepts an application token as authentication. // // --- // consumes: [application/json] // produces: [application/json] // security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []] // parameters: // - name: body // in: body // description: the message to add // required: true // schema: // $ref: "#/definitions/Message" // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Message" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message := model.MessageExternal{} if err := ctx.Bind(&message); err == nil { application, err := a.DB.GetApplicationByToken(auth.GetTokenID(ctx)) if success := successOrAbort(ctx, 500, err); !success { return } message.ApplicationID = application.ID if strings.TrimSpace(message.Title) == "" { message.Title = application.Name } if message.Priority == nil { message.Priority = &application.DefaultPriority } message.Date = timeNow() message.ID = 0 msgInternal := toInternalMessage(&message) if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { return } a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) ctx.JSON(200, toExternalMessage(msgInternal)) } } func toInternalMessage(msg *model.MessageExternal) *model.Message { res := &model.Message{ ID: msg.ID, ApplicationID: msg.ApplicationID, Message: msg.Message, Title: msg.Title, Date: msg.Date, } if msg.Priority != nil { res.Priority = *msg.Priority } if msg.Extras != nil { res.Extras, _ = json.Marshal(msg.Extras) } return res } func toExternalMessage(msg *model.Message) *model.MessageExternal { res := &model.MessageExternal{ ID: msg.ID, ApplicationID: msg.ApplicationID, Message: msg.Message, Title: msg.Title, Priority: &msg.Priority, Date: msg.Date, } if len(msg.Extras) != 0 { res.Extras = make(map[string]interface{}) json.Unmarshal(msg.Extras, &res.Extras) } return res } func toExternalMessages(msg []*model.Message) []*model.MessageExternal { res := make([]*model.MessageExternal, len(msg)) for i := range msg { res[i] = toExternalMessage(msg[i]) } return res } ================================================ FILE: api/message_test.go ================================================ package api import ( "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func TestMessageSuite(t *testing.T) { suite.Run(t, new(MessageSuite)) } type MessageSuite struct { suite.Suite db *testdb.Database a *MessageAPI ctx *gin.Context recorder *httptest.ResponseRecorder notifiedMessage *model.MessageExternal } func (s *MessageSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil) s.db = testdb.NewDB(s.T()) s.notifiedMessage = nil s.a = &MessageAPI{DB: s.db, Notifier: s} } func (s *MessageSuite) AfterTest(string, string) { s.db.Close() } func (s *MessageSuite) Notify(userID uint, msg *model.MessageExternal) { s.notifiedMessage = msg } func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() { t, _ := time.Parse("2006/01/02", "2017/01/02") actual := &model.PagedMessages{ Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"}, Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{ "test::string": "string", "test::array": []interface{}{1, 2, 3}, "test::int": 1, "test::float": 0.5, }}}, } test.JSONEquals(s.T(), actual, `{"paging": {"limit":5, "since": 122, "size": 5, "next": "http://example.com/message?limit=5&since=122"}, "messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z","extras":{"test::string":"string","test::array":[1,2,3],"test::int":1,"test::float":0.5}}]}`) } func (s *MessageSuite) Test_GetMessages() { user := s.db.User(5) first := user.App(1).NewMessage(1) second := user.App(2).NewMessage(2) firstExternal := toExternalMessage(&first) secondExternal := toExternalMessage(&second) test.WithUser(s.ctx, 5) s.a.GetMessages(s.ctx) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 100, Size: 2, Next: ""}, Messages: []*model.MessageExternal{secondExternal, firstExternal}, } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() { user := s.db.User(5) app1 := user.App(1) app2 := user.App(2) var messages []*model.Message for i := 100; i >= 1; i -= 2 { one := app2.NewMessage(uint(i)) two := app1.NewMessage(uint(i - 1)) messages = append(messages, &one, &two) } s.withURL("http", "example.com", "/messages", "limit=5") test.WithUser(s.ctx, 5) s.a.GetMessages(s.ctx) // Since: entries with ids from 100 - 96 will be returned (5 entries) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 5, Size: 5, Since: 96, Next: "http://example.com/messages?limit=5&since=96"}, Messages: toExternalMessages(messages[:5]), } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() { user := s.db.User(5) app1 := user.App(1) app2 := user.App(2) var messages []*model.Message for i := 100; i >= 1; i -= 2 { one := app2.NewMessage(uint(i)) two := app1.NewMessage(uint(i - 1)) messages = append(messages, &one, &two) } s.withURL("http", "example.com", "/messages", "limit=13&since=55") test.WithUser(s.ctx, 5) s.a.GetMessages(s.ctx) // Since: entries with ids from 54 - 42 will be returned (13 entries) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/messages?limit=13&since=42"}, Messages: toExternalMessages(messages[46 : 46+13]), } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() { s.db.User(5) test.WithUser(s.ctx, 5) s.withURL("http", "example.com", "/messages", "limit=555") s.a.GetMessages(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() { s.db.User(5) test.WithUser(s.ctx, 5) s.withURL("http", "example.com", "/messages", "limit=-5") s.a.GetMessages(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() { s.db.User(4).App(2).NewMessage(1) test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.withURL("http", "example.com", "/messages", "limit=555") s.a.GetMessagesWithApplication(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *MessageSuite) Test_GetMessagesWithToken() { msg := s.db.User(4).App(2).NewMessage(1) test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetMessagesWithApplication(s.ctx) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 100, Size: 1, Next: ""}, Messages: toExternalMessages([]*model.Message{&msg}), } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext() { user := s.db.User(5) app1 := user.App(2) var messages []*model.Message for i := 100; i >= 1; i-- { msg := app1.NewMessage(uint(i)) messages = append(messages, &msg) } s.withURL("http", "example.com", "/app/2/message", "limit=9") test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetMessagesWithApplication(s.ctx) // Since: entries with ids from 100 - 92 will be returned (9 entries) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 9, Size: 9, Since: 92, Next: "http://example.com/app/2/message?limit=9&since=92"}, Messages: toExternalMessages(messages[:9]), } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext() { user := s.db.User(5) app1 := user.App(2) var messages []*model.Message for i := 100; i >= 1; i-- { msg := app1.NewMessage(uint(i)) messages = append(messages, &msg) } s.withURL("http", "example.com", "/app/2/message", "limit=13&since=55") test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetMessagesWithApplication(s.ctx) // Since: entries with ids from 54 - 42 will be returned (13 entries) expected := &model.PagedMessages{ Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/app/2/message?limit=13&since=42"}, Messages: toExternalMessages(messages[46 : 46+13]), } test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectNotFound() { s.db.User(4) s.db.User(5).App(2).Message(66) test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetMessagesWithApplication(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessage_invalidID() { s.ctx.Params = gin.Params{{Key: "id", Value: "string"}} s.a.DeleteMessage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessage_notExistingID() { s.db.User(1).App(5).Message(55) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DeleteMessage(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessage_existingIDButNotOwner() { s.db.User(1).App(10).Message(100) s.db.User(2) test.WithUser(s.ctx, 2) s.ctx.Params = gin.Params{{Key: "id", Value: "100"}} s.a.DeleteMessage(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessage() { s.db.User(6).App(1).Message(50) test.WithUser(s.ctx, 6) s.ctx.Params = gin.Params{{Key: "id", Value: "50"}} s.a.DeleteMessage(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertMessageNotExist(50) } func (s *MessageSuite) Test_DeleteMessageWithID() { s.db.User(2).AppWithToken(5, "mytoken").Message(55) test.WithUser(s.ctx, 2) s.ctx.Params = gin.Params{{Key: "id", Value: "5"}} s.a.DeleteMessageWithApplication(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertMessageNotExist(55) } func (s *MessageSuite) Test_DeleteMessageWithToken_notExistingID() { s.db.User(2).AppWithToken(1, "wrong").Message(1) test.WithUser(s.ctx, 2) s.ctx.Params = gin.Params{{Key: "id", Value: "55"}} s.a.DeleteMessageWithApplication(s.ctx) s.db.AssertMessageExist(1) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessageWithToken_notOwner() { s.db.User(4) s.db.User(2).App(55).Message(5) test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "55"}} s.a.DeleteMessageWithApplication(s.ctx) s.db.AssertMessageExist(5) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *MessageSuite) Test_DeleteMessages() { userBuilder := s.db.User(4) userBuilder.App(5).Message(5).Message(6) userBuilder.App(2).Message(7).Message(8) s.db.User(5).App(7).Message(22) test.WithUser(s.ctx, 4) s.a.DeleteMessages(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertMessageExist(22) s.db.AssertMessageNotExist(5, 6, 7, 8) } func (s *MessageSuite) Test_CreateMessage_onJson_allParams() { t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(7, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": 1}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(7) assert.NoError(s.T(), err) expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), expected, s.notifiedMessage) } func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() { t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(8) assert.NoError(s.T(), err) expected := &model.MessageExternal{ID: 1, ApplicationID: 8, Title: "mytitle", Message: "mymessage", Priority: intPtr(5), Date: t} assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), expected, s.notifiedMessage) } func (s *MessageSuite) Test_CreateMessage_WithTitle() { t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(5, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(5) assert.NoError(s.T(), err) expected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: "mytitle", Message: "mymessage", Date: t, Priority: intPtr(0)} assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), expected, s.notifiedMessage) } func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(1, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) { assert.Empty(s.T(), msgs) } assert.Equal(s.T(), 400, s.recorder.Code) assert.Nil(s.T(), s.notifiedMessage) } func (s *MessageSuite) Test_CreateMessage_WithoutTitle() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(8) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), "Application name", msgs[0].Title) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), "mymessage", s.notifiedMessage.Message) } func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": " "}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(8) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), "Application name", msgs[0].Title) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), "mymessage", msgs[0].Message) } func (s *MessageSuite) Test_CreateMessage_IgnoreID() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "id": 1337}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(8) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assert.NotEqual(s.T(), msgs[0].ID, uint(1337)) assert.Equal(s.T(), 200, s.recorder.Code) } func (s *MessageSuite) Test_CreateMessage_WithExtras() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": "msg with extras", "extras": {"gotify::test":{"int":1,"float":0.5,"string":"test","array":[1,2,3]}}}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) msgs, err := s.db.GetMessagesByApplication(8) assert.NoError(s.T(), err) expected := &model.MessageExternal{ ID: 1, ApplicationID: 8, Message: "mymessage", Title: "msg with extras", Date: t, Priority: intPtr(0), Extras: map[string]interface{}{ "gotify::test": map[string]interface{}{ "string": "test", "array": []interface{}{float64(1), float64(2), float64(3)}, "int": float64(1), "float": float64(0.5), }, }, } assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), uint(1), s.notifiedMessage.ID) } func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(8, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": "asd"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateMessage(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Nil(s.T(), s.notifiedMessage) if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) { assert.Empty(s.T(), msgs) } } func (s *MessageSuite) Test_CreateMessage_onQueryData() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(2, "app-token") t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() s.ctx.Request = httptest.NewRequest("POST", "/message?title=mytitle&message=mymessage&priority=1", nil) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.a.CreateMessage(s.ctx) expected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} msgs, err := s.db.GetMessagesByApplication(2) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), uint(1), s.notifiedMessage.ID) } func (s *MessageSuite) Test_CreateMessage_onFormData() { auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") s.db.User(4).AppWithToken(99, "app-token") t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`title=mytitle&message=mymessage&priority=1`)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.a.CreateMessage(s.ctx) expected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} msgs, err := s.db.GetMessagesByApplication(99) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), uint(1), s.notifiedMessage.ID) } func (s *MessageSuite) withURL(scheme, host, path, query string) { s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query} s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) } func intPtr(x int) *int { return &x } ================================================ FILE: api/plugin.go ================================================ package api import ( "errors" "fmt" "io" "github.com/gin-gonic/gin" "github.com/gotify/location" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin" "github.com/gotify/server/v2/plugin/compat" "gopkg.in/yaml.v3" ) // The PluginDatabase interface for encapsulating database access. type PluginDatabase interface { GetPluginConfByUser(userid uint) ([]*model.PluginConf, error) UpdatePluginConf(p *model.PluginConf) error GetPluginConfByID(id uint) (*model.PluginConf, error) } // The PluginAPI provides handlers for managing plugins. type PluginAPI struct { Notifier Notifier Manager *plugin.Manager DB PluginDatabase } // GetPlugins returns all plugins a user has. // swagger:operation GET /plugin plugin getPlugins // // Return all plugins. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: array // items: // $ref: "#/definitions/PluginConf" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) GetPlugins(ctx *gin.Context) { userID := auth.GetUserID(ctx) plugins, err := c.DB.GetPluginConfByUser(userID) if success := successOrAbort(ctx, 500, err); !success { return } result := make([]model.PluginConfExternal, 0) for _, conf := range plugins { if inst, err := c.Manager.Instance(conf.ID); err == nil { info := c.Manager.PluginInfo(conf.ModulePath) result = append(result, model.PluginConfExternal{ ID: conf.ID, Name: info.String(), Token: conf.Token, ModulePath: conf.ModulePath, Author: info.Author, Website: info.Website, License: info.License, Enabled: conf.Enabled, Capabilities: inst.Supports().Strings(), }) } } ctx.JSON(200, result) } // EnablePlugin enables a plugin. // swagger:operation POST /plugin/{id}/enable plugin enablePlugin // // Enable a plugin. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the plugin id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) EnablePlugin(ctx *gin.Context) { withID(ctx, "id", func(id uint) { conf, err := c.DB.GetPluginConfByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if conf == nil || !isPluginOwner(ctx, conf) { ctx.AbortWithError(404, errors.New("unknown plugin")) return } _, err = c.Manager.Instance(id) if err != nil { ctx.AbortWithError(404, errors.New("plugin instance not found")) return } if err := c.Manager.SetPluginEnabled(id, true); err == plugin.ErrAlreadyEnabledOrDisabled { ctx.AbortWithError(400, err) } else if err != nil { ctx.AbortWithError(500, err) } }) } // DisablePlugin disables a plugin. // swagger:operation POST /plugin/{id}/disable plugin disablePlugin // // Disable a plugin. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the plugin id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) DisablePlugin(ctx *gin.Context) { withID(ctx, "id", func(id uint) { conf, err := c.DB.GetPluginConfByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if conf == nil || !isPluginOwner(ctx, conf) { ctx.AbortWithError(404, errors.New("unknown plugin")) return } _, err = c.Manager.Instance(id) if err != nil { ctx.AbortWithError(404, errors.New("plugin instance not found")) return } if err := c.Manager.SetPluginEnabled(id, false); err == plugin.ErrAlreadyEnabledOrDisabled { ctx.AbortWithError(400, err) } else if err != nil { ctx.AbortWithError(500, err) } }) } // GetDisplay get display info for Displayer plugin. // swagger:operation GET /plugin/{id}/display plugin getPluginDisplay // // Get display info for a Displayer plugin. // // --- // consumes: [application/json] // produces: [application/json] // parameters: // - name: id // in: path // description: the plugin id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: string // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) GetDisplay(ctx *gin.Context) { withID(ctx, "id", func(id uint) { conf, err := c.DB.GetPluginConfByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if conf == nil || !isPluginOwner(ctx, conf) { ctx.AbortWithError(404, errors.New("unknown plugin")) return } instance, err := c.Manager.Instance(id) if err != nil { ctx.AbortWithError(404, errors.New("plugin instance not found")) return } ctx.JSON(200, instance.GetDisplay(location.Get(ctx))) }) } // GetConfig returns Configurer plugin configuration in YAML format. // swagger:operation GET /plugin/{id}/config plugin getPluginConfig // // Get YAML configuration for Configurer plugin. // // --- // consumes: [application/json] // produces: [application/x-yaml] // parameters: // - name: id // in: path // description: the plugin id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: object // description: plugin configuration // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) GetConfig(ctx *gin.Context) { withID(ctx, "id", func(id uint) { conf, err := c.DB.GetPluginConfByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if conf == nil || !isPluginOwner(ctx, conf) { ctx.AbortWithError(404, errors.New("unknown plugin")) return } instance, err := c.Manager.Instance(id) if err != nil { ctx.AbortWithError(404, errors.New("plugin instance not found")) return } if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted { return } ctx.Header("content-type", "application/x-yaml") ctx.Writer.Write(conf.Config) }) } // UpdateConfig updates Configurer plugin configuration in YAML format. // swagger:operation POST /plugin/{id}/config plugin updatePluginConfig // // Update YAML configuration for Configurer plugin. // // --- // consumes: [application/x-yaml] // produces: [application/json] // parameters: // - name: id // in: path // description: the plugin id // required: true // type: integer // format: int64 // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" // 500: // description: Internal Server Error // schema: // $ref: "#/definitions/Error" func (c *PluginAPI) UpdateConfig(ctx *gin.Context) { withID(ctx, "id", func(id uint) { conf, err := c.DB.GetPluginConfByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if conf == nil || !isPluginOwner(ctx, conf) { ctx.AbortWithError(404, errors.New("unknown plugin")) return } instance, err := c.Manager.Instance(id) if err != nil { ctx.AbortWithError(404, errors.New("plugin instance not found")) return } if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted { return } newConf := instance.DefaultConfig() newconfBytes, err := io.ReadAll(ctx.Request.Body) if err != nil { ctx.AbortWithError(500, err) return } if err := yaml.Unmarshal(newconfBytes, newConf); err != nil { ctx.AbortWithError(400, err) return } if err := instance.ValidateAndSetConfig(newConf); err != nil { ctx.AbortWithError(400, err) return } conf.Config = newconfBytes successOrAbort(ctx, 500, c.DB.UpdatePluginConf(conf)) }) } func isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool { return conf.UserID == auth.GetUserID(ctx) } func supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, module compat.Capability) (aborted bool) { if compat.HasSupport(instance, module) { return false } ctx.AbortWithError(400, fmt.Errorf("plugin does not support %s", module)) return true } ================================================ FILE: api/plugin_test.go ================================================ package api import ( "bytes" "encoding/json" "errors" "fmt" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin" "github.com/gotify/server/v2/plugin/compat" "github.com/gotify/server/v2/plugin/testing/mock" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gopkg.in/yaml.v3" ) func TestPluginSuite(t *testing.T) { suite.Run(t, new(PluginSuite)) } type PluginSuite struct { suite.Suite db *testdb.Database a *PluginAPI ctx *gin.Context recorder *httptest.ResponseRecorder manager *plugin.Manager notified bool } func (s *PluginSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.db = testdb.NewDB(s.T()) s.resetRecorder() manager, err := plugin.NewManager(s.db, "", nil, s) assert.Nil(s.T(), err) s.manager = manager withURL(s.ctx, "http", "example.com") s.a = &PluginAPI{DB: s.db, Manager: manager, Notifier: s} mockPluginCompat := new(mock.Plugin) assert.Nil(s.T(), s.manager.LoadPlugin(mockPluginCompat)) s.db.User(1) assert.Nil(s.T(), s.manager.InitializeForUserID(1)) s.db.User(2) assert.Nil(s.T(), s.manager.InitializeForUserID(2)) s.db.CreatePluginConf(&model.PluginConf{ UserID: 1, ModulePath: "github.com/gotify/server/v2/plugin/example/removed", Token: "P1234", Enabled: false, }) } func (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf { conf, err := s.db.GetPluginConfByUserAndPath(uid, "github.com/gotify/server/v2/plugin/example/removed") assert.NoError(s.T(), err) return conf } func (s *PluginSuite) resetRecorder() { s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) } func (s *PluginSuite) AfterTest(suiteName, testName string) { s.db.Close() } func (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) { s.notified = true } func (s *PluginSuite) Test_GetPlugins() { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", "/plugin", nil) s.a.GetPlugins(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) pluginConfs := make([]model.PluginConfExternal, 0) assert.Nil(s.T(), json.Unmarshal(s.recorder.Body.Bytes(), &pluginConfs)) assert.Equal(s.T(), mock.Name, pluginConfs[0].Name) assert.Equal(s.T(), mock.ModulePath, pluginConfs[0].ModulePath) assert.False(s.T(), pluginConfs[0].Enabled, "Plugins should be disabled by default") } func (s *PluginSuite) Test_EnableDisablePlugin() { { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.True(s.T(), pluginConf.Enabled) } s.resetRecorder() } { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.True(s.T(), pluginConf.Enabled) } s.resetRecorder() } { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } } func (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expect500() { s.db.User(16) assert.Nil(s.T(), s.manager.InitializeForUserID(16)) mock.ReturnErrorOnEnableForUser(16, errors.New("test error")) conf, err := s.db.GetPluginConfByUserAndPath(16, mock.ModulePath) assert.NoError(s.T(), err) { test.WithUser(s.ctx, 16) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } } func (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_expect500() { s.db.User(17) assert.Nil(s.T(), s.manager.InitializeForUserID(17)) mock.ReturnErrorOnDisableForUser(17, errors.New("test error")) conf, err := s.db.GetPluginConfByUserAndPath(17, mock.ModulePath) assert.NoError(s.T(), err) s.manager.SetPluginEnabled(conf.ID, true) { test.WithUser(s.ctx, 17) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } } func (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNotFound() { { test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } { test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) { assert.False(s.T(), pluginConf.Enabled) } s.resetRecorder() } } func (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNotFound() { { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/enable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.resetRecorder() } { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/disable", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.resetRecorder() } } func (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotFound() { conf := s.getDanglingConf(1) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.EnablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.resetRecorder() } { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.DisablePlugin(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) s.resetRecorder() } } func (s *PluginSuite) Test_GetDisplay() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) mockInst.DisplayString = "test string" { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetDisplay(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.JSONEquals(s.T(), mockInst.DisplayString, s.recorder.Body.String()) } } func (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) mockInst.SetCapability(compat.Displayer, false) defer mockInst.SetCapability(compat.Displayer, true) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetDisplay(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.JSONEquals(s.T(), "", s.recorder.Body.String()) } } func (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) mockInst.DisplayString = "test string" { test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetDisplay(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() { conf := s.getDanglingConf(1) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetDisplay(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() { { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/display", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} s.a.GetDisplay(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_GetConfig() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) assert.Equal(s.T(), mockInst.DefaultConfig(), mockInst.Config, "Initial config should be default config") { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetConfig(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) returnedConfig := new(mock.PluginConfig) assert.Nil(s.T(), yaml.Unmarshal(s.recorder.Body.Bytes(), returnedConfig)) assert.Equal(s.T(), mockInst.Config, returnedConfig) } } func (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) mockInst.SetCapability(compat.Configurer, false) defer mockInst.SetCapability(compat.Configurer, true) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetConfig(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } } func (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) { test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() { conf := s.getDanglingConf(1) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.GetConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() { { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/config", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} s.a.GetConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_UpdateConfig() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) newConfig := &mock.PluginConfig{ TestKey: "test__new__config", } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), newConfig, mockInst.Config, "config should be received by plugin") var pluginFromDBBytes []byte if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) { pluginFromDBBytes = pluginConf.Config } pluginFromDB := new(mock.PluginConfig) err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) assert.Nil(s.T(), err) assert.Equal(s.T(), newConfig, pluginFromDB, "config should be updated in database") } } func (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) origConfig := mockInst.Config newConfig := &mock.PluginConfig{ TestKey: "test__new__config__invalid", IsNotValid: true, } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") var pluginFromDBBytes []byte if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) { pluginFromDBBytes = pluginConf.Config } pluginFromDB := new(mock.PluginConfig) err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) assert.Nil(s.T(), err) assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") } } func (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) origConfig := mockInst.Config newConfigYAML := []byte(`--- "rg e""`) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") var pluginFromDBBytes []byte if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) { pluginFromDBBytes = pluginConf.Config } pluginFromDB := new(mock.PluginConfig) err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) assert.Nil(s.T(), err) assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") } } func (s *PluginSuite) Test_UpdateConfig_ioError_expect500() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) origConfig := mockInst.Config { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), test.UnreadableReader()) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") var pluginFromDBBytes []byte if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) { pluginFromDBBytes = pluginConf.Config } pluginFromDB := new(mock.PluginConfig) err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) assert.Nil(s.T(), err) assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") } } func (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) newConfig := &mock.PluginConfig{ TestKey: "test__new__config", } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) mockInst.SetCapability(compat.Configurer, false) defer mockInst.SetCapability(compat.Configurer, true) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } } func (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() { conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) assert.NoError(s.T(), err) inst, err := s.manager.Instance(conf.ID) assert.Nil(s.T(), err) mockInst := inst.(*mock.PluginInstance) origConfig := mockInst.Config newConfig := &mock.PluginConfig{ TestKey: "test__new__config", } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) { test.WithUser(s.ctx, 2) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") var pluginFromDBBytes []byte if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) { pluginFromDBBytes = pluginConf.Config } pluginFromDB := new(mock.PluginConfig) err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) assert.Nil(s.T(), err) assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") } } func (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() { conf := s.getDanglingConf(1) newConfig := &mock.PluginConfig{ TestKey: "test__new__config", } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } func (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() { newConfig := &mock.PluginConfig{ TestKey: "test__new__config", } newConfigYAML, err := yaml.Marshal(newConfig) assert.Nil(s.T(), err) { test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/config", bytes.NewReader(newConfigYAML)) s.ctx.Header("Content-Type", "application/x-yaml") s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} s.a.UpdateConfig(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } } ================================================ FILE: api/stream/client.go ================================================ package stream import ( "fmt" "time" "github.com/gorilla/websocket" "github.com/gotify/server/v2/model" ) const ( writeWait = 2 * time.Second ) var ping = func(conn *websocket.Conn) error { return conn.WriteMessage(websocket.PingMessage, nil) } var writeJSON = func(conn *websocket.Conn, v interface{}) error { return conn.WriteJSON(v) } type client struct { conn *websocket.Conn onClose func(*client) write chan *model.MessageExternal userID uint token string once once } func newClient(conn *websocket.Conn, userID uint, token string, onClose func(*client)) *client { return &client{ conn: conn, write: make(chan *model.MessageExternal, 1), userID: userID, token: token, onClose: onClose, } } // Close closes the connection. func (c *client) Close() { c.once.Do(func() { c.conn.Close() close(c.write) }) } // NotifyClose closes the connection and notifies that the connection was closed. func (c *client) NotifyClose() { c.once.Do(func() { c.conn.Close() close(c.write) c.onClose(c) }) } // startWriteHandler starts listening on the client connection. As we do not need anything from the client, // we ignore incoming messages. Leaves the loop on errors. func (c *client) startReading(pongWait time.Duration) { defer c.NotifyClose() c.conn.SetReadLimit(64) c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(appData string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) for { if _, _, err := c.conn.NextReader(); err != nil { printWebSocketError("ReadError", err) return } } } // startWriteHandler starts the write loop. The method has the following tasks: // * ping the client in the interval provided as parameter // * write messages send by the channel to the client // * on errors exit the loop. func (c *client) startWriteHandler(pingPeriod time.Duration) { pingTicker := time.NewTicker(pingPeriod) defer func() { c.NotifyClose() pingTicker.Stop() }() for { select { case message, ok := <-c.write: if !ok { return } c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := writeJSON(c.conn, message); err != nil { printWebSocketError("WriteError", err) return } case <-pingTicker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := ping(c.conn); err != nil { printWebSocketError("PingError", err) return } } } } func printWebSocketError(prefix string, err error) { closeError, ok := err.(*websocket.CloseError) if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) { // normal closure return } fmt.Println("WebSocket:", prefix, err) } ================================================ FILE: api/stream/once.go ================================================ // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package stream import ( "sync" "sync/atomic" ) // Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go) // This version unlocks the mutex early and therefore doesn't hold the lock while executing func f(). type once struct { m sync.Mutex done uint32 } func (o *once) Do(f func()) { if atomic.LoadUint32(&o.done) == 1 { return } if o.mayExecute() { f() } } func (o *once) mayExecute() bool { o.m.Lock() defer o.m.Unlock() if o.done == 0 { atomic.StoreUint32(&o.done, 1) return true } return false } ================================================ FILE: api/stream/once_test.go ================================================ package stream import ( "testing" "time" "github.com/stretchr/testify/assert" ) func Test_Execute(t *testing.T) { executeOnce := once{} execution := make(chan struct{}) fExecute := func() { execution <- struct{}{} } go executeOnce.Do(fExecute) go executeOnce.Do(fExecute) select { case <-execution: // expected case <-time.After(100 * time.Millisecond): t.Fatal("fExecute should be executed once") } select { case <-execution: t.Fatal("should only execute once") case <-time.After(100 * time.Millisecond): // expected } assert.False(t, executeOnce.mayExecute()) go executeOnce.Do(fExecute) select { case <-execution: t.Fatal("should only execute once") case <-time.After(100 * time.Millisecond): // expected } } ================================================ FILE: api/stream/stream.go ================================================ package stream import ( "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" ) // The API provides a handler for a WebSocket stream API. type API struct { clients map[uint][]*client lock sync.RWMutex pingPeriod time.Duration pongTimeout time.Duration upgrader *websocket.Upgrader } // New creates a new instance of API. // pingPeriod: is the interval, in which is server sends the a ping to the client. // pongTimeout: is the duration after the connection will be terminated, when the client does not respond with the // pong command. func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string) *API { return &API{ clients: make(map[uint][]*client), pingPeriod: pingPeriod, pongTimeout: pingPeriod + pongTimeout, upgrader: newUpgrader(allowedWebSocketOrigins), } } // CollectConnectedClientTokens returns all tokens of the connected clients. func (a *API) CollectConnectedClientTokens() []string { a.lock.RLock() defer a.lock.RUnlock() var clients []string for _, cs := range a.clients { for _, c := range cs { clients = append(clients, c.token) } } return uniq(clients) } // NotifyDeletedUser closes existing connections for the given user. func (a *API) NotifyDeletedUser(userID uint) error { a.lock.Lock() defer a.lock.Unlock() if clients, ok := a.clients[userID]; ok { for _, client := range clients { client.Close() } delete(a.clients, userID) } return nil } // NotifyDeletedClient closes existing connections with the given token. func (a *API) NotifyDeletedClient(userID uint, token string) { a.lock.Lock() defer a.lock.Unlock() if clients, ok := a.clients[userID]; ok { for i := len(clients) - 1; i >= 0; i-- { client := clients[i] if client.token == token { client.Close() clients = append(clients[:i], clients[i+1:]...) } } a.clients[userID] = clients } } // Notify notifies the clients with the given userID that a new messages was created. func (a *API) Notify(userID uint, msg *model.MessageExternal) { a.lock.RLock() defer a.lock.RUnlock() if clients, ok := a.clients[userID]; ok { for _, c := range clients { c.write <- msg } } } func (a *API) remove(remove *client) { a.lock.Lock() defer a.lock.Unlock() if userIDClients, ok := a.clients[remove.userID]; ok { for i, client := range userIDClients { if client == remove { a.clients[remove.userID] = append(userIDClients[:i], userIDClients[i+1:]...) break } } } } func (a *API) register(client *client) { a.lock.Lock() defer a.lock.Unlock() a.clients[client.userID] = append(a.clients[client.userID], client) } // Handle handles incoming requests. First it upgrades the protocol to the WebSocket protocol and then starts listening // for read and writes. // swagger:operation GET /stream message streamMessages // // Websocket, return newly created messages. // // --- // schema: ws, wss // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/Message" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 500: // description: Server Error // schema: // $ref: "#/definitions/Error" func (a *API) Handle(ctx *gin.Context) { conn, err := a.upgrader.Upgrade(ctx.Writer, ctx.Request, nil) if err != nil { ctx.Error(err) return } client := newClient(conn, auth.GetUserID(ctx), auth.GetTokenID(ctx), a.remove) a.register(client) go client.startReading(a.pongTimeout) go client.startWriteHandler(a.pingPeriod) } // Close closes all client connections and stops answering new connections. func (a *API) Close() { a.lock.Lock() defer a.lock.Unlock() for _, clients := range a.clients { for _, client := range clients { client.Close() } } for k := range a.clients { delete(a.clients, k) } } func uniq[T comparable](s []T) []T { m := make(map[T]struct{}, len(s)) r := make([]T, 0, len(s)) for _, v := range s { if _, ok := m[v]; !ok { m[v] = struct{}{} r = append(r, v) } } return r } func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool { origin := r.Header.Get("origin") if origin == "" { return true } u, err := url.Parse(origin) if err != nil { return false } if strings.EqualFold(u.Host, r.Host) { return true } for _, allowedOrigin := range allowedOrigins { if allowedOrigin.MatchString(strings.ToLower(u.Hostname())) { return true } } return false } func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader { compiledAllowedOrigins := compileAllowedWebSocketOrigins(allowedWebSocketOrigins) return &websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { if mode.IsDev() { return true } return isAllowedOrigin(r, compiledAllowedOrigins) }, } } func compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.Regexp { var compiledAllowedOrigins []*regexp.Regexp for _, origin := range allowedOrigins { compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin)) } return compiledAllowedOrigins } ================================================ FILE: api/stream/stream_test.go ================================================ package stream import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/fortytw2/leaktest" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) func TestFailureOnNormalHttpRequest(t *testing.T) { mode.Set(mode.TestDev) defer leaktest.Check(t)() server, api := bootTestServer(staticUserID()) defer server.Close() defer api.Close() resp, err := http.Get(server.URL) assert.Nil(t, err) assert.Equal(t, 400, resp.StatusCode) resp.Body.Close() } func TestWriteMessageFails(t *testing.T) { mode.Set(mode.TestDev) oldWrite := writeJSON // try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know. writeJSON = func(conn *websocket.Conn, v interface{}) error { return errors.New("asd") } defer func() { writeJSON = oldWrite }() defer leaktest.Check(t)() server, api := bootTestServer(func(context *gin.Context) { auth.RegisterAuthentication(context, nil, 1, "") }) defer server.Close() defer api.Close() wsURL := wsURL(server.URL) user := testClient(t, wsURL) waitForConnectedClients(api, 1) clients := clients(api, 1) assert.NotEmpty(t, clients) api.Notify(1, &model.MessageExternal{Message: "HI"}) user.expectNoMessage() } func TestWritePingFails(t *testing.T) { mode.Set(mode.TestDev) oldPing := ping // try emulate an write error, mostly this should kill the ReadMessage gorouting first but you'll never know. ping = func(conn *websocket.Conn) error { return errors.New("asd") } defer func() { ping = oldPing }() defer leaktest.CheckTimeout(t, 10*time.Second)() server, api := bootTestServer(staticUserID()) defer api.Close() defer server.Close() wsURL := wsURL(server.URL) user := testClient(t, wsURL) defer user.conn.Close() waitForConnectedClients(api, 1) clients := clients(api, 1) assert.NotEmpty(t, clients) time.Sleep(api.pingPeriod + (50 * time.Millisecond)) // waiting for ping api.Notify(1, &model.MessageExternal{Message: "HI"}) user.expectNoMessage() } func TestPing(t *testing.T) { mode.Set(mode.TestDev) server, api := bootTestServer(staticUserID()) defer server.Close() defer api.Close() wsURL := wsURL(server.URL) user := createClient(t, wsURL) defer user.conn.Close() ping := make(chan bool) oldPingHandler := user.conn.PingHandler() user.conn.SetPingHandler(func(appData string) error { err := oldPingHandler(appData) ping <- true return err }) startReading(user) expectNoMessage(user) select { case <-time.After(2 * time.Second): assert.Fail(t, "Expected ping but there was one :(") case <-ping: // expected } expectNoMessage(user) api.Notify(1, &model.MessageExternal{Message: "HI"}) user.expectMessage(&model.MessageExternal{Message: "HI"}) } func TestCloseClientOnNotReading(t *testing.T) { mode.Set(mode.TestDev) server, api := bootTestServer(staticUserID()) defer server.Close() defer api.Close() wsURL := wsURL(server.URL) ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) assert.Nil(t, err) defer ws.Close() waitForConnectedClients(api, 1) assert.NotEmpty(t, clients(api, 1)) time.Sleep(api.pingPeriod + api.pongTimeout) assert.Empty(t, clients(api, 1)) } func TestMessageDirectlyAfterConnect(t *testing.T) { mode.Set(mode.Prod) defer leaktest.Check(t)() server, api := bootTestServer(staticUserID()) defer server.Close() defer api.Close() wsURL := wsURL(server.URL) user := testClient(t, wsURL) defer user.conn.Close() waitForConnectedClients(api, 1) api.Notify(1, &model.MessageExternal{Message: "msg"}) user.expectMessage(&model.MessageExternal{Message: "msg"}) } func TestDeleteClientShouldCloseConnection(t *testing.T) { mode.Set(mode.Prod) defer leaktest.Check(t)() server, api := bootTestServer(staticUserID()) defer server.Close() defer api.Close() wsURL := wsURL(server.URL) user := testClient(t, wsURL) defer user.conn.Close() waitForConnectedClients(api, 1) api.Notify(1, &model.MessageExternal{Message: "msg"}) user.expectMessage(&model.MessageExternal{Message: "msg"}) api.NotifyDeletedClient(1, "customtoken") api.Notify(1, &model.MessageExternal{Message: "msg"}) user.expectNoMessage() } func TestDeleteMultipleClients(t *testing.T) { mode.Set(mode.TestDev) defer leaktest.Check(t)() userIDs := []uint{1, 1, 1, 1, 2, 2, 3} tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"} i := 0 server, api := bootTestServer(func(context *gin.Context) { auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) i++ }) defer server.Close() wsURL := wsURL(server.URL) userOneIPhone := testClient(t, wsURL) defer userOneIPhone.conn.Close() userOneAndroid := testClient(t, wsURL) defer userOneAndroid.conn.Close() userOneBrowser := testClient(t, wsURL) defer userOneBrowser.conn.Close() userOneOther := testClient(t, wsURL) defer userOneOther.conn.Close() userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther} userTwoBrowser := testClient(t, wsURL) defer userTwoBrowser.conn.Close() userTwoAndroid := testClient(t, wsURL) defer userTwoAndroid.conn.Close() userTwo := []*testingClient{userTwoAndroid, userTwoBrowser} userThreeAndroid := testClient(t, wsURL) defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"}) expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.NotifyDeletedClient(1, "1-2") api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"}) expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userOneIPhone, userOneOther) expectNoMessage(userOneBrowser, userOneAndroid) expectNoMessage(userThree...) expectNoMessage(userTwo...) api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"}) expectNoMessage(userOne...) expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...) expectNoMessage(userThree...) api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"}) expectNoMessage(userOne...) expectNoMessage(userTwo...) expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...) api.Close() } func TestDeleteUser(t *testing.T) { mode.Set(mode.TestDev) defer leaktest.Check(t)() userIDs := []uint{1, 1, 1, 1, 2, 2, 3} tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"} i := 0 server, api := bootTestServer(func(context *gin.Context) { auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) i++ }) defer server.Close() wsURL := wsURL(server.URL) userOneIPhone := testClient(t, wsURL) defer userOneIPhone.conn.Close() userOneAndroid := testClient(t, wsURL) defer userOneAndroid.conn.Close() userOneBrowser := testClient(t, wsURL) defer userOneBrowser.conn.Close() userOneOther := testClient(t, wsURL) defer userOneOther.conn.Close() userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther} userTwoBrowser := testClient(t, wsURL) defer userTwoBrowser.conn.Close() userTwoAndroid := testClient(t, wsURL) defer userTwoAndroid.conn.Close() userTwo := []*testingClient{userTwoAndroid, userTwoBrowser} userThreeAndroid := testClient(t, wsURL) defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"}) expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.NotifyDeletedUser(1) api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"}) expectNoMessage(userOne...) expectNoMessage(userThree...) expectNoMessage(userTwo...) api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"}) expectNoMessage(userOne...) expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...) expectNoMessage(userThree...) api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"}) expectNoMessage(userOne...) expectNoMessage(userTwo...) expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...) api.Close() } func TestCollectConnectedClientTokens(t *testing.T) { mode.Set(mode.TestDev) defer leaktest.Check(t)() userIDs := []uint{1, 1, 1, 2, 2} tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"} i := 0 server, api := bootTestServer(func(context *gin.Context) { auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) i++ }) defer server.Close() wsURL := wsURL(server.URL) userOneConnOne := testClient(t, wsURL) defer userOneConnOne.conn.Close() userOneConnTwo := testClient(t, wsURL) defer userOneConnTwo.conn.Close() userOneConnThree := testClient(t, wsURL) defer userOneConnThree.conn.Close() waitForConnectedClients(api, 3) ret := api.CollectConnectedClientTokens() sort.Strings(ret) assert.Equal(t, []string{"1-1", "1-2"}, ret) userTwoConnOne := testClient(t, wsURL) defer userTwoConnOne.conn.Close() userTwoConnTwo := testClient(t, wsURL) defer userTwoConnTwo.conn.Close() waitForConnectedClients(api, 5) ret = api.CollectConnectedClientTokens() sort.Strings(ret) assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret) } func TestMultipleClients(t *testing.T) { mode.Set(mode.TestDev) defer leaktest.Check(t)() userIDs := []uint{1, 1, 1, 2, 2, 3} i := 0 server, api := bootTestServer(func(context *gin.Context) { auth.RegisterAuthentication(context, nil, userIDs[i], "t"+fmt.Sprint(userIDs[i])) i++ }) defer server.Close() wsURL := wsURL(server.URL) userOneIPhone := testClient(t, wsURL) defer userOneIPhone.conn.Close() userOneAndroid := testClient(t, wsURL) defer userOneAndroid.conn.Close() userOneBrowser := testClient(t, wsURL) defer userOneBrowser.conn.Close() userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone} userTwoBrowser := testClient(t, wsURL) defer userTwoBrowser.conn.Close() userTwoAndroid := testClient(t, wsURL) defer userTwoAndroid.conn.Close() userTwo := []*testingClient{userTwoAndroid, userTwoBrowser} userThreeAndroid := testClient(t, wsURL) defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) // there should not be messages at the beginning expectNoMessage(userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.Notify(1, &model.MessageExternal{ID: 1, Message: "hello"}) time.Sleep(500 * time.Millisecond) expectMessage(&model.MessageExternal{ID: 1, Message: "hello"}, userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"}) expectNoMessage(userOne...) expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...) expectNoMessage(userThree...) userOneIPhone.conn.Close() expectNoMessage(userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.Notify(1, &model.MessageExternal{ID: 3, Message: "how"}) expectMessage(&model.MessageExternal{ID: 3, Message: "how"}, userOneAndroid, userOneBrowser) expectNoMessage(userOneIPhone) expectNoMessage(userTwo...) expectNoMessage(userThree...) api.Notify(2, &model.MessageExternal{ID: 4, Message: "are"}) expectNoMessage(userOne...) expectMessage(&model.MessageExternal{ID: 4, Message: "are"}, userTwo...) expectNoMessage(userThree...) api.Close() api.Notify(2, &model.MessageExternal{ID: 5, Message: "you"}) expectNoMessage(userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) } func Test_sameOrigin_returnsTrue(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com/stream", nil) req.Header.Set("Origin", "http://example.com") actual := isAllowedOrigin(req, nil) assert.True(t, actual) } func Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com:8080/stream", nil) req.Header.Set("Origin", "http://example.com:8080") actual := isAllowedOrigin(req, nil) assert.True(t, actual) } func Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com/stream", nil) req.Header.Set("Origin", "http://gorify.example.com") actual := isAllowedOrigin(req, nil) assert.False(t, actual) } func Test_isAllowedOriginMatching(t *testing.T) { mode.Set(mode.Prod) compiledAllowedOrigins := compileAllowedWebSocketOrigins([]string{"go.{4}\\.example\\.com", "go\\.example\\.com"}) req := httptest.NewRequest("GET", "http://example.me/stream", nil) req.Header.Set("Origin", "http://gorify.example.com") assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins)) req.Header.Set("Origin", "http://go.example.com") assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins)) req.Header.Set("Origin", "http://hello.example.com") assert.False(t, isAllowedOrigin(req, compiledAllowedOrigins)) } func Test_emptyOrigin_returnsTrue(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com/stream", nil) actual := isAllowedOrigin(req, nil) assert.True(t, actual) } func Test_otherOrigin_returnsFalse(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com/stream", nil) req.Header.Set("Origin", "http://otherexample.de") actual := isAllowedOrigin(req, nil) assert.False(t, actual) } func Test_invalidOrigin_returnsFalse(t *testing.T) { mode.Set(mode.Prod) req := httptest.NewRequest("GET", "http://example.com/stream", nil) req.Header.Set("Origin", "http\\://otherexample.de") actual := isAllowedOrigin(req, nil) assert.False(t, actual) } func Test_compileAllowedWebSocketOrigins(t *testing.T) { assert.Equal(t, 0, len(compileAllowedWebSocketOrigins([]string{}))) assert.Equal(t, 3, len(compileAllowedWebSocketOrigins([]string{"^.*$", "", "abc"}))) } func clients(api *API, user uint) []*client { api.lock.RLock() defer api.lock.RUnlock() return api.clients[user] } func countClients(a *API) int { a.lock.RLock() defer a.lock.RUnlock() var i int for _, clients := range a.clients { i += len(clients) } return i } func testClient(t *testing.T, url string) *testingClient { client := createClient(t, url) startReading(client) return client } func startReading(client *testingClient) { go func() { for { _, payload, err := client.conn.ReadMessage() if err != nil { return } actual := &model.MessageExternal{} json.NewDecoder(bytes.NewBuffer(payload)).Decode(actual) client.readMessage <- *actual } }() } func createClient(t *testing.T, url string) *testingClient { ws, _, err := websocket.DefaultDialer.Dial(url, nil) assert.Nil(t, err) readMessages := make(chan model.MessageExternal) return &testingClient{conn: ws, readMessage: readMessages, t: t} } type testingClient struct { conn *websocket.Conn readMessage chan model.MessageExternal t *testing.T } func (c *testingClient) expectMessage(expected *model.MessageExternal) { select { case <-time.After(50 * time.Millisecond): assert.Fail(c.t, "Expected message but none was send :(") case actual := <-c.readMessage: assert.Equal(c.t, *expected, actual) } } func expectMessage(expected *model.MessageExternal, clients ...*testingClient) { for _, client := range clients { client.expectMessage(expected) } } func expectNoMessage(clients ...*testingClient) { for _, client := range clients { client.expectNoMessage() } } func (c *testingClient) expectNoMessage() { select { case <-time.After(50 * time.Millisecond): // no message == as expected case msg := <-c.readMessage: assert.Fail(c.t, "Expected NO message but there was one :(", fmt.Sprint(msg)) } } func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) { r := gin.New() r.Use(handlerFunc) // ping every 500 ms, and the client has 500 ms to respond api := New(500*time.Millisecond, 500*time.Millisecond, []string{}) r.GET("/", api.Handle) server := httptest.NewServer(r) return server, api } func wsURL(httpURL string) string { return "ws" + strings.TrimPrefix(httpURL, "http") } func staticUserID() gin.HandlerFunc { return func(context *gin.Context) { auth.RegisterAuthentication(context, nil, 1, "customtoken") } } func waitForConnectedClients(api *API, count int) { for i := 0; i < 10; i++ { if countClients(api) == count { // ok return } time.Sleep(10 * time.Millisecond) } } ================================================ FILE: api/tokens.go ================================================ package api import ( "github.com/gotify/server/v2/auth" ) var generateApplicationToken = auth.GenerateApplicationToken var generateClientToken = auth.GenerateClientToken var generateImageName = auth.GenerateImageName ================================================ FILE: api/tokens_test.go ================================================ package api import ( "regexp" "testing" "github.com/stretchr/testify/assert" ) func TestTokenGeneration(t *testing.T) { assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken()) assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken()) assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName()) } ================================================ FILE: api/user.go ================================================ package api import ( "errors" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/model" ) // The UserDatabase interface for encapsulating database access. type UserDatabase interface { GetUsers() ([]*model.User, error) GetUserByID(id uint) (*model.User, error) GetUserByName(name string) (*model.User, error) DeleteUserByID(id uint) error UpdateUser(user *model.User) error CreateUser(user *model.User) error CountUser(condition ...interface{}) (int64, error) } // UserChangeNotifier notifies listeners for user changes. type UserChangeNotifier struct { userDeletedCallbacks []func(uid uint) error userAddedCallbacks []func(uid uint) error } // OnUserDeleted is called on user deletion. func (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) { c.userDeletedCallbacks = append(c.userDeletedCallbacks, cb) } // OnUserAdded is called on user creation. func (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) { c.userAddedCallbacks = append(c.userAddedCallbacks, cb) } func (c *UserChangeNotifier) fireUserDeleted(uid uint) error { for _, cb := range c.userDeletedCallbacks { if err := cb(uid); err != nil { return err } } return nil } func (c *UserChangeNotifier) fireUserAdded(uid uint) error { for _, cb := range c.userAddedCallbacks { if err := cb(uid); err != nil { return err } } return nil } // The UserAPI provides handlers for managing users. type UserAPI struct { DB UserDatabase PasswordStrength int UserChangeNotifier *UserChangeNotifier Registration bool } // GetUsers returns all the users // swagger:operation GET /user user getUsers // // Return all users. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // type: array // items: // $ref: "#/definitions/User" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *UserAPI) GetUsers(ctx *gin.Context) { users, err := a.DB.GetUsers() if success := successOrAbort(ctx, 500, err); !success { return } var resp []*model.UserExternal for _, user := range users { resp = append(resp, toExternalUser(user)) } ctx.JSON(200, resp) } // GetCurrentUser returns the current user // swagger:operation GET /current/user user currentUser // // Return the current user. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/User" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { user, err := a.DB.GetUserByID(auth.GetUserID(ctx)) if success := successOrAbort(ctx, 500, err); !success { return } ctx.JSON(200, toExternalUser(user)) } // CreateUser create a user. // swagger:operation POST /user user createUser // // Create a user. // // With enabled registration: non admin users can be created without authentication. // With disabled registrations: users can only be created by admin users. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the user to add // required: true // schema: // $ref: "#/definitions/CreateUserExternal" // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/User" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *UserAPI) CreateUser(ctx *gin.Context) { user := model.CreateUserExternal{} if err := ctx.Bind(&user); err == nil { internal := &model.User{ Name: user.Name, Admin: user.Admin, Pass: password.CreatePassword(user.Pass, a.PasswordStrength), } existingUser, err := a.DB.GetUserByName(internal.Name) if success := successOrAbort(ctx, 500, err); !success { return } var requestedBy *model.User uid := auth.TryGetUserID(ctx) if uid != nil { requestedBy, err = a.DB.GetUserByID(*uid) if err != nil { ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("could not get user: %s", err)) return } } if requestedBy == nil || !requestedBy.Admin { status := http.StatusUnauthorized if requestedBy != nil { status = http.StatusForbidden } if !a.Registration { ctx.AbortWithError(status, errors.New("you are not allowed to access this api")) return } if internal.Admin { ctx.AbortWithError(status, errors.New("you are not allowed to create an admin user")) return } } if existingUser == nil { if success := successOrAbort(ctx, 500, a.DB.CreateUser(internal)); !success { return } if err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil { ctx.AbortWithError(500, err) return } ctx.JSON(200, toExternalUser(internal)) } else { ctx.AbortWithError(400, errors.New("username already exists")) } } } // GetUserByID returns the user by id // swagger:operation GET /user/{id} user getUser // // Get a user. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the user id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/User" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *UserAPI) GetUserByID(ctx *gin.Context) { withID(ctx, "id", func(id uint) { user, err := a.DB.GetUserByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if user != nil { ctx.JSON(200, toExternalUser(user)) } else { ctx.AbortWithError(404, errors.New("user does not exist")) } }) } // DeleteUserByID deletes the user by id // swagger:operation DELETE /user/{id} user deleteUser // // Deletes a user. // // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the user id // required: true // type: integer // format: int64 // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { withID(ctx, "id", func(id uint) { user, err := a.DB.GetUserByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if user != nil { adminCount, err := a.DB.CountUser(&model.User{Admin: true}) if success := successOrAbort(ctx, 500, err); !success { return } if user.Admin && adminCount == 1 { ctx.AbortWithError(400, errors.New("cannot delete last admin")) return } if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil { ctx.AbortWithError(500, err) return } successOrAbort(ctx, 500, a.DB.DeleteUserByID(id)) } else { ctx.AbortWithError(404, errors.New("user does not exist")) } }) } // ChangePassword changes the password from the current user // swagger:operation POST /current/user/password user updateCurrentUser // // Update the password of the current user. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body // description: the user // required: true // schema: // $ref: "#/definitions/UserPass" // responses: // 200: // description: Ok // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" func (a *UserAPI) ChangePassword(ctx *gin.Context) { pw := model.UserExternalPass{} if err := ctx.Bind(&pw); err == nil { user, err := a.DB.GetUserByID(auth.GetUserID(ctx)) if success := successOrAbort(ctx, 500, err); !success { return } user.Pass = password.CreatePassword(pw.Pass, a.PasswordStrength) successOrAbort(ctx, 500, a.DB.UpdateUser(user)) } } // UpdateUserByID updates and user by id // swagger:operation POST /user/{id} user updateUser // // Update a user. // // --- // consumes: [application/json] // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: id // in: path // description: the user id // required: true // type: integer // format: int64 // - name: body // in: body // description: the updated user // required: true // schema: // $ref: "#/definitions/UpdateUserExternal" // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/User" // 400: // description: Bad Request // schema: // $ref: "#/definitions/Error" // 401: // description: Unauthorized // schema: // $ref: "#/definitions/Error" // 403: // description: Forbidden // schema: // $ref: "#/definitions/Error" // 404: // description: Not Found // schema: // $ref: "#/definitions/Error" func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { withID(ctx, "id", func(id uint) { var user *model.UpdateUserExternal if err := ctx.Bind(&user); err == nil { oldUser, err := a.DB.GetUserByID(id) if success := successOrAbort(ctx, 500, err); !success { return } if oldUser != nil { adminCount, err := a.DB.CountUser(&model.User{Admin: true}) if success := successOrAbort(ctx, 500, err); !success { return } if !user.Admin && oldUser.Admin && adminCount == 1 { ctx.AbortWithError(400, errors.New("cannot delete last admin")) return } internal := &model.User{ ID: oldUser.ID, Name: user.Name, Admin: user.Admin, Pass: oldUser.Pass, } if user.Pass != "" { internal.Pass = password.CreatePassword(user.Pass, a.PasswordStrength) } if success := successOrAbort(ctx, 500, a.DB.UpdateUser(internal)); !success { return } ctx.JSON(200, toExternalUser(internal)) } else { ctx.AbortWithError(404, errors.New("user does not exist")) } } }) } func toExternalUser(internal *model.User) *model.UserExternal { return &model.UserExternal{ Name: internal.Name, Admin: internal.Admin, ID: internal.ID, } } ================================================ FILE: api/user_test.go ================================================ package api import ( "errors" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func TestUserSuite(t *testing.T) { suite.Run(t, new(UserSuite)) } type UserSuite struct { suite.Suite db *testdb.Database a *UserAPI ctx *gin.Context recorder *httptest.ResponseRecorder notifiedAdd bool notifiedDelete bool notifier *UserChangeNotifier } func (s *UserSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) s.db = testdb.NewDB(s.T()) s.notifier = new(UserChangeNotifier) s.notifier.OnUserDeleted(func(uint) error { s.notifiedDelete = true return nil }) s.notifier.OnUserAdded(func(uint) error { s.notifiedAdd = true return nil }) s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier} } func (s *UserSuite) AfterTest(suiteName, testName string) { s.db.Close() } func (s *UserSuite) Test_GetUsers() { first := s.db.NewUser(2) second := s.db.NewUser(5) s.a.GetUsers(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), []*model.UserExternal{externalOf(first), externalOf(second)}, s.recorder) } func (s *UserSuite) Test_GetCurrentUser() { user := s.db.NewUser(5) test.WithUser(s.ctx, 5) s.a.GetCurrentUser(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), externalOf(user), s.recorder) } func (s *UserSuite) Test_GetUserByID() { user := s.db.NewUser(2) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetUserByID(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), externalOf(user), s.recorder) } func (s *UserSuite) Test_GetUserByID_InvalidID() { s.db.User(2) s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} s.a.GetUserByID(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_GetUserByID_UnknownUser() { s.db.User(2) s.ctx.Params = gin.Params{{Key: "id", Value: "3"}} s.a.GetUserByID(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *UserSuite) Test_DeleteUserByID_LastAdmin_Expect400() { s.db.CreateUser(&model.User{ ID: 7, Name: "admin", Admin: true, }) s.ctx.Params = gin.Params{{Key: "id", Value: "7"}} s.a.DeleteUserByID(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_DeleteUserByID_InvalidID() { s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} s.a.DeleteUserByID(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_DeleteUserByID_UnknownUser() { s.db.User(2) s.ctx.Params = gin.Params{{Key: "id", Value: "3"}} s.a.DeleteUserByID(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *UserSuite) Test_DeleteUserByID() { assert.False(s.T(), s.notifiedDelete) s.db.User(2) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.DeleteUserByID(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertUserNotExist(2) assert.True(s.T(), s.notifiedDelete) } func (s *UserSuite) Test_DeleteUserByID_NotifyFail() { s.db.User(5) s.notifier.OnUserDeleted(func(id uint) error { if id == 5 { return errors.New("some error") } return nil }) s.ctx.Params = gin.Params{{Key: "id", Value: "5"}} s.a.DeleteUserByID(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) } func (s *UserSuite) Test_CreateUser() { s.loginAdmin() assert.False(s.T(), s.notifiedAdd) s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) user := &model.UserExternal{ID: 2, Name: "tom", Admin: true} test.BodyEquals(s.T(), user, s.recorder) if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) { assert.NotNil(s.T(), created) assert.True(s.T(), password.ComparePassword(created.Pass, []byte("mylittlepony"))) } assert.True(s.T(), s.notifiedAdd) } func (s *UserSuite) Test_CreateUser_ByNonAdmin() { s.loginUser() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 403, s.recorder.Code) } func (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() { s.loginUser() s.a.Registration = true s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) { assert.NotNil(s.T(), created) } } func (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() { s.a.Registration = true s.loginUser() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 403, s.recorder.Code) s.db.AssertUsernameNotExist("tom") } func (s *UserSuite) Test_CreateUser_Anonymous() { s.noLogin() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 401, s.recorder.Code) s.db.AssertUsernameNotExist("tom") } func (s *UserSuite) Test_CreateUser_Register_Anonymous() { s.a.Registration = true s.noLogin() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) { assert.NotNil(s.T(), created) } } func (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() { s.a.Registration = true s.noLogin() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 401, s.recorder.Code) s.db.AssertUsernameNotExist("tom") } func (s *UserSuite) Test_CreateUser_NotifyFail() { s.loginAdmin() s.notifier.OnUserAdded(func(id uint) error { user, err := s.db.GetUserByID(id) if err != nil { return err } if user.Name == "eva" { return errors.New("some error") } return nil }) s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "eva", "pass": "mylittlepony", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 500, s.recorder.Code) } func (s *UserSuite) Test_CreateUser_NoPassword() { s.loginAdmin() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_CreateUser_NoName() { s.loginAdmin() s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "", "pass": "asd", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_CreateUser_NameAlreadyExists() { s.loginAdmin() s.db.NewUserWithName(2, "tom") s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.CreateUser(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_UpdateUserByID_InvalidID() { s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} s.ctx.Request = httptest.NewRequest("POST", "/user/abc", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.UpdateUserByID(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_UpdateUserByID_LastAdmin_Expect400() { s.db.CreateUser(&model.User{ ID: 7, Name: "admin", Admin: true, }) s.ctx.Params = gin.Params{{Key: "id", Value: "7"}} s.ctx.Request = httptest.NewRequest("POST", "/user/7", strings.NewReader(`{"name": "admin", "pass": "", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.UpdateUserByID(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) } func (s *UserSuite) Test_UpdateUserByID_UnknownUser() { s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.UpdateUserByID(s.ctx) assert.Equal(s.T(), 404, s.recorder.Code) } func (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() { s.db.CreateUser(&model.User{ID: 2, Name: "nico", Pass: password.CreatePassword("old", 5)}) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.UpdateUserByID(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) user, err := s.db.GetUserByID(2) assert.NoError(s.T(), err) assert.NotNil(s.T(), user) assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old"))) } func (s *UserSuite) Test_UpdateUserByID_UpdatePassword() { s.db.CreateUser(&model.User{ID: 2, Name: "tom", Pass: password.CreatePassword("old", 5)}) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "new", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.UpdateUserByID(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) user, err := s.db.GetUserByID(2) assert.NoError(s.T(), err) assert.NotNil(s.T(), user) assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new"))) } func (s *UserSuite) Test_UpdatePassword() { s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)}) test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass": "new"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.ChangePassword(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) user, err := s.db.GetUserByID(1) assert.NoError(s.T(), err) assert.NotNil(s.T(), user) assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new"))) } func (s *UserSuite) Test_UpdatePassword_EmptyPassword() { s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)}) test.WithUser(s.ctx, 1) s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass":""}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") s.a.ChangePassword(s.ctx) assert.Equal(s.T(), 400, s.recorder.Code) user, err := s.db.GetUserByID(1) assert.NoError(s.T(), err) assert.NotNil(s.T(), user) assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old"))) } func (s *UserSuite) loginAdmin() { s.db.CreateUser(&model.User{ID: 1, Name: "admin", Admin: true}) auth.RegisterAuthentication(s.ctx, nil, 1, "") } func (s *UserSuite) loginUser() { s.db.CreateUser(&model.User{ID: 1, Name: "user", Admin: false}) auth.RegisterAuthentication(s.ctx, nil, 1, "") } func (s *UserSuite) noLogin() { auth.RegisterAuthentication(s.ctx, nil, 0, "") } func externalOf(user *model.User) *model.UserExternal { return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID} } ================================================ FILE: app.go ================================================ package main import ( "fmt" "os" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/database" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/router" "github.com/gotify/server/v2/runner" ) var ( // Version the version of Gotify. Version = "unknown" // Commit the git commit hash of this version. Commit = "unknown" // BuildDate the date on which this binary was build. BuildDate = "unknown" // Mode the build mode. Mode = mode.Dev ) func main() { vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} mode.Set(Mode) fmt.Println("Starting Gotify version", vInfo.Version+"@"+BuildDate) conf := config.Get() if conf.PluginsDir != "" { if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil { panic(err) } } if err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil { panic(err) } db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true) if err != nil { panic(err) } defer db.Close() engine, closeable := router.Create(db, vInfo, conf) defer closeable() if err := runner.Run(engine, conf); err != nil { fmt.Println("Server error: ", err) os.Exit(1) } } ================================================ FILE: auth/authentication.go ================================================ package auth import ( "errors" "strings" "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/model" ) const ( headerName = "X-Gotify-Key" ) // The Database interface for encapsulating database access. type Database interface { GetApplicationByToken(token string) (*model.Application, error) GetClientByToken(token string) (*model.Client, error) GetPluginConfByToken(token string) (*model.PluginConf, error) GetUserByName(name string) (*model.User, error) GetUserByID(id uint) (*model.User, error) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error UpdateApplicationTokenLastUsed(token string, t *time.Time) error } // Auth is the provider for authentication middleware. type Auth struct { DB Database } type authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error) // RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied // with the request. Also the authenticated user must be an administrator. func (a *Auth) RequireAdmin() gin.HandlerFunc { return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { if user != nil { return true, user.Admin, user.ID, nil } if token, err := a.DB.GetClientByToken(tokenID); err != nil { return false, false, 0, err } else if token != nil { user, err := a.DB.GetUserByID(token.UserID) if err != nil { return false, false, token.UserID, err } return true, user.Admin, token.UserID, nil } return false, false, 0, nil }) } // RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied // with the request. func (a *Auth) RequireClient() gin.HandlerFunc { return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { if user != nil { return true, true, user.ID, nil } if client, err := a.DB.GetClientByToken(tokenID); err != nil { return false, false, 0, err } else if client != nil { now := time.Now() if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil { return false, false, 0, err } } return true, true, client.UserID, nil } return false, false, 0, nil }) } // RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. func (a *Auth) RequireApplicationToken() gin.HandlerFunc { return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { if user != nil { return true, false, 0, nil } if app, err := a.DB.GetApplicationByToken(tokenID); err != nil { return false, false, 0, err } else if app != nil { now := time.Now() if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil { return false, false, 0, err } } return true, true, app.UserID, nil } return false, false, 0, nil }) } func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string { if token := a.tokenFromQuery(ctx); token != "" { return token } else if token := a.tokenFromXGotifyHeader(ctx); token != "" { return token } else if token := a.tokenFromAuthorizationHeader(ctx); token != "" { return token } return "" } func (a *Auth) tokenFromQuery(ctx *gin.Context) string { return ctx.Request.URL.Query().Get("token") } func (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string { return ctx.Request.Header.Get(headerName) } func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string { const prefix = "Bearer " authHeader := ctx.Request.Header.Get("Authorization") if authHeader == "" { return "" } if len(authHeader) < len(prefix) || !strings.EqualFold(prefix, authHeader[:len(prefix)]) { return "" } return authHeader[len(prefix):] } func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) { if name, pass, ok := ctx.Request.BasicAuth(); ok { if user, err := a.DB.GetUserByName(name); err != nil { return nil, err } else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) { return user, nil } } return nil, nil } func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc { return func(ctx *gin.Context) { token := a.tokenFromQueryOrHeader(ctx) user, err := a.userFromBasicAuth(ctx) if err != nil { ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) return } if user != nil || token != "" { authenticated, ok, userID, err := auth(token, user) if err != nil { ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) return } else if ok { RegisterAuthentication(ctx, user, userID, token) ctx.Next() return } else if authenticated { ctx.AbortWithError(403, errors.New("you are not allowed to access this api")) return } } ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api")) } } func (a *Auth) Optional() gin.HandlerFunc { return func(ctx *gin.Context) { token := a.tokenFromQueryOrHeader(ctx) user, err := a.userFromBasicAuth(ctx) if err != nil { RegisterAuthentication(ctx, nil, 0, "") ctx.Next() return } if user != nil { RegisterAuthentication(ctx, user, user.ID, token) ctx.Next() return } else if token != "" { if tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil { RegisterAuthentication(ctx, user, tokenClient.UserID, token) ctx.Next() return } } RegisterAuthentication(ctx, nil, 0, "") ctx.Next() } } ================================================ FILE: auth/authentication_test.go ================================================ package auth import ( "fmt" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func TestSuite(t *testing.T) { suite.Run(t, new(AuthenticationSuite)) } type AuthenticationSuite struct { suite.Suite auth *Auth DB *testdb.Database } func (s *AuthenticationSuite) SetupSuite() { mode.Set(mode.TestDev) s.DB = testdb.NewDB(s.T()) s.auth = &Auth{s.DB} s.DB.CreateUser(&model.User{ Name: "existing", Pass: password.CreatePassword("pw", 5), Admin: false, Applications: []model.Application{{Token: "apptoken", Name: "backup server1", Description: "irrelevant"}}, Clients: []model.Client{{Token: "clienttoken", Name: "android phone1"}}, }) s.DB.CreateUser(&model.User{ Name: "admin", Pass: password.CreatePassword("pw", 5), Admin: true, Applications: []model.Application{{Token: "apptoken_admin", Name: "backup server2", Description: "irrelevant"}}, Clients: []model.Client{{Token: "clienttoken_admin", Name: "android phone2"}}, }) } func (s *AuthenticationSuite) TearDownSuite() { s.DB.Close() } func (s *AuthenticationSuite) TestQueryToken() { // not existing token s.assertQueryRequest("token", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "ergerogerg", s.auth.RequireClient, 401) s.assertQueryRequest("token", "ergerogerg", s.auth.RequireAdmin, 401) // not existing key s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireClient, 401) s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireAdmin, 401) // apptoken s.assertQueryRequest("token", "apptoken", s.auth.RequireApplicationToken, 200) s.assertQueryRequest("token", "apptoken", s.auth.RequireClient, 401) s.assertQueryRequest("token", "apptoken", s.auth.RequireAdmin, 401) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireClient, 401) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireAdmin, 401) // clienttoken s.assertQueryRequest("token", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "clienttoken", s.auth.RequireClient, 200) s.assertQueryRequest("token", "clienttoken", s.auth.RequireAdmin, 403) s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireClient, 200) s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireAdmin, 200) } func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) { recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/?%s=%s", key, value), nil) f()(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } func (s *AuthenticationSuite) TestNothingProvided() { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) s.auth.RequireApplicationToken()(ctx) assert.Equal(s.T(), 401, recorder.Code) } func (s *AuthenticationSuite) TestHeaderApiKeyToken() { // not existing token s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireAdmin, 401) // not existing key s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireAdmin, 401) // apptoken s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireAdmin, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireAdmin, 401) // clienttoken s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireClient, 200) s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireAdmin, 403) s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireClient, 200) s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireAdmin, 200) } func (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() { // not existing token s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireAdmin, 401) // no authentication schema s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireAdmin, 401) // wrong authentication schema s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireAdmin, 401) // Authorization Bearer apptoken s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireAdmin, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireAdmin, 401) // Authorization Bearer clienttoken s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireAdmin, 403) s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireAdmin, 200) } func (s *AuthenticationSuite) TestBasicAuth() { s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireAdmin, 401) // user existing:pw s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 403) s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 403) // user admin:pw s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireApplicationToken, 403) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 200) // user admin:pwx s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireAdmin, 401) // user notexisting:pw s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 401) } func (s *AuthenticationSuite) TestOptionalAuth() { // various invalid users ctx := s.assertQueryRequest("token", "ergerogerg", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) ctx = s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) ctx = s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) ctx = s.assertQueryRequest("tokenx", "clienttoken", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) ctx = s.assertQueryRequest("token", "apptoken_admin", s.auth.Optional, 200) assert.Nil(s.T(), TryGetUserID(ctx)) // user existing:pw ctx = s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200) assert.Equal(s.T(), uint(1), *TryGetUserID(ctx)) ctx = s.assertQueryRequest("token", "clienttoken", s.auth.Optional, 200) assert.Equal(s.T(), uint(1), *TryGetUserID(ctx)) // user admin:pw ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.Optional, 200) assert.Equal(s.T(), uint(2), *TryGetUserID(ctx)) ctx = s.assertQueryRequest("token", "clienttoken_admin", s.auth.Optional, 200) assert.Equal(s.T(), uint(2), *TryGetUserID(ctx)) } func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) { recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) ctx.Request.Header.Set(key, value) f()(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } type fMiddleware func() gin.HandlerFunc ================================================ FILE: auth/cors.go ================================================ package auth import ( "regexp" "strings" "time" "github.com/gin-contrib/cors" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/mode" ) // CorsConfig generates a config to use in gin cors middleware based on server configuration. func CorsConfig(conf *config.Configuration) cors.Config { corsConf := cors.Config{ MaxAge: 12 * time.Hour, AllowBrowserExtensions: true, } if mode.IsDev() { corsConf.AllowAllOrigins = true corsConf.AllowMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"} corsConf.AllowHeaders = []string{ "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", "Connection", "Accept-Encoding", "Accept-Language", "Host", } } else { compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins) corsConf.AllowMethods = conf.Server.Cors.AllowMethods corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders corsConf.AllowOriginFunc = func(origin string) bool { for _, compiledOrigin := range compiledOrigins { if compiledOrigin.MatchString(strings.ToLower(origin)) { return true } } return false } if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 { corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin) } } return corsConf } func headerIgnoreCase(conf *config.Configuration, search string) (value string) { for key, value := range conf.Server.ResponseHeaders { if strings.ToLower(key) == search { return value } } return "" } func compileAllowedCORSOrigins(allowedOrigins []string) []*regexp.Regexp { var compiledAllowedOrigins []*regexp.Regexp for _, origin := range allowedOrigins { compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin)) } return compiledAllowedOrigins } ================================================ FILE: auth/cors_test.go ================================================ package auth import ( "testing" "time" "github.com/gin-contrib/cors" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/mode" "github.com/stretchr/testify/assert" ) func TestCorsConfig(t *testing.T) { mode.Set(mode.Prod) serverConf := config.Configuration{} serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"} serverConf.Server.Cors.AllowHeaders = []string{"content-type"} serverConf.Server.Cors.AllowMethods = []string{"GET"} actual := CorsConfig(&serverConf) allowF := actual.AllowOriginFunc actual.AllowOriginFunc = nil // func cannot be checked with equal assert.Equal(t, cors.Config{ AllowAllOrigins: false, AllowHeaders: []string{"content-type"}, AllowMethods: []string{"GET"}, MaxAge: 12 * time.Hour, AllowBrowserExtensions: true, }, actual) assert.NotNil(t, allowF) assert.True(t, allowF("http://test.com")) assert.False(t, allowF("https://test.com")) assert.False(t, allowF("https://other.com")) } func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) { mode.Set(mode.Prod) serverConf := config.Configuration{} serverConf.Server.ResponseHeaders = map[string]string{"Access-control-allow-origin": "https://example.com"} actual := CorsConfig(&serverConf) assert.NotNil(t, actual.AllowOriginFunc) actual.AllowOriginFunc = nil // func cannot be checked with equal assert.Equal(t, cors.Config{ AllowAllOrigins: false, AllowOrigins: []string{"https://example.com"}, MaxAge: 12 * time.Hour, AllowBrowserExtensions: true, }, actual) } func TestDevCorsConfig(t *testing.T) { mode.Set(mode.Dev) serverConf := config.Configuration{} serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"} serverConf.Server.Cors.AllowHeaders = []string{"content-type"} serverConf.Server.Cors.AllowMethods = []string{"GET"} actual := CorsConfig(&serverConf) assert.Equal(t, cors.Config{ AllowHeaders: []string{ "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", "Connection", "Accept-Encoding", "Accept-Language", "Host", }, AllowMethods: []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}, MaxAge: 12 * time.Hour, AllowAllOrigins: true, AllowBrowserExtensions: true, }, actual) } ================================================ FILE: auth/password/password.go ================================================ package password import "golang.org/x/crypto/bcrypt" // CreatePassword returns a hashed version of the given password. func CreatePassword(pw string, strength int) []byte { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), strength) if err != nil { panic(err) } return hashedPassword } // ComparePassword compares a hashed password with its possible plaintext equivalent. func ComparePassword(hashedPassword, password []byte) bool { return bcrypt.CompareHashAndPassword(hashedPassword, password) == nil } ================================================ FILE: auth/password/password_test.go ================================================ package password import ( "testing" "github.com/stretchr/testify/assert" ) func TestPasswordSuccess(t *testing.T) { password := CreatePassword("secret", 5) assert.Equal(t, true, ComparePassword(password, []byte("secret"))) } func TestPasswordFailure(t *testing.T) { password := CreatePassword("secret", 5) assert.Equal(t, false, ComparePassword(password, []byte("secretx"))) } func TestBCryptFailure(t *testing.T) { assert.Panics(t, func() { CreatePassword("secret", 12312) }) } ================================================ FILE: auth/token.go ================================================ package auth import ( "crypto/rand" "math/big" ) var ( tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") randomTokenLength = 14 applicationPrefix = "A" clientPrefix = "C" pluginPrefix = "P" randReader = rand.Reader ) func randIntn(n int) int { max := big.NewInt(int64(n)) res, err := rand.Int(randReader, max) if err != nil { panic("random source is not available") } return int(res.Int64()) } // GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token. func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { for { token := generateToken() if !tokenExists(token) { return token } } } // GenerateApplicationToken generates an application token. func GenerateApplicationToken() string { return generateRandomToken(applicationPrefix) } // GenerateClientToken generates a client token. func GenerateClientToken() string { return generateRandomToken(clientPrefix) } // GeneratePluginToken generates a plugin token. func GeneratePluginToken() string { return generateRandomToken(pluginPrefix) } // GenerateImageName generates an image name. func GenerateImageName() string { return generateRandomString(25) } func generateRandomToken(prefix string) string { return prefix + generateRandomString(randomTokenLength) } func generateRandomString(length int) string { res := make([]byte, length) for i := range res { index := randIntn(len(tokenCharacters)) res[i] = tokenCharacters[index] } return string(res) } func init() { randIntn(2) } ================================================ FILE: auth/token_test.go ================================================ package auth import ( "crypto/rand" "fmt" "strings" "testing" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" ) func TestTokenHavePrefix(t *testing.T) { for i := 0; i < 50; i++ { assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P")) assert.NotEmpty(t, GenerateImageName()) } } func TestGenerateNotExistingToken(t *testing.T) { count := 5 token := GenerateNotExistingToken(func() string { return fmt.Sprint(count) }, func(token string) bool { count-- return token != "0" }) assert.Equal(t, "0", token) } func TestBadCryptoReaderPanics(t *testing.T) { assert.Panics(t, func() { randReader = test.UnreadableReader() defer func() { randReader = rand.Reader }() randIntn(2) }) } ================================================ FILE: auth/util.go ================================================ package auth import ( "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" ) // RegisterAuthentication registers the user id, user and or token. func RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tokenID string) { ctx.Set("user", user) ctx.Set("userid", userID) ctx.Set("tokenid", tokenID) } // GetUserID returns the user id which was previously registered by RegisterAuthentication. func GetUserID(ctx *gin.Context) uint { id := TryGetUserID(ctx) if id == nil { panic("token and user may not be null") } return *id } // TryGetUserID returns the user id or nil if one is not set. func TryGetUserID(ctx *gin.Context) *uint { user := ctx.MustGet("user").(*model.User) if user == nil { userID := ctx.MustGet("userid").(uint) if userID == 0 { return nil } return &userID } return &user.ID } // GetTokenID returns the tokenID. func GetTokenID(ctx *gin.Context) string { return ctx.MustGet("tokenid").(string) } ================================================ FILE: auth/util_test.go ================================================ package auth import ( "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func TestUtilSuite(t *testing.T) { suite.Run(t, new(UtilSuite)) } type UtilSuite struct { suite.Suite } func (s *UtilSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) } func (s *UtilSuite) Test_getID() { s.expectUserIDWith(&model.User{ID: 2}, 0, 2) s.expectUserIDWith(nil, 5, 5) assert.Panics(s.T(), func() { s.expectUserIDWith(nil, 0, 0) }) s.expectTryUserIDWith(nil, 0, nil) } func (s *UtilSuite) Test_getToken() { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) RegisterAuthentication(ctx, nil, 1, "asdasda") actualID := GetTokenID(ctx) assert.Equal(s.T(), "asdasda", actualID) } func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) RegisterAuthentication(ctx, user, tokenUserID, "") actualID := GetUserID(ctx) assert.Equal(s.T(), expectedID, actualID) } func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) RegisterAuthentication(ctx, user, tokenUserID, "") actualID := TryGetUserID(ctx) assert.Equal(s.T(), expectedID, actualID) } ================================================ FILE: config/config.go ================================================ package config import ( "path/filepath" "strings" "github.com/gotify/server/v2/mode" "github.com/jinzhu/configor" ) // Configuration is stuff that can be configured externally per env variables or config file (config.yml). type Configuration struct { Server struct { KeepAlivePeriodSeconds int ListenAddr string `default:""` Port int `default:"80"` SSL struct { Enabled bool `default:"false"` RedirectToHTTPS bool `default:"true"` ListenAddr string `default:""` Port int `default:"443"` CertFile string `default:""` CertKey string `default:""` LetsEncrypt struct { Enabled bool `default:"false"` AcceptTOS bool `default:"false"` Cache string `default:"data/certs"` DirectoryURL string `default:""` Hosts []string } } ResponseHeaders map[string]string Stream struct { PingPeriodSeconds int `default:"45"` AllowedOrigins []string } Cors struct { AllowOrigins []string AllowMethods []string AllowHeaders []string } TrustedProxies []string } Database struct { Dialect string `default:"sqlite3"` Connection string `default:"data/gotify.db"` } DefaultUser struct { Name string `default:"admin"` Pass string `default:"admin"` } PassStrength int `default:"10"` UploadedImagesDir string `default:"data/images"` PluginsDir string `default:"data/plugins"` Registration bool `default:"false"` } func configFiles() []string { if mode.Get() == mode.TestDev { return []string{"config.yml"} } return []string{"config.yml", "/etc/gotify/config.yml"} } // Get returns the configuration extracted from env variables or config file. func Get() *Configuration { conf := new(Configuration) err := configor.New(&configor.Config{ENVPrefix: "GOTIFY", Silent: true}).Load(conf, configFiles()...) if err != nil { panic(err) } addTrailingSlashToPaths(conf) return conf } func addTrailingSlashToPaths(conf *Configuration) { if !strings.HasSuffix(conf.UploadedImagesDir, "/") && !strings.HasSuffix(conf.UploadedImagesDir, "\\") { conf.UploadedImagesDir += string(filepath.Separator) } } ================================================ FILE: config/config_test.go ================================================ package config import ( "os" "path/filepath" "testing" "github.com/gotify/server/v2/mode" "github.com/stretchr/testify/assert" ) func TestConfigEnv(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis") os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "- push.example.tld\n- push.other.tld") os.Setenv("GOTIFY_SERVER_RESPONSEHEADERS", "Access-Control-Allow-Origin: \"*\"\nAccess-Control-Allow-Methods: \"GET,POST\"", ) os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"") os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "- \"GET\"\n- \"POST\"") os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "- \"Authorization\"\n- \"content-type\"") os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"") conf := Get() assert.Equal(t, 80, conf.Server.Port, "should use defaults") assert.Equal(t, "jmattheis", conf.DefaultUser.Name, "should not use default but env var") assert.Equal(t, []string{"push.example.tld", "push.other.tld"}, conf.Server.SSL.LetsEncrypt.Hosts) assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"]) assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"]) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Cors.AllowOrigins) assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods) assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) os.Unsetenv("GOTIFY_DEFAULTUSER_NAME") os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS") os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS") os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS") os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS") os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS") os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS") } func TestAddSlash(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images") conf := Get() assert.Equal(t, "../data/images"+string(filepath.Separator), conf.UploadedImagesDir) os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR") } func TestNotAddSlash(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/") conf := Get() assert.Equal(t, "../data/", conf.UploadedImagesDir) os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR") } func TestFileWithSyntaxErrors(t *testing.T) { mode.Set(mode.TestDev) file, err := os.Create("config.yml") defer func() { file.Close() }() assert.Nil(t, err) _, err = file.WriteString(` sdgsgsdfgsdfg `) file.Close() assert.Nil(t, err) assert.Panics(t, func() { Get() }) assert.Nil(t, os.Remove("config.yml")) } func TestConfigFile(t *testing.T) { mode.Set(mode.TestDev) file, err := os.Create("config.yml") defer func() { file.Close() }() assert.Nil(t, err) _, err = file.WriteString(` server: port: 1234 ssl: port: 3333 letsencrypt: hosts: - push.example.tld responseheaders: Access-Control-Allow-Origin: "*" Access-Control-Allow-Methods: "GET,POST" cors: alloworigins: - ".*" - ".+" allowmethods: - "GET" - "POST" allowheaders: - "Authorization" - "content-type" stream: allowedorigins: - ".+.example.com" - "otherdomain.com" database: dialect: mysql connection: user name defaultuser: name: nicories pass: 12345 pluginsdir: data/plugins `) file.Close() assert.Nil(t, err) conf := Get() assert.Equal(t, 1234, conf.Server.Port) assert.Equal(t, 3333, conf.Server.SSL.Port) assert.Equal(t, []string{"push.example.tld"}, conf.Server.SSL.LetsEncrypt.Hosts) assert.Equal(t, "nicories", conf.DefaultUser.Name) assert.Equal(t, "12345", conf.DefaultUser.Pass) assert.Equal(t, "mysql", conf.Database.Dialect) assert.Equal(t, "user name", conf.Database.Connection) assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"]) assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"]) assert.Equal(t, []string{".*", ".+"}, conf.Server.Cors.AllowOrigins) assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods) assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) assert.Equal(t, "data/plugins", conf.PluginsDir) assert.Nil(t, os.Remove("config.yml")) } ================================================ FILE: config.example.yml ================================================ # Example configuration file for the server. # Save it to `config.yml` when edited server: keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing. listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". port: 80 # the port the HTTP server will listen on ssl: enabled: false # if https should be enabled redirecttohttps: true # redirect to https if site is accessed by http listenaddr: "" # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". port: 443 # the https port certfile: # the cert file (leave empty when using letsencrypt) certkey: # the cert key (leave empty when using letsencrypt) letsencrypt: enabled: false # if the certificate should be requested from letsencrypt accepttos: false # if you accept the tos from letsencrypt cache: data/certs # the directory of the cache from letsencrypt directoryurl: # override the directory url of the ACME server # Let's Encrypt highly recommend testing against their staging environment before using their production environment. # Staging server has high rate limits for testing and debugging, issued certificates are not valid # example: https://acme-staging-v02.api.letsencrypt.org/directory hosts: # the hosts for which letsencrypt should request certificates # - mydomain.tld # - myotherdomain.tld responseheaders: # response headers are added to every response (default: none) # X-Custom-Header: "custom value" # trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets) # - 127.0.0.1/32 # - ::1 cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers. alloworigins: # - ".+.example.com" # - "otherdomain.com" allowmethods: # - "GET" # - "POST" allowheaders: # - "Authorization" # - "content-type" stream: pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing. allowedorigins: # allowed origins for websocket connections (same origin is always allowed) # - ".+.example.com" # - "otherdomain.com" database: # for database see (configure database section) dialect: sqlite3 connection: data/gotify.db defaultuser: # on database creation, gotify creates an admin user name: admin # the username of the default user pass: admin # the password of the default user passstrength: 10 # the bcrypt password strength (higher = better but also slower) uploadedimagesdir: data/images # the directory for storing uploaded images pluginsdir: data/plugins # the directory where plugin resides registration: false # enable registrations ================================================ FILE: database/application.go ================================================ package database import ( "database/sql" "time" "github.com/gotify/server/v2/fracdex" "github.com/gotify/server/v2/model" "gorm.io/gorm" ) // GetApplicationByToken returns the application for the given token or nil. func (d *GormDatabase) GetApplicationByToken(token string) (*model.Application, error) { app := new(model.Application) err := d.DB.Where("token = ?", token).Find(app).Error if err == gorm.ErrRecordNotFound { err = nil } if app.Token == token { return app, err } return nil, err } // GetApplicationByID returns the application for the given id or nil. func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) { app := new(model.Application) err := d.DB.Where("id = ?", id).Find(app).Error if err == gorm.ErrRecordNotFound { err = nil } if app.ID == id { return app, err } return nil, err } // CreateApplication creates an application. func (d *GormDatabase) CreateApplication(application *model.Application) error { return d.DB.Transaction(func(tx *gorm.DB) error { if application.SortKey == "" { sortKey := "" err := tx.Model(&model.Application{}).Select("sort_key").Where("user_id = ?", application.UserID).Order("sort_key DESC").Limit(1).Find(&sortKey).Error if err != nil && err != gorm.ErrRecordNotFound { return err } application.SortKey, err = fracdex.KeyBetween(sortKey, "") if err != nil { return err } } return tx.Create(application).Error }, &sql.TxOptions{Isolation: sql.LevelSerializable}) } // DeleteApplicationByID deletes an application by its id. func (d *GormDatabase) DeleteApplicationByID(id uint) error { d.DeleteMessagesByApplication(id) return d.DB.Where("id = ?", id).Delete(&model.Application{}).Error } // GetApplicationsByUser returns all applications from a user. func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) { var apps []*model.Application err := d.DB.Where("user_id = ?", userID).Order("sort_key, id ASC").Find(&apps).Error if err == gorm.ErrRecordNotFound { err = nil } return apps, err } // UpdateApplication updates an application. func (d *GormDatabase) UpdateApplication(app *model.Application) error { return d.DB.Save(app).Error } // UpdateApplicationTokenLastUsed updates the last used time of the application token. func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error { return d.DB.Model(&model.Application{}).Where("token = ?", token).Update("last_used", t).Error } ================================================ FILE: database/application_test.go ================================================ package database import ( "time" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) func (s *DatabaseSuite) TestApplication() { if app, err := s.db.GetApplicationByToken("asdasdf"); assert.NoError(s.T(), err) { assert.Nil(s.T(), app, "not existing app") } if app, err := s.db.GetApplicationByID(uint(1)); assert.NoError(s.T(), err) { assert.Nil(s.T(), app, "not existing app") } user := &model.User{Name: "test", Pass: []byte{1}} s.db.CreateUser(user) assert.NotEqual(s.T(), 0, user.ID) if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { assert.Empty(s.T(), apps) } app := &model.Application{UserID: user.ID, Token: "C0000000000", Name: "backupserver"} s.db.CreateApplication(app) if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { assert.Len(s.T(), apps, 1) assert.Contains(s.T(), apps, app) } newApp, err := s.db.GetApplicationByToken(app.Token) if assert.NoError(s.T(), err) { assert.Equal(s.T(), app, newApp) } newApp, err = s.db.GetApplicationByID(app.ID) if assert.NoError(s.T(), err) { assert.Equal(s.T(), app, newApp) } lastUsed := time.Now().Add(-time.Hour) s.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed) newApp, err = s.db.GetApplicationByID(app.ID) if assert.NoError(s.T(), err) { assert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix()) } app.LastUsed = &lastUsed newApp.Image = "asdasd" assert.NoError(s.T(), s.db.UpdateApplication(newApp)) newApp, err = s.db.GetApplicationByID(app.ID) if assert.NoError(s.T(), err) { assert.Equal(s.T(), "asdasd", newApp.Image) } assert.NoError(s.T(), s.db.DeleteApplicationByID(app.ID)) if apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) { assert.Empty(s.T(), apps) } if app, err := s.db.GetApplicationByID(app.ID); assert.NoError(s.T(), err) { assert.Nil(s.T(), app) } } func (s *DatabaseSuite) TestDeleteAppDeletesMessages() { assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 55, Token: "token"})) assert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 66, Token: "token2"})) assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 12, ApplicationID: 55})) assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 13, ApplicationID: 66})) assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 14, ApplicationID: 55})) assert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 15, ApplicationID: 55})) assert.NoError(s.T(), s.db.DeleteApplicationByID(55)) if msg, err := s.db.GetMessageByID(12); assert.NoError(s.T(), err) { assert.Nil(s.T(), msg) } if msg, err := s.db.GetMessageByID(13); assert.NoError(s.T(), err) { assert.NotNil(s.T(), msg) } if msg, err := s.db.GetMessageByID(14); assert.NoError(s.T(), err) { assert.Nil(s.T(), msg) } if msg, err := s.db.GetMessageByID(15); assert.NoError(s.T(), err) { assert.Nil(s.T(), msg) } if msgs, err := s.db.GetMessagesByApplication(55); assert.NoError(s.T(), err) { assert.Empty(s.T(), msgs) } if msgs, err := s.db.GetMessagesByApplication(66); assert.NoError(s.T(), err) { assert.NotEmpty(s.T(), msgs) } } ================================================ FILE: database/client.go ================================================ package database import ( "time" "github.com/gotify/server/v2/model" "gorm.io/gorm" ) // GetClientByID returns the client for the given id or nil. func (d *GormDatabase) GetClientByID(id uint) (*model.Client, error) { client := new(model.Client) err := d.DB.Where("id = ?", id).Find(client).Error if err == gorm.ErrRecordNotFound { err = nil } if client.ID == id { return client, err } return nil, err } // GetClientByToken returns the client for the given token or nil. func (d *GormDatabase) GetClientByToken(token string) (*model.Client, error) { client := new(model.Client) err := d.DB.Where("token = ?", token).Find(client).Error if err == gorm.ErrRecordNotFound { err = nil } if client.Token == token { return client, err } return nil, err } // CreateClient creates a client. func (d *GormDatabase) CreateClient(client *model.Client) error { return d.DB.Create(client).Error } // GetClientsByUser returns all clients from a user. func (d *GormDatabase) GetClientsByUser(userID uint) ([]*model.Client, error) { var clients []*model.Client err := d.DB.Where("user_id = ?", userID).Find(&clients).Error if err == gorm.ErrRecordNotFound { err = nil } return clients, err } // DeleteClientByID deletes a client by its id. func (d *GormDatabase) DeleteClientByID(id uint) error { return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error } // UpdateClient updates a client. func (d *GormDatabase) UpdateClient(client *model.Client) error { return d.DB.Save(client).Error } // UpdateClientTokensLastUsed updates the last used timestamp of clients. func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error { return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error } ================================================ FILE: database/client_test.go ================================================ package database import ( "time" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) func (s *DatabaseSuite) TestClient() { if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { assert.Nil(s.T(), client, "not existing client") } if client, err := s.db.GetClientByToken("asdasd"); assert.NoError(s.T(), err) { assert.Nil(s.T(), client, "not existing client") } user := &model.User{Name: "test", Pass: []byte{1}} s.db.CreateUser(user) assert.NotEqual(s.T(), 0, user.ID) if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { assert.Empty(s.T(), clients) } client := &model.Client{UserID: user.ID, Token: "C0000000000", Name: "android"} assert.NoError(s.T(), s.db.CreateClient(client)) if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { assert.Len(s.T(), clients, 1) assert.Contains(s.T(), clients, client) } newClient, err := s.db.GetClientByID(client.ID) if assert.NoError(s.T(), err) { assert.Equal(s.T(), client, newClient) } if newClient, err := s.db.GetClientByToken(client.Token); assert.NoError(s.T(), err) { assert.Equal(s.T(), client, newClient) } updateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: "C0000000000", Name: "new_name"} s.db.UpdateClient(updateClient) if updatedClient, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) { assert.Equal(s.T(), updateClient, updatedClient) } lastUsed := time.Now().Add(-time.Hour) s.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed) newClient, err = s.db.GetClientByID(client.ID) if assert.NoError(s.T(), err) { assert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix()) } s.db.DeleteClientByID(client.ID) if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { assert.Empty(s.T(), clients) } if client, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) { assert.Nil(s.T(), client) } } ================================================ FILE: database/database.go ================================================ package database import ( "database/sql" "errors" "fmt" "log" "math" "os" "path/filepath" "time" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/fracdex" "github.com/gotify/server/v2/model" "github.com/mattn/go-isatty" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) var mkdirAll = os.MkdirAll // New creates a new wrapper for the gorm database framework. func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) { createDirectoryIfSqlite(dialect, connection) dbLogger := logger.New(log.New(os.Stderr, "\r\n", log.LstdFlags), logger.Config{ SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, Colorful: isatty.IsTerminal(os.Stderr.Fd()), }) gormConfig := &gorm.Config{ Logger: dbLogger, DisableForeignKeyConstraintWhenMigrating: true, TranslateError: true, } var db *gorm.DB err := errors.New("unsupported dialect: " + dialect) switch dialect { case "mysql": db, err = gorm.Open(mysql.Open(connection), gormConfig) case "postgres": db, err = gorm.Open(postgres.Open(connection), gormConfig) case "sqlite3": db, err = gorm.Open(sqlite.Open(connection), gormConfig) } if err != nil { return nil, err } sqldb, err := db.DB() if err != nil { return nil, err } // We normally don't need that much connections, so we limit them. F.ex. mysql complains about // "too many connections", while load testing Gotify. sqldb.SetMaxOpenConns(10) if dialect == "sqlite3" { // We use the database connection inside the handlers from the http // framework, therefore concurrent access occurs. Sqlite cannot handle // concurrent writes, so we limit sqlite to one connection. // see https://github.com/mattn/go-sqlite3/issues/274 sqldb.SetMaxOpenConns(1) } if dialect == "mysql" { // Mysql has a setting called wait_timeout, which defines the duration // after which a connection may not be used anymore. // The default for this setting on mariadb is 10 minutes. // See https://github.com/docker-library/mariadb/issues/113 sqldb.SetConnMaxLifetime(9 * time.Minute) } if err := db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf)); err != nil { return nil, err } userCount := int64(0) db.Find(new(model.User)).Count(&userCount) if createDefaultUserIfNotExist && userCount == 0 { db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) } if err := db.Transaction(fillMissingSortKeys, &sql.TxOptions{Isolation: sql.LevelSerializable}); err != nil { return nil, err } return &GormDatabase{DB: db}, nil } func fillMissingSortKeys(db *gorm.DB) error { missingSort := int64(0) if err := db.Model(new(model.Application)).Where("sort_key IS NULL OR sort_key = ''").Count(&missingSort).Error; err != nil { return err } if missingSort == 0 { return nil } var apps []*model.Application if err := db.Order("user_id, sort_key, id ASC").Find(&apps).Error; err != nil && err != gorm.ErrRecordNotFound { return err } fmt.Println("Migrating", len(apps), "application sort keys") sortKey := "" currentUser := uint(math.MaxUint) var err error for _, app := range apps { if currentUser != app.UserID { sortKey = "" currentUser = app.UserID } sortKey, err = fracdex.KeyBetween(sortKey, "") if err != nil { return err } app.SortKey = sortKey } return db.Save(apps).Error } func createDirectoryIfSqlite(dialect, connection string) { if dialect == "sqlite3" { if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) { if err := mkdirAll(filepath.Dir(connection), 0o777); err != nil { panic(err) } } } } // GormDatabase is a wrapper for the gorm framework. type GormDatabase struct { DB *gorm.DB } // Close closes the gorm database connection. func (d *GormDatabase) Close() { sqldb, err := d.DB.DB() if err != nil { return } sqldb.Close() } ================================================ FILE: database/database_test.go ================================================ package database import ( "errors" "fmt" "os" "testing" "time" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/gorm" ) func TestDatabaseSuite(t *testing.T) { suite.Run(t, new(DatabaseSuite)) } type DatabaseSuite struct { suite.Suite db *GormDatabase tmpDir test.TmpDir } func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { s.tmpDir = test.NewTmpDir("gotify_databasesuite") db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(s.T(), err) s.db = db } func (s *DatabaseSuite) AfterTest(suiteName, testName string) { s.db.Close() assert.Nil(s.T(), s.tmpDir.Clean()) } func TestInvalidDialect(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testinvaliddialect") defer tmpDir.Clean() _, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Error(t, err) } func TestCreateSqliteFolder(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder") defer tmpDir.Clean() db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(t, err) assert.DirExists(t, tmpDir.Path("somepath")) db.Close() } func TestWithAlreadyExistingSqliteFolder(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testwithexistingfolder") defer tmpDir.Clean() db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(t, err) assert.DirExists(t, tmpDir.Path("somepath")) db.Close() } func TestPanicsOnMkdirError(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testpanicsonmkdirerror") defer tmpDir.Clean() mkdirAll = func(path string, perm os.FileMode) error { return errors.New("ERROR") } assert.Panics(t, func() { New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true) }) } func TestMigrateSortKey(t *testing.T) { db, err := New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true) assert.Nil(t, err) assert.NotNil(t, db) err = db.CreateApplication(&model.Application{Name: "one", Token: "one", UserID: 1}) assert.NoError(t, err) err = db.CreateApplication(&model.Application{Name: "two", Token: "two", UserID: 1}) assert.NoError(t, err) err = db.CreateApplication(&model.Application{Name: "three", Token: "three", UserID: 1}) assert.NoError(t, err) err = db.CreateApplication(&model.Application{Name: "one-other", Token: "one-other", UserID: 2}) assert.NoError(t, err) err = db.DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(new(model.Application)).UpdateColumn("sort_key", nil).Error assert.NoError(t, err) err = fillMissingSortKeys(db.DB) assert.NoError(t, err) apps, err := db.GetApplicationsByUser(1) assert.NoError(t, err) assert.Len(t, apps, 3) assert.Equal(t, apps[0].Name, "one") assert.Equal(t, apps[0].SortKey, "a0") assert.Equal(t, apps[1].Name, "two") assert.Equal(t, apps[1].SortKey, "a1") assert.Equal(t, apps[2].Name, "three") assert.Equal(t, apps[2].SortKey, "a2") apps, err = db.GetApplicationsByUser(2) assert.NoError(t, err) assert.Len(t, apps, 1) assert.Equal(t, apps[0].Name, "one-other") assert.Equal(t, apps[0].SortKey, "a0") } ================================================ FILE: database/message.go ================================================ package database import ( "github.com/gotify/server/v2/model" "gorm.io/gorm" ) // GetMessageByID returns the messages for the given id or nil. func (d *GormDatabase) GetMessageByID(id uint) (*model.Message, error) { msg := new(model.Message) err := d.DB.Find(msg, id).Error if err == gorm.ErrRecordNotFound { err = nil } if msg.ID == id { return msg, err } return nil, err } // CreateMessage creates a message. func (d *GormDatabase) CreateMessage(message *model.Message) error { return d.DB.Create(message).Error } // GetMessagesByUser returns all messages from a user. func (d *GormDatabase) GetMessagesByUser(userID uint) ([]*model.Message, error) { var messages []*model.Message err := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID). Where("messages.application_id = applications.id").Order("messages.id desc").Find(&messages).Error if err == gorm.ErrRecordNotFound { err = nil } return messages, err } // GetMessagesByUserSince returns limited messages from a user. // If since is 0 it will be ignored. func (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error) { var messages []*model.Message db := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID). Where("messages.application_id = applications.id").Order("messages.id desc").Limit(limit) if since != 0 { db = db.Where("messages.id < ?", since) } err := db.Find(&messages).Error if err == gorm.ErrRecordNotFound { err = nil } return messages, err } // GetMessagesByApplication returns all messages from an application. func (d *GormDatabase) GetMessagesByApplication(tokenID uint) ([]*model.Message, error) { var messages []*model.Message err := d.DB.Where("application_id = ?", tokenID).Order("messages.id desc").Find(&messages).Error if err == gorm.ErrRecordNotFound { err = nil } return messages, err } // GetMessagesByApplicationSince returns limited messages from an application. // If since is 0 it will be ignored. func (d *GormDatabase) GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error) { var messages []*model.Message db := d.DB.Where("application_id = ?", appID).Order("messages.id desc").Limit(limit) if since != 0 { db = db.Where("messages.id < ?", since) } err := db.Find(&messages).Error if err == gorm.ErrRecordNotFound { err = nil } return messages, err } // DeleteMessageByID deletes a message by its id. func (d *GormDatabase) DeleteMessageByID(id uint) error { return d.DB.Where("id = ?", id).Delete(&model.Message{}).Error } // DeleteMessagesByApplication deletes all messages from an application. func (d *GormDatabase) DeleteMessagesByApplication(applicationID uint) error { return d.DB.Where("application_id = ?", applicationID).Delete(&model.Message{}).Error } // DeleteMessagesByUser deletes all messages from a user. func (d *GormDatabase) DeleteMessagesByUser(userID uint) error { app, _ := d.GetApplicationsByUser(userID) for _, app := range app { d.DeleteMessagesByApplication(app.ID) } return nil } ================================================ FILE: database/message_test.go ================================================ package database import ( "testing" "time" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func (s *DatabaseSuite) TestMessage() { messages, err := s.db.GetMessageByID(5) require.NoError(s.T(), err) assert.Nil(s.T(), messages, "not existing message") user := &model.User{Name: "test", Pass: []byte{1}} s.db.CreateUser(user) assert.NotEqual(s.T(), 0, user.ID) backupServer := &model.Application{UserID: user.ID, Token: "A0000000000", Name: "backupserver"} s.db.CreateApplication(backupServer) assert.NotEqual(s.T(), 0, backupServer.ID) msgs, err := s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) msgs, err = s.db.GetMessagesByApplication(backupServer.ID) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) backupdone := &model.Message{ApplicationID: backupServer.ID, Message: "backup done", Title: "backup", Priority: 1, Date: time.Now()} require.NoError(s.T(), s.db.CreateMessage(backupdone)) assert.NotEqual(s.T(), 0, backupdone.ID) messages, err = s.db.GetMessageByID(backupdone.ID) require.NoError(s.T(), err) assertEquals(s.T(), messages, backupdone) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assertEquals(s.T(), msgs[0], backupdone) msgs, err = s.db.GetMessagesByApplication(backupServer.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assertEquals(s.T(), msgs[0], backupdone) loginServer := &model.Application{UserID: user.ID, Token: "A0000000001", Name: "loginserver"} require.NoError(s.T(), s.db.CreateApplication(loginServer)) assert.NotEqual(s.T(), 0, loginServer.ID) logindone := &model.Message{ApplicationID: loginServer.ID, Message: "login done", Title: "login", Priority: 1, Date: time.Now()} require.NoError(s.T(), s.db.CreateMessage(logindone)) assert.NotEqual(s.T(), 0, logindone.ID) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 2) assertEquals(s.T(), msgs[0], logindone) assertEquals(s.T(), msgs[1], backupdone) msgs, err = s.db.GetMessagesByApplication(backupServer.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assertEquals(s.T(), msgs[0], backupdone) loginfailed := &model.Message{ApplicationID: loginServer.ID, Message: "login failed", Title: "login", Priority: 1, Date: time.Now()} require.NoError(s.T(), s.db.CreateMessage(loginfailed)) assert.NotEqual(s.T(), 0, loginfailed.ID) msgs, err = s.db.GetMessagesByApplication(backupServer.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assertEquals(s.T(), msgs[0], backupdone) msgs, err = s.db.GetMessagesByApplication(loginServer.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 2) assertEquals(s.T(), msgs[0], loginfailed) assertEquals(s.T(), msgs[1], logindone) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 3) assertEquals(s.T(), msgs[0], loginfailed) assertEquals(s.T(), msgs[1], logindone) assertEquals(s.T(), msgs[2], backupdone) backupfailed := &model.Message{ApplicationID: backupServer.ID, Message: "backup failed", Title: "backup", Priority: 1, Date: time.Now()} require.NoError(s.T(), s.db.CreateMessage(backupfailed)) assert.NotEqual(s.T(), 0, backupfailed.ID) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 4) assertEquals(s.T(), msgs[0], backupfailed) assertEquals(s.T(), msgs[1], loginfailed) assertEquals(s.T(), msgs[2], logindone) assertEquals(s.T(), msgs[3], backupdone) msgs, err = s.db.GetMessagesByApplication(loginServer.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 2) assertEquals(s.T(), msgs[0], loginfailed) assertEquals(s.T(), msgs[1], logindone) require.NoError(s.T(), s.db.DeleteMessagesByApplication(loginServer.ID)) msgs, err = s.db.GetMessagesByApplication(loginServer.ID) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 2) assertEquals(s.T(), msgs[0], backupfailed) assertEquals(s.T(), msgs[1], backupdone) logindone = &model.Message{ApplicationID: loginServer.ID, Message: "login done", Title: "login", Priority: 1, Date: time.Now()} require.NoError(s.T(), s.db.CreateMessage(logindone)) assert.NotEqual(s.T(), 0, logindone.ID) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 3) assertEquals(s.T(), msgs[0], logindone) assertEquals(s.T(), msgs[1], backupfailed) assertEquals(s.T(), msgs[2], backupdone) s.db.DeleteMessagesByUser(user.ID) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) logout := &model.Message{ApplicationID: loginServer.ID, Message: "logout success", Title: "logout", Priority: 1, Date: time.Now()} s.db.CreateMessage(logout) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) assertEquals(s.T(), msgs[0], logout) require.NoError(s.T(), s.db.DeleteMessageByID(logout.ID)) msgs, err = s.db.GetMessagesByUser(user.ID) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) } func (s *DatabaseSuite) TestGetMessagesSince() { user := &model.User{Name: "test", Pass: []byte{1}} require.NoError(s.T(), s.db.CreateUser(user)) app := &model.Application{UserID: user.ID, Token: "A0000000000"} app2 := &model.Application{UserID: user.ID, Token: "A0000000001"} require.NoError(s.T(), s.db.CreateApplication(app)) require.NoError(s.T(), s.db.CreateApplication(app2)) curDate := time.Now() for i := 1; i <= 500; i++ { s.db.CreateMessage(&model.Message{ApplicationID: app.ID, Message: "abc", Date: curDate.Add(time.Duration(i) * time.Second)}) s.db.CreateMessage(&model.Message{ApplicationID: app2.ID, Message: "abc", Date: curDate.Add(time.Duration(i) * time.Second)}) } actual, err := s.db.GetMessagesByUserSince(user.ID, 50, 0) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 1000, 951, 1) actual, err = s.db.GetMessagesByUserSince(user.ID, 50, 951) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 950, 901, 1) actual, err = s.db.GetMessagesByUserSince(user.ID, 100, 951) require.NoError(s.T(), err) assert.Len(s.T(), actual, 100) hasIDInclusiveBetween(s.T(), actual, 950, 851, 1) actual, err = s.db.GetMessagesByUserSince(user.ID, 100, 51) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 50, 1, 1) actual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 0) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 999, 901, 2) actual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 901) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 899, 801, 2) actual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 666) require.NoError(s.T(), err) assert.Len(s.T(), actual, 100) hasIDInclusiveBetween(s.T(), actual, 665, 467, 2) actual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 101) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 99, 1, 2) actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 0) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 1000, 902, 2) actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 902) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 900, 802, 2) actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 667) require.NoError(s.T(), err) assert.Len(s.T(), actual, 100) hasIDInclusiveBetween(s.T(), actual, 666, 468, 2) actual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 102) require.NoError(s.T(), err) assert.Len(s.T(), actual, 50) hasIDInclusiveBetween(s.T(), actual, 100, 2, 2) } func hasIDInclusiveBetween(t *testing.T, msgs []*model.Message, from, to, decrement int) { index := 0 for expectedID := from; expectedID >= to; expectedID -= decrement { if !assert.Equal(t, uint(expectedID), msgs[index].ID) { break } index++ } assert.Equal(t, index, len(msgs), "not all entries inside msgs were checked") } // assertEquals compares messages and correctly check dates. func assertEquals(t *testing.T, left, right *model.Message) { assert.Equal(t, left.Date.Unix(), right.Date.Unix()) left.Date = right.Date assert.Equal(t, left, right) } ================================================ FILE: database/migration_test.go ================================================ package database import ( "testing" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestMigration(t *testing.T) { suite.Run(t, &MigrationSuite{}) } type MigrationSuite struct { suite.Suite tmpDir test.TmpDir } func (s *MigrationSuite) BeforeTest(suiteName, testName string) { s.tmpDir = test.NewTmpDir("gotify_migrationsuite") db, err := gorm.Open(sqlite.Open(s.tmpDir.Path("test_obsolete.db")), &gorm.Config{}) assert.NoError(s.T(), err) sqlDB, err := db.DB() assert.NoError(s.T(), err) defer sqlDB.Close() assert.Nil(s.T(), db.Migrator().CreateTable(new(model.User))) assert.Nil(s.T(), db.Create(&model.User{ Name: "test_user", Admin: true, }).Error) // we should not be able to create applications by now assert.False(s.T(), db.Migrator().HasTable(new(model.Application))) } func (s *MigrationSuite) AfterTest(suiteName, testName string) { assert.Nil(s.T(), s.tmpDir.Clean()) } func (s *MigrationSuite) TestMigration() { db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true) assert.Nil(s.T(), err) defer db.Close() assert.True(s.T(), db.DB.Migrator().HasTable(new(model.Application))) // a user already exist, not adding a new user if user, err := db.GetUserByName("admin"); assert.NoError(s.T(), err) { assert.Nil(s.T(), user) } // the old user should persist if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) { assert.Equal(s.T(), true, user.Admin) } // we should be able to create applications if user, err := db.GetUserByName("test_user"); assert.NoError(s.T(), err) { assert.Nil(s.T(), db.CreateApplication(&model.Application{ Token: "A1234", UserID: user.ID, Description: "this is a test application", Name: "test application", })) } if app, err := db.GetApplicationByToken("A1234"); assert.NoError(s.T(), err) { assert.Equal(s.T(), "test application", app.Name) } } ================================================ FILE: database/ping.go ================================================ package database // Ping pings the database to verify the connection. func (d *GormDatabase) Ping() error { sqldb, err := d.DB.DB() if err != nil { return err } return sqldb.Ping() } ================================================ FILE: database/ping_test.go ================================================ package database import ( "github.com/stretchr/testify/assert" ) func (s *DatabaseSuite) TestPing_onValidDB() { err := s.db.Ping() assert.NoError(s.T(), err) } func (s *DatabaseSuite) TestPing_onClosedDB() { s.db.Close() err := s.db.Ping() assert.Error(s.T(), err) } ================================================ FILE: database/plugin.go ================================================ package database import ( "github.com/gotify/server/v2/model" "gorm.io/gorm" ) // GetPluginConfByUser gets plugin configurations from a user. func (d *GormDatabase) GetPluginConfByUser(userid uint) ([]*model.PluginConf, error) { var plugins []*model.PluginConf err := d.DB.Where("user_id = ?", userid).Find(&plugins).Error if err == gorm.ErrRecordNotFound { err = nil } return plugins, err } // GetPluginConfByUserAndPath gets plugin configuration by user and file name. func (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error) { plugin := new(model.PluginConf) err := d.DB.Where("user_id = ? AND module_path = ?", userid, path).First(plugin).Error if err == gorm.ErrRecordNotFound { err = nil } if plugin.ModulePath == path { return plugin, err } return nil, err } // GetPluginConfByApplicationID gets plugin configuration by its internal appid. func (d *GormDatabase) GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error) { plugin := new(model.PluginConf) err := d.DB.Where("application_id = ?", appid).First(plugin).Error if err == gorm.ErrRecordNotFound { err = nil } if plugin.ApplicationID == appid { return plugin, err } return nil, err } // CreatePluginConf creates a new plugin configuration. func (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error { return d.DB.Create(p).Error } // GetPluginConfByToken gets plugin configuration by plugin token. func (d *GormDatabase) GetPluginConfByToken(token string) (*model.PluginConf, error) { plugin := new(model.PluginConf) err := d.DB.Where("token = ?", token).First(plugin).Error if err == gorm.ErrRecordNotFound { err = nil } if plugin.Token == token { return plugin, err } return nil, err } // GetPluginConfByID gets plugin configuration by plugin ID. func (d *GormDatabase) GetPluginConfByID(id uint) (*model.PluginConf, error) { plugin := new(model.PluginConf) err := d.DB.Where("id = ?", id).First(plugin).Error if err == gorm.ErrRecordNotFound { err = nil } if plugin.ID == id { return plugin, err } return nil, err } // UpdatePluginConf updates plugin configuration. func (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error { return d.DB.Save(p).Error } // DeletePluginConfByID deletes a plugin configuration by its id. func (d *GormDatabase) DeletePluginConfByID(id uint) error { return d.DB.Where("id = ?", id).Delete(&model.PluginConf{}).Error } ================================================ FILE: database/plugin_test.go ================================================ package database import ( "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func (s *DatabaseSuite) TestPluginConf() { plugin := model.PluginConf{ ModulePath: "github.com/gotify/example-plugin", Token: "Pabc", UserID: 1, Enabled: true, Config: nil, ApplicationID: 2, } assert.Nil(s.T(), s.db.CreatePluginConf(&plugin)) assert.Equal(s.T(), uint(1), plugin.ID) pluginConf, err := s.db.GetPluginConfByUserAndPath(1, "github.com/gotify/example-plugin") require.NoError(s.T(), err) assert.Equal(s.T(), "Pabc", pluginConf.Token) pluginConf, err = s.db.GetPluginConfByToken("Pabc") require.NoError(s.T(), err) assert.Equal(s.T(), true, pluginConf.Enabled) pluginConf, err = s.db.GetPluginConfByApplicationID(2) require.NoError(s.T(), err) assert.Equal(s.T(), "Pabc", pluginConf.Token) pluginConf, err = s.db.GetPluginConfByID(1) require.NoError(s.T(), err) assert.Equal(s.T(), "github.com/gotify/example-plugin", pluginConf.ModulePath) pluginConf, err = s.db.GetPluginConfByToken("Pnotexist") require.NoError(s.T(), err) assert.Nil(s.T(), pluginConf) pluginConf, err = s.db.GetPluginConfByID(12) require.NoError(s.T(), err) assert.Nil(s.T(), pluginConf) pluginConf, err = s.db.GetPluginConfByUserAndPath(1, "not/exist") require.NoError(s.T(), err) assert.Nil(s.T(), pluginConf) pluginConf, err = s.db.GetPluginConfByApplicationID(99) require.NoError(s.T(), err) assert.Nil(s.T(), pluginConf) pluginConfs, err := s.db.GetPluginConfByUser(1) require.NoError(s.T(), err) assert.Len(s.T(), pluginConfs, 1) pluginConfs, err = s.db.GetPluginConfByUser(0) require.NoError(s.T(), err) assert.Len(s.T(), pluginConfs, 0) testConf := `{"test_config_key":"hello"}` plugin.Enabled = false plugin.Config = []byte(testConf) assert.Nil(s.T(), s.db.UpdatePluginConf(&plugin)) pluginConf, err = s.db.GetPluginConfByToken("Pabc") require.NoError(s.T(), err) assert.Equal(s.T(), false, pluginConf.Enabled) assert.Equal(s.T(), testConf, string(pluginConf.Config)) } ================================================ FILE: database/user.go ================================================ package database import ( "github.com/gotify/server/v2/model" "gorm.io/gorm" ) // GetUserByName returns the user by the given name or nil. func (d *GormDatabase) GetUserByName(name string) (*model.User, error) { user := new(model.User) err := d.DB.Where("name = ?", name).Find(user).Error if err == gorm.ErrRecordNotFound { err = nil } if user.Name == name { return user, err } return nil, err } // GetUserByID returns the user by the given id or nil. func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) { user := new(model.User) err := d.DB.Find(user, id).Error if err == gorm.ErrRecordNotFound { err = nil } if user.ID == id { return user, err } return nil, err } // CountUser returns the user count which satisfies the given condition. func (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) { c := int64(-1) handle := d.DB.Model(new(model.User)) if len(condition) == 1 { handle = handle.Where(condition[0]) } else if len(condition) > 1 { handle = handle.Where(condition[0], condition[1:]...) } err := handle.Count(&c).Error return c, err } // GetUsers returns all users. func (d *GormDatabase) GetUsers() ([]*model.User, error) { var users []*model.User err := d.DB.Find(&users).Error return users, err } // DeleteUserByID deletes a user by its id. func (d *GormDatabase) DeleteUserByID(id uint) error { apps, _ := d.GetApplicationsByUser(id) for _, app := range apps { d.DeleteApplicationByID(app.ID) } clients, _ := d.GetClientsByUser(id) for _, client := range clients { d.DeleteClientByID(client.ID) } pluginConfs, _ := d.GetPluginConfByUser(id) for _, conf := range pluginConfs { d.DeletePluginConfByID(conf.ID) } return d.DB.Where("id = ?", id).Delete(&model.User{}).Error } // UpdateUser updates a user. func (d *GormDatabase) UpdateUser(user *model.User) error { return d.DB.Save(user).Error } // CreateUser creates a user. func (d *GormDatabase) CreateUser(user *model.User) error { return d.DB.Create(user).Error } ================================================ FILE: database/user_test.go ================================================ package database import ( "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func (s *DatabaseSuite) TestUser() { user, err := s.db.GetUserByID(55) require.NoError(s.T(), err) assert.Nil(s.T(), user, "not existing user") user, err = s.db.GetUserByName("nicories") require.NoError(s.T(), err) assert.Nil(s.T(), user, "not existing user") jmattheis, err := s.db.GetUserByID(1) require.NoError(s.T(), err) assert.NotNil(s.T(), jmattheis, "on bootup the first user should be automatically created") adminCount, err := s.db.CountUser("admin = ?", true) require.NoError(s.T(), err) assert.Equal(s.T(), int64(1), adminCount, "there is initially one admin") users, err := s.db.GetUsers() require.NoError(s.T(), err) assert.Len(s.T(), users, 1) assert.Contains(s.T(), users, jmattheis) nicories := &model.User{Name: "nicories", Pass: []byte{1, 2, 3, 4}, Admin: false} s.db.CreateUser(nicories) assert.NotEqual(s.T(), 0, nicories.ID, "on create user a new id should be assigned") userCount, err := s.db.CountUser() require.NoError(s.T(), err) assert.Equal(s.T(), int64(2), userCount, "two users should exist") user, err = s.db.GetUserByName("nicories") require.NoError(s.T(), err) assert.Equal(s.T(), nicories, user) users, err = s.db.GetUsers() require.NoError(s.T(), err) assert.Len(s.T(), users, 2) assert.Contains(s.T(), users, jmattheis) assert.Contains(s.T(), users, nicories) nicories.Name = "tom" nicories.Pass = []byte{12} nicories.Admin = true require.NoError(s.T(), s.db.UpdateUser(nicories)) tom, err := s.db.GetUserByID(nicories.ID) require.NoError(s.T(), err) assert.Equal(s.T(), &model.User{ID: nicories.ID, Name: "tom", Pass: []byte{12}, Admin: true}, tom) users, err = s.db.GetUsers() require.NoError(s.T(), err) assert.Len(s.T(), users, 2) adminCount, err = s.db.CountUser(&model.User{Admin: true}) require.NoError(s.T(), err) assert.Equal(s.T(), int64(2), adminCount, "two admins exist") require.NoError(s.T(), s.db.DeleteUserByID(tom.ID)) users, err = s.db.GetUsers() require.NoError(s.T(), err) assert.Len(s.T(), users, 1) assert.Contains(s.T(), users, jmattheis) s.db.DeleteUserByID(jmattheis.ID) users, err = s.db.GetUsers() require.NoError(s.T(), err) assert.Empty(s.T(), users) } func (s *DatabaseSuite) TestUserPlugins() { assert.NoError(s.T(), s.db.CreateUser(&model.User{Name: "geek", ID: 16})) if geekUser, err := s.db.GetUserByName("geek"); assert.NoError(s.T(), err) { s.db.CreatePluginConf(&model.PluginConf{ UserID: geekUser.ID, ModulePath: "github.com/gotify/example-plugin", Token: "P1234", Enabled: true, }) s.db.CreatePluginConf(&model.PluginConf{ UserID: geekUser.ID, ModulePath: "github.com/gotify/example-plugin/v2", Token: "P5678", Enabled: true, }) } if geekUser, err := s.db.GetUserByName("geek"); assert.NoError(s.T(), err) { if pluginConfs, err := s.db.GetPluginConfByUser(geekUser.ID); assert.NoError(s.T(), err) { assert.Len(s.T(), pluginConfs, 2) } } if pluginConf, err := s.db.GetPluginConfByToken("P1234"); assert.NoError(s.T(), err) { assert.Equal(s.T(), "github.com/gotify/example-plugin", pluginConf.ModulePath) } } func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClientsAndPluginConfs() { require.NoError(s.T(), s.db.CreateUser(&model.User{Name: "nicories", ID: 10})) require.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 100, Token: "apptoken", UserID: 10})) require.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100})) require.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 10000, Token: "clienttoken", UserID: 10})) require.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 1000, Token: "plugintoken", UserID: 10})) require.NoError(s.T(), s.db.CreateUser(&model.User{Name: "nicories2", ID: 20})) require.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 200, Token: "apptoken2", UserID: 20})) require.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200})) require.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 20000, Token: "clienttoken2", UserID: 20})) require.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 2000, Token: "plugintoken2", UserID: 20})) require.NoError(s.T(), s.db.DeleteUserByID(10)) app, err := s.db.GetApplicationByToken("apptoken") require.NoError(s.T(), err) assert.Nil(s.T(), app) client, err := s.db.GetClientByToken("clienttoken") require.NoError(s.T(), err) assert.Nil(s.T(), client) clients, err := s.db.GetClientsByUser(10) require.NoError(s.T(), err) assert.Empty(s.T(), clients) apps, err := s.db.GetApplicationsByUser(10) require.NoError(s.T(), err) assert.Empty(s.T(), apps) msgs, err := s.db.GetMessagesByApplication(100) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) msgs, err = s.db.GetMessagesByUser(10) require.NoError(s.T(), err) assert.Empty(s.T(), msgs) pluginConfs, err := s.db.GetPluginConfByUser(10) require.NoError(s.T(), err) assert.Empty(s.T(), pluginConfs) msg, err := s.db.GetMessageByID(1000) require.NoError(s.T(), err) assert.Nil(s.T(), msg) app, err = s.db.GetApplicationByToken("apptoken2") require.NoError(s.T(), err) assert.NotNil(s.T(), app) client, err = s.db.GetClientByToken("clienttoken2") require.NoError(s.T(), err) assert.NotNil(s.T(), client) clients, err = s.db.GetClientsByUser(20) require.NoError(s.T(), err) assert.NotEmpty(s.T(), clients) apps, err = s.db.GetApplicationsByUser(20) require.NoError(s.T(), err) assert.NotEmpty(s.T(), apps) pluginConf, err := s.db.GetPluginConfByUser(20) require.NoError(s.T(), err) assert.NotEmpty(s.T(), pluginConf) msgs, err = s.db.GetMessagesByApplication(200) require.NoError(s.T(), err) assert.NotEmpty(s.T(), msgs) msgs, err = s.db.GetMessagesByUser(20) require.NoError(s.T(), err) assert.NotEmpty(s.T(), msgs) msg, err = s.db.GetMessageByID(2000) require.NoError(s.T(), err) assert.NotNil(s.T(), msg) } ================================================ FILE: docker/Dockerfile ================================================ ARG BUILDKIT_SBOM_SCAN_CONTEXT=true # Suppress warning about invalid variable expansion ARG GO_VERSION=PLEASE_PROVIDE_GO_VERSION ARG DEBIAN=sid-slim # Hack to normalize platform to match the chosed build image # Get the gotify/build image tag ARG __TARGETPLATFORM_DASHES=${TARGETPLATFORM/\//-} ARG __TARGETPLATFORM_GO_NOTATION=${__TARGETPLATFORM_DASHES/arm\/v7/arm-7} # --- JS Builder --- FROM --platform=${BUILDPLATFORM} node:24 AS js-builder ARG BUILD_JS=0 COPY ./Makefile /src/gotify/Makefile COPY ./ui /src/gotify/ui RUN if [ "$BUILD_JS" = "1" ]; then \ (cd /src/gotify/ui && yarn install) && \ (cd /src/gotify && make build-js) \ else \ mkdir -p /src/gotify/ui/build; \ fi # --- Go Builder --- FROM --platform=${BUILDPLATFORM} gotify/build:${GO_VERSION}-${__TARGETPLATFORM_GO_NOTATION} AS builder ARG BUILDPLATFORM ARG TARGETPLATFORM ARG BUILD_JS=0 ARG RUN_TESTS=0 # 0=never, 1=native only ARG LD_FLAGS="" ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -yq --no-install-recommends \ ca-certificates \ git COPY . /src/gotify COPY --from=js-builder /src/gotify/ui/build /ui-build RUN if [ "$BUILD_JS" = "1" ]; then \ cp -r --update /ui-build /src/gotify/ui/build; \ fi RUN cd /src/gotify && \ if [ "$RUN_TESTS" = "1" ] && [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ go test -v ./...; \ fi && \ LD_FLAGS=${LD_FLAGS} make OUTPUT=/target/app/gotify-app _build_within_docker FROM debian:${DEBIAN} LABEL org.opencontainers.image.documentation=https://gotify.net/ LABEL org.opencontainers.image.source=https://github.com/gotify/server # Build-time configurables ARG GOTIFY_SERVER_EXPOSE=80 ENV GOTIFY_SERVER_PORT=$GOTIFY_SERVER_EXPOSE WORKDIR /app RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -yq --no-install-recommends \ tzdata \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD curl --fail http://localhost:$GOTIFY_SERVER_PORT/health || exit 1 EXPOSE $GOTIFY_SERVER_EXPOSE COPY --from=builder /target / ENTRYPOINT ["./gotify-app"] ================================================ FILE: docs/package.go ================================================ // Package docs Gotify REST-API. // // This is the documentation of the Gotify REST-API. // // # Authentication // In Gotify there are two token types: // __clientToken__: a client is something that receives message and manages stuff like creating new tokens or delete messages. (f.ex this token should be used for an android app) // __appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script) // // The token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or // through a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`). // There is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken. // // \--- // // Found a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues) // // Schemes: http, https // Host: localhost // Version: 2.0.2 // License: MIT https://github.com/gotify/server/blob/master/LICENSE // // Consumes: // - application/json // // Produces: // - application/json // // SecurityDefinitions: // appTokenQuery: // type: apiKey // name: token // in: query // clientTokenQuery: // type: apiKey // name: token // in: query // appTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header // clientTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header // appTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header // description: >- // Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`. // clientTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header // description: >- // Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`. // basicAuth: // type: basic // // swagger:meta package docs ================================================ FILE: docs/spec.json ================================================ { "consumes": [ "application/json" ], "produces": [ "application/json" ], "schemes": [ "http", "https" ], "swagger": "2.0", "info": { "description": "This is the documentation of the Gotify REST-API.\n\n# Authentication\nIn Gotify there are two token types:\n__clientToken__: a client is something that receives message and manages stuff like creating new tokens or delete messages. (f.ex this token should be used for an android app)\n__appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script)\n\nThe token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or\nthrough a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`).\nThere is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken.\n\n\\---\n\nFound a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues)", "title": "Gotify REST-API.", "license": { "name": "MIT", "url": "https://github.com/gotify/server/blob/master/LICENSE" }, "version": "2.0.2" }, "host": "localhost", "paths": { "/application": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Return all applications.", "operationId": "getApps", "responses": { "200": { "description": "Ok", "schema": { "type": "array", "items": { "$ref": "#/definitions/Application" } } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Create an application.", "operationId": "createApp", "parameters": [ { "description": "the application to add", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ApplicationParams" } } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Application" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/application/{id}": { "put": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Update an application.", "operationId": "updateApplication", "parameters": [ { "description": "the application to update", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ApplicationParams" } }, { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Application" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Delete an application.", "operationId": "deleteApp", "parameters": [ { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } } }, "/application/{id}/image": { "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "multipart/form-data" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Upload an image for an application.", "operationId": "uploadAppImage", "parameters": [ { "type": "file", "description": "the application image", "name": "file", "in": "formData", "required": true }, { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Application" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Server Error", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "application" ], "summary": "Deletes an image of an application.", "operationId": "removeAppImage", "parameters": [ { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/application/{id}/message": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Return all messages from a specific application.", "operationId": "getAppMessages", "parameters": [ { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true }, { "maximum": 200, "minimum": 1, "type": "integer", "default": 100, "description": "the maximal amount of messages to return", "name": "limit", "in": "query" }, { "minimum": 0, "type": "integer", "format": "int64", "description": "return all messages with an ID less than this value", "name": "since", "in": "query" } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/PagedMessages" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Delete all messages from a specific application.", "operationId": "deleteAppMessages", "parameters": [ { "type": "integer", "format": "int64", "description": "the application id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } } }, "/client": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "client" ], "summary": "Return all clients.", "operationId": "getClients", "responses": { "200": { "description": "Ok", "schema": { "type": "array", "items": { "$ref": "#/definitions/Client" } } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "client" ], "summary": "Create a client.", "operationId": "createClient", "parameters": [ { "description": "the client to add", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ClientParams" } } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Client" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/client/{id}": { "put": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "client" ], "summary": "Update a client.", "operationId": "updateClient", "parameters": [ { "description": "the client to update", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ClientParams" } }, { "type": "integer", "format": "int64", "description": "the client id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Client" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "client" ], "summary": "Delete a client.", "operationId": "deleteClient", "parameters": [ { "type": "integer", "format": "int64", "description": "the client id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } } }, "/current/user": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Return the current user.", "operationId": "currentUser", "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/User" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/current/user/password": { "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Update the password of the current user.", "operationId": "updateCurrentUser", "parameters": [ { "description": "the user", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/UserPass" } } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/health": { "get": { "produces": [ "application/json" ], "tags": [ "health" ], "summary": "Get health information.", "operationId": "getHealth", "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Health" } }, "500": { "description": "Ok", "schema": { "$ref": "#/definitions/Health" } } } } }, "/message": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Return all messages.", "operationId": "getMessages", "parameters": [ { "maximum": 200, "minimum": 1, "type": "integer", "default": 100, "description": "the maximal amount of messages to return", "name": "limit", "in": "query" }, { "minimum": 0, "type": "integer", "format": "int64", "description": "return all messages with an ID less than this value", "name": "since", "in": "query" } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/PagedMessages" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "appTokenAuthorizationHeader": [] }, { "appTokenHeader": [] }, { "appTokenQuery": [] } ], "description": "__NOTE__: This API ONLY accepts an application token as authentication.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Create a message.", "operationId": "createMessage", "parameters": [ { "description": "the message to add", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Message" } } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Message" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Delete all messages.", "operationId": "deleteMessages", "responses": { "200": { "description": "Ok" }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/message/{id}": { "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Deletes a message with an id.", "operationId": "deleteMessage", "parameters": [ { "type": "integer", "format": "int64", "description": "the message id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } } }, "/plugin": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "plugin" ], "summary": "Return all plugins.", "operationId": "getPlugins", "responses": { "200": { "description": "Ok", "schema": { "type": "array", "items": { "$ref": "#/definitions/PluginConf" } } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/plugin/{id}/config": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/x-yaml" ], "tags": [ "plugin" ], "summary": "Get YAML configuration for Configurer plugin.", "operationId": "getPluginConfig", "parameters": [ { "type": "integer", "format": "int64", "description": "the plugin id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "description": "plugin configuration", "type": "object" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/x-yaml" ], "produces": [ "application/json" ], "tags": [ "plugin" ], "summary": "Update YAML configuration for Configurer plugin.", "operationId": "updatePluginConfig", "parameters": [ { "type": "integer", "format": "int64", "description": "the plugin id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/plugin/{id}/disable": { "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "plugin" ], "summary": "Disable a plugin.", "operationId": "disablePlugin", "parameters": [ { "type": "integer", "format": "int64", "description": "the plugin id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/plugin/{id}/display": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "plugin" ], "summary": "Get display info for a Displayer plugin.", "operationId": "getPluginDisplay", "parameters": [ { "type": "integer", "format": "int64", "description": "the plugin id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "type": "string" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/plugin/{id}/enable": { "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "plugin" ], "summary": "Enable a plugin.", "operationId": "enablePlugin", "parameters": [ { "type": "integer", "format": "int64", "description": "the plugin id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Internal Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/stream": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "message" ], "summary": "Websocket, return newly created messages.", "operationId": "streamMessages", "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/Message" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "500": { "description": "Server Error", "schema": { "$ref": "#/definitions/Error" } } } } }, "/user": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Return all users.", "operationId": "getUsers", "responses": { "200": { "description": "Ok", "schema": { "type": "array", "items": { "$ref": "#/definitions/User" } } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Create a user.", "operationId": "createUser", "parameters": [ { "description": "the user to add", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/CreateUserExternal" } } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/User" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } } } } }, "/user/{id}": { "get": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Get a user.", "operationId": "getUser", "parameters": [ { "type": "integer", "format": "int64", "description": "the user id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/User" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } }, "post": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Update a user.", "operationId": "updateUser", "parameters": [ { "type": "integer", "format": "int64", "description": "the user id", "name": "id", "in": "path", "required": true }, { "description": "the updated user", "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/UpdateUserExternal" } } ], "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/User" } }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } }, "delete": { "security": [ { "clientTokenAuthorizationHeader": [] }, { "clientTokenHeader": [] }, { "clientTokenQuery": [] }, { "basicAuth": [] } ], "produces": [ "application/json" ], "tags": [ "user" ], "summary": "Deletes a user.", "operationId": "deleteUser", "parameters": [ { "type": "integer", "format": "int64", "description": "the user id", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "Ok" }, "400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/Error" } }, "401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/Error" } }, "403": { "description": "Forbidden", "schema": { "$ref": "#/definitions/Error" } }, "404": { "description": "Not Found", "schema": { "$ref": "#/definitions/Error" } } } } }, "/version": { "get": { "produces": [ "application/json" ], "tags": [ "version" ], "summary": "Get version information.", "operationId": "getVersion", "responses": { "200": { "description": "Ok", "schema": { "$ref": "#/definitions/VersionInfo" } } } } } }, "definitions": { "Application": { "description": "The Application holds information about an app which can send notifications.", "type": "object", "title": "Application Model", "required": [ "id", "token", "name", "description", "internal", "image", "sortKey" ], "properties": { "defaultPriority": { "description": "The default priority of messages sent by this application. Defaults to 0.", "type": "integer", "format": "int64", "x-go-name": "DefaultPriority", "example": 4 }, "description": { "description": "The description of the application.", "type": "string", "x-go-name": "Description", "example": "Backup server for the interwebs" }, "id": { "description": "The application id.", "type": "integer", "format": "int64", "x-go-name": "ID", "readOnly": true, "example": 5 }, "image": { "description": "The image of the application.", "type": "string", "x-go-name": "Image", "readOnly": true, "example": "image/image.jpeg" }, "internal": { "description": "Whether the application is an internal application. Internal applications should not be deleted.", "type": "boolean", "x-go-name": "Internal", "readOnly": true, "example": false }, "lastUsed": { "description": "The last time the application token was used.", "type": "string", "format": "date-time", "x-go-name": "LastUsed", "readOnly": true, "example": "2019-01-01T00:00:00Z" }, "name": { "description": "The application name. This is how the application should be displayed to the user.", "type": "string", "x-go-name": "Name", "example": "Backup Server" }, "sortKey": { "description": "The sort key of this application. Uses fractional indexing.", "type": "string", "x-go-name": "SortKey", "example": "a1" }, "token": { "description": "The application token. Can be used as `appToken`. See Authentication.", "type": "string", "x-go-name": "Token", "readOnly": true, "example": "AWH0wZ5r0Mbac.r" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "ApplicationParams": { "description": "Params allowed to create or update Applications.", "type": "object", "title": "Application Params Model", "required": [ "name" ], "properties": { "defaultPriority": { "description": "The default priority of messages sent by this application. Defaults to 0.", "type": "integer", "format": "int64", "x-go-name": "DefaultPriority", "example": 5 }, "description": { "description": "The description of the application.", "type": "string", "x-go-name": "Description", "example": "Backup server for the interwebs" }, "name": { "description": "The application name. This is how the application should be displayed to the user.", "type": "string", "x-go-name": "Name", "example": "Backup Server" }, "sortKey": { "description": "The sortKey for the application. Uses fractional indexing.", "type": "string", "x-go-name": "SortKey", "example": "a1" } }, "x-go-package": "github.com/gotify/server/v2/api" }, "Client": { "description": "The Client holds information about a device which can receive notifications (and other stuff).", "type": "object", "title": "Client Model", "required": [ "id", "token", "name" ], "properties": { "id": { "description": "The client id.", "type": "integer", "format": "int64", "x-go-name": "ID", "readOnly": true, "example": 5 }, "lastUsed": { "description": "The last time the client token was used.", "type": "string", "format": "date-time", "x-go-name": "LastUsed", "readOnly": true, "example": "2019-01-01T00:00:00Z" }, "name": { "description": "The client name. This is how the client should be displayed to the user.", "type": "string", "x-go-name": "Name", "example": "Android Phone" }, "token": { "description": "The client token. Can be used as `clientToken`. See Authentication.", "type": "string", "x-go-name": "Token", "readOnly": true, "example": "CWH0wZ5r0Mbac.r" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "ClientParams": { "description": "Params allowed to create or update Clients.", "type": "object", "title": "Client Params Model", "required": [ "name" ], "properties": { "name": { "description": "The client name", "type": "string", "x-go-name": "Name", "example": "My Client" } }, "x-go-package": "github.com/gotify/server/v2/api" }, "CreateUserExternal": { "description": "Used for user creation.", "type": "object", "title": "CreateUserExternal Model", "required": [ "name", "admin", "pass" ], "properties": { "admin": { "description": "If the user is an administrator.", "type": "boolean", "x-go-name": "Admin", "example": true }, "name": { "description": "The user name. For login.", "type": "string", "x-go-name": "Name", "example": "unicorn" }, "pass": { "description": "The user password. For login.", "type": "string", "x-go-name": "Pass", "example": "nrocinu" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "Error": { "description": "The Error contains error relevant information.", "type": "object", "title": "Error Model", "required": [ "error", "errorCode", "errorDescription" ], "properties": { "error": { "description": "The general error message", "type": "string", "x-go-name": "Error", "example": "Unauthorized" }, "errorCode": { "description": "The http error code.", "type": "integer", "format": "int64", "x-go-name": "ErrorCode", "example": 401 }, "errorDescription": { "description": "The http error code.", "type": "string", "x-go-name": "ErrorDescription", "example": "you need to provide a valid access token or user credentials to access this api" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "Health": { "description": "Health represents how healthy the application is.", "type": "object", "title": "Health Model", "required": [ "health", "database" ], "properties": { "database": { "description": "The health of the database connection.", "type": "string", "x-go-name": "Database", "example": "green" }, "health": { "description": "The health of the overall application.", "type": "string", "x-go-name": "Health", "example": "green" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "Message": { "description": "The MessageExternal holds information about a message which was sent by an Application.", "type": "object", "title": "MessageExternal Model", "required": [ "id", "appid", "message", "date" ], "properties": { "appid": { "description": "The application id that send this message.", "type": "integer", "format": "int64", "x-go-name": "ApplicationID", "readOnly": true, "example": 5 }, "date": { "description": "The date the message was created.", "type": "string", "format": "date-time", "x-go-name": "Date", "readOnly": true, "example": "2018-02-27T19:36:10.5045044+01:00" }, "extras": { "description": "The extra data sent along the message.\n\nThe extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type.\n\nThe keys should be in the following format: \u0026lt;top-namespace\u0026gt;::[\u0026lt;sub-namespace\u0026gt;::]\u0026lt;action\u0026gt;\n\nThese namespaces are reserved and might be used in the official clients: gotify android ios web server client. Do not use them for other purposes.", "type": "object", "additionalProperties": {}, "x-go-name": "Extras", "example": { "home::appliances::lighting::on": { "brightness": 15 }, "home::appliances::thermostat::change_temperature": { "temperature": 23 } } }, "id": { "description": "The message id.", "type": "integer", "format": "int64", "x-go-name": "ID", "readOnly": true, "example": 25 }, "message": { "description": "The message. Markdown (excluding html) is allowed.", "type": "string", "x-go-name": "Message", "example": "**Backup** was successfully finished." }, "priority": { "description": "The priority of the message. If unset, then the default priority of the\napplication will be used.", "type": "integer", "format": "int64", "x-go-name": "Priority", "example": 2 }, "title": { "description": "The title of the message.", "type": "string", "x-go-name": "Title", "example": "Backup" } }, "x-go-name": "MessageExternal", "x-go-package": "github.com/gotify/server/v2/model" }, "PagedMessages": { "description": "Wrapper for the paging and the messages.", "type": "object", "title": "PagedMessages Model", "required": [ "paging", "messages" ], "properties": { "messages": { "description": "The messages.", "type": "array", "items": { "$ref": "#/definitions/Message" }, "x-go-name": "Messages", "readOnly": true }, "paging": { "$ref": "#/definitions/Paging" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "Paging": { "description": "The Paging holds information about the limit and making requests to the next page.", "type": "object", "title": "Paging Model", "required": [ "size", "since", "limit" ], "properties": { "limit": { "description": "The limit of the messages for the current request.", "type": "integer", "format": "int64", "maximum": 200, "minimum": 1, "x-go-name": "Limit", "readOnly": true, "example": 123 }, "next": { "description": "The request url for the next page. Empty/Null when no next page is available.", "type": "string", "x-go-name": "Next", "readOnly": true, "example": "http://example.com/message?limit=50\u0026since=123456" }, "since": { "description": "The ID of the last message returned in the current request. Use this as alternative to the next link.", "type": "integer", "format": "int64", "minimum": 0, "x-go-name": "Since", "readOnly": true, "example": 5 }, "size": { "description": "The amount of messages that got returned in the current request.", "type": "integer", "format": "int64", "x-go-name": "Size", "readOnly": true, "example": 5 } }, "x-go-package": "github.com/gotify/server/v2/model" }, "PluginConf": { "description": "Holds information about a plugin instance for one user.", "type": "object", "title": "PluginConfExternal Model", "required": [ "id", "name", "token", "modulePath", "enabled", "capabilities" ], "properties": { "author": { "description": "The author of the plugin.", "type": "string", "x-go-name": "Author", "readOnly": true, "example": "jmattheis" }, "capabilities": { "description": "Capabilities the plugin provides", "type": "array", "items": { "type": "string" }, "x-go-name": "Capabilities", "example": [ "webhook", "display" ] }, "enabled": { "description": "Whether the plugin instance is enabled.", "type": "boolean", "x-go-name": "Enabled", "example": true }, "id": { "description": "The plugin id.", "type": "integer", "format": "int64", "x-go-name": "ID", "readOnly": true, "example": 25 }, "license": { "description": "The license of the plugin.", "type": "string", "x-go-name": "License", "readOnly": true, "example": "MIT" }, "modulePath": { "description": "The module path of the plugin.", "type": "string", "x-go-name": "ModulePath", "readOnly": true, "example": "github.com/gotify/server/plugin/example/echo" }, "name": { "description": "The plugin name.", "type": "string", "x-go-name": "Name", "readOnly": true, "example": "RSS poller" }, "token": { "description": "The user name. For login.", "type": "string", "x-go-name": "Token", "example": "P1234" }, "website": { "description": "The website of the plugin.", "type": "string", "x-go-name": "Website", "readOnly": true, "example": "gotify.net" } }, "x-go-name": "PluginConfExternal", "x-go-package": "github.com/gotify/server/v2/model" }, "UpdateUserExternal": { "description": "Used for updating a user.", "type": "object", "title": "UpdateUserExternal Model", "required": [ "name", "admin" ], "properties": { "admin": { "description": "If the user is an administrator.", "type": "boolean", "x-go-name": "Admin", "example": true }, "name": { "description": "The user name. For login.", "type": "string", "x-go-name": "Name", "example": "unicorn" }, "pass": { "description": "The user password. For login. Empty for using old password", "type": "string", "x-go-name": "Pass", "example": "nrocinu" } }, "x-go-package": "github.com/gotify/server/v2/model" }, "User": { "description": "The User holds information about permission and other stuff.", "type": "object", "title": "UserExternal Model", "required": [ "id", "name", "admin" ], "properties": { "admin": { "description": "If the user is an administrator.", "type": "boolean", "x-go-name": "Admin", "example": true }, "id": { "description": "The user id.", "type": "integer", "format": "int64", "x-go-name": "ID", "readOnly": true, "example": 25 }, "name": { "description": "The user name. For login.", "type": "string", "x-go-name": "Name", "example": "unicorn" } }, "x-go-name": "UserExternal", "x-go-package": "github.com/gotify/server/v2/model" }, "UserPass": { "description": "The Password for updating the user.", "type": "object", "title": "UserExternalPass Model", "required": [ "pass" ], "properties": { "pass": { "description": "The user password. For login.", "type": "string", "x-go-name": "Pass", "example": "nrocinu" } }, "x-go-name": "UserExternalPass", "x-go-package": "github.com/gotify/server/v2/model" }, "VersionInfo": { "description": "VersionInfo Model", "type": "object", "required": [ "version", "commit", "buildDate" ], "properties": { "buildDate": { "description": "The date on which this binary was built.", "type": "string", "x-go-name": "BuildDate", "example": "2018-02-27T19:36:10.5045044+01:00" }, "commit": { "description": "The git commit hash on which this binary was built.", "type": "string", "x-go-name": "Commit", "example": "ae9512b6b6feea56a110d59a3353ea3b9c293864" }, "version": { "description": "The current version.", "type": "string", "x-go-name": "Version", "example": "5.2.6" } }, "x-go-package": "github.com/gotify/server/v2/model" } }, "securityDefinitions": { "appTokenAuthorizationHeader": { "description": "Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`.", "type": "apiKey", "name": "Authorization", "in": "header" }, "appTokenHeader": { "type": "apiKey", "name": "X-Gotify-Key", "in": "header" }, "appTokenQuery": { "type": "apiKey", "name": "token", "in": "query" }, "basicAuth": { "type": "basic" }, "clientTokenAuthorizationHeader": { "description": "Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`.", "type": "apiKey", "name": "Authorization", "in": "header" }, "clientTokenHeader": { "type": "apiKey", "name": "X-Gotify-Key", "in": "header" }, "clientTokenQuery": { "type": "apiKey", "name": "token", "in": "query" } } } ================================================ FILE: docs/swagger.go ================================================ package docs import ( _ "embed" "strings" "github.com/gin-gonic/gin" "github.com/gotify/location" ) //go:embed spec.json var spec string // Serve serves the documentation. func Serve(ctx *gin.Context) { base := location.Get(ctx).Host if basePathFromQuery := ctx.Query("base"); basePathFromQuery != "" { base = basePathFromQuery } ctx.Writer.WriteString(getSwaggerJSON(base)) } func getSwaggerJSON(base string) string { return strings.Replace(spec, "localhost", base, 1) } ================================================ FILE: docs/swagger_test.go ================================================ package docs import ( "net/http/httptest" "net/url" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/stretchr/testify/assert" ) func TestServe(t *testing.T) { mode.Set(mode.TestDev) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) withURL(ctx, "http", "example.com") ctx.Request = httptest.NewRequest("GET", "/swagger?base="+url.QueryEscape("127.0.0.1/proxy/"), nil) Serve(ctx) content := recorder.Body.String() assert.NotEmpty(t, content) assert.Contains(t, content, "127.0.0.1/proxy/") } func withURL(ctx *gin.Context, scheme, host string) { ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) } ================================================ FILE: docs/ui.go ================================================ package docs import "github.com/gin-gonic/gin" var ui = ` Swagger UI
` // UI serves the swagger ui. func UI(ctx *gin.Context) { ctx.Writer.WriteString(ui) } ================================================ FILE: docs/ui_test.go ================================================ package docs import ( "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/stretchr/testify/assert" ) func TestUI(t *testing.T) { mode.Set(mode.TestDev) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) withURL(ctx, "http", "example.com") ctx.Request = httptest.NewRequest("GET", "/swagger", nil) UI(ctx) content := recorder.Body.String() assert.NotEmpty(t, content) } ================================================ FILE: error/handler.go ================================================ package error import ( "fmt" "net/http" "strings" "unicode" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/gotify/server/v2/model" ) // Handler creates a gin middleware for handling errors. func Handler() gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) > 0 { for _, e := range c.Errors { switch e.Type { case gin.ErrorTypeBind: errs, ok := e.Err.(validator.ValidationErrors) if !ok { writeError(c, e.Error()) return } var stringErrors []string for _, err := range errs { stringErrors = append(stringErrors, validationErrorToText(err)) } writeError(c, strings.Join(stringErrors, "; ")) default: writeError(c, e.Err.Error()) } } } } } func validationErrorToText(e validator.FieldError) string { runes := []rune(e.Field()) runes[0] = unicode.ToLower(runes[0]) fieldName := string(runes) switch e.Tag() { case "required": return fmt.Sprintf("Field '%s' is required", fieldName) case "max": return fmt.Sprintf("Field '%s' must be less or equal to %s", fieldName, e.Param()) case "min": return fmt.Sprintf("Field '%s' must be more or equal to %s", fieldName, e.Param()) } return fmt.Sprintf("Field '%s' is not valid", fieldName) } func writeError(ctx *gin.Context, errString string) { status := http.StatusBadRequest if ctx.Writer.Status() != http.StatusOK { status = ctx.Writer.Status() } ctx.JSON(status, &model.Error{Error: http.StatusText(status), ErrorCode: status, ErrorDescription: errString}) } ================================================ FILE: error/handler_test.go ================================================ package error import ( "encoding/json" "errors" "io" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) func TestDefaultErrorInternal(t *testing.T) { mode.Set(mode.TestDev) rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.AbortWithError(500, errors.New("something went wrong")) Handler()(ctx) assertJSONResponse(t, rec, 500, `{"errorCode":500, "errorDescription":"something went wrong", "error":"Internal Server Error"}`) } func TestBindingErrorDefault(t *testing.T) { mode.Set(mode.TestDev) rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.AbortWithError(400, errors.New("you need todo something")).SetType(gin.ErrorTypeBind) Handler()(ctx) assertJSONResponse(t, rec, 400, `{"errorCode":400, "errorDescription":"you need todo something", "error":"Bad Request"}`) } func TestDefaultErrorBadRequest(t *testing.T) { mode.Set(mode.TestDev) rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.AbortWithError(400, errors.New("you need todo something")) Handler()(ctx) assertJSONResponse(t, rec, 400, `{"errorCode":400, "errorDescription":"you need todo something", "error":"Bad Request"}`) } type testValidate struct { Username string `json:"username" binding:"required"` Mail string `json:"mail" binding:"email"` Age int `json:"age" binding:"max=100"` Limit int `json:"limit" binding:"min=50"` } func TestValidationError(t *testing.T) { mode.Set(mode.TestDev) rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.Request = httptest.NewRequest("GET", "/uri", nil) assert.Error(t, ctx.Bind(&testValidate{Age: 150, Limit: 20})) Handler()(ctx) err := new(model.Error) json.NewDecoder(rec.Body).Decode(err) assert.Equal(t, 400, rec.Code) assert.Equal(t, "Bad Request", err.Error) assert.Equal(t, 400, err.ErrorCode) assert.Contains(t, err.ErrorDescription, "Field 'username' is required") assert.Contains(t, err.ErrorDescription, "Field 'mail' is not valid") assert.Contains(t, err.ErrorDescription, "Field 'age' must be less or equal to 100") assert.Contains(t, err.ErrorDescription, "Field 'limit' must be more or equal to 50") } func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, code int, json string) { bytes, _ := io.ReadAll(rec.Body) assert.Equal(t, code, rec.Code) assert.JSONEq(t, json, string(bytes)) } ================================================ FILE: error/notfound.go ================================================ package error import ( "net/http" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" ) // NotFound creates a gin middleware for handling page not found. func NotFound() gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusNotFound, &model.Error{ Error: http.StatusText(http.StatusNotFound), ErrorCode: http.StatusNotFound, ErrorDescription: "page not found", }) } } ================================================ FILE: error/notfound_test.go ================================================ package error import ( "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" ) func TestNotFound(t *testing.T) { mode.Set(mode.TestDev) rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) NotFound()(ctx) assertJSONResponse(t, rec, 404, `{"errorCode":404, "errorDescription":"page not found", "error":"Not Found"}`) } ================================================ FILE: fracdex/fracdex.go ================================================ // Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex package fracdex import ( "errors" "fmt" "math" "strings" ) const ( base62Digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" smallestInt = "A00000000000000000000000000" zero = "a0" ) // KeyBetween returns a key that sorts lexicographically between a and b. // Either a or b can be empty strings. If a is empty it indicates smallest key, // If b is empty it indicates largest key. // b must be empty string or > a. func KeyBetween(a, b string) (string, error) { if a != "" { err := validateOrderKey(a) if err != nil { return "", err } } if b != "" { err := validateOrderKey(b) if err != nil { return "", err } } if a != "" && b != "" && a >= b { return "", fmt.Errorf("%s >= %s", a, b) } if a == "" { if b == "" { return zero, nil } ib, err := getIntPart(b) if err != nil { return "", err } fb := b[len(ib):] if ib == smallestInt { return ib + midpoint("", fb), nil } if ib < b { return ib, nil } res, err := decrementInt(ib) if err != nil { return "", err } if res == "" { return "", errors.New("range underflow") } return res, nil } if b == "" { ia, err := getIntPart(a) if err != nil { return "", err } fa := a[len(ia):] i, err := incrementInt(ia) if err != nil { return "", err } if i == "" { return ia + midpoint(fa, ""), nil } return i, nil } ia, err := getIntPart(a) if err != nil { return "", err } fa := a[len(ia):] ib, err := getIntPart(b) if err != nil { return "", err } fb := b[len(ib):] if ia == ib { return ia + midpoint(fa, fb), nil } i, err := incrementInt(ia) if err != nil { return "", err } if i == "" { return "", errors.New("range overflow") } if i < b { return i, nil } return ia + midpoint(fa, ""), nil } // `a < b` lexicographically if `b` is non-empty. // a == "" means first possible string. // b == "" means last possible string. func midpoint(a, b string) string { if b != "" { // remove longest common prefix. pad `a` with 0s as we // go. note that we don't need to pad `b`, because it can't // end before `a` while traversing the common prefix. i := 0 for ; i < len(b); i++ { c := byte('0') if len(a) > i { c = a[i] } if c != b[i] { break } } if i > 0 { if i > len(a) { return b[0:i] + midpoint("", b[i:]) } return b[0:i] + midpoint(a[i:], b[i:]) } } // first digits (or lack of digit) are different digitA := 0 if a != "" { digitA = strings.Index(base62Digits, string(a[0])) } digitB := len(base62Digits) if b != "" { digitB = strings.Index(base62Digits, string(b[0])) } if digitB-digitA > 1 { midDigit := int(math.Round(0.5 * float64(digitA+digitB))) return string(base62Digits[midDigit]) } // first digits are consecutive if len(b) > 1 { return b[0:1] } // `b` is empty or has length 1 (a single digit). // the first digit of `a` is the previous digit to `b`, // or 9 if `b` is null. // given, for example, midpoint('49', '5'), return // '4' + midpoint('9', null), which will become // '4' + '9' + midpoint('', null), which is '495' sa := "" if len(a) > 0 { sa = a[1:] } return string(base62Digits[digitA]) + midpoint(sa, "") } func validateInt(i string) error { exp, err := getIntLen(i[0]) if err != nil { return err } if len(i) != exp { return fmt.Errorf("invalid integer part of order key: %s", i) } return nil } func getIntLen(head byte) (int, error) { if head >= 'a' && head <= 'z' { return int(head - 'a' + 2), nil } else if head >= 'A' && head <= 'Z' { return int('Z' - head + 2), nil } else { return 0, fmt.Errorf("invalid order key head: %s", string(head)) } } func getIntPart(key string) (string, error) { intPartLen, err := getIntLen(key[0]) if err != nil { return "", err } if intPartLen > len(key) { return "", fmt.Errorf("invalid order key: %s", key) } return key[0:intPartLen], nil } func validateOrderKey(key string) error { if key == smallestInt { return fmt.Errorf("invalid order key: %s", key) } // getIntPart will return error if the first character is bad, // or the key is too short. we'd call it to check these things // even if we didn't need the result i, err := getIntPart(key) if err != nil { return err } f := key[len(i):] if strings.HasSuffix(f, "0") { return fmt.Errorf("invalid order key: %s", key) } return nil } // returns error if x is invalid, or if range is exceeded. func incrementInt(x string) (string, error) { err := validateInt(x) if err != nil { return "", err } digs := strings.Split(x, "") head := digs[0] digs = digs[1:] carry := true for i := len(digs) - 1; carry && i >= 0; i-- { d := strings.Index(base62Digits, digs[i]) + 1 if d == len(base62Digits) { digs[i] = "0" } else { digs[i] = string(base62Digits[d]) carry = false } } if carry { if head == "Z" { return "a0", nil } if head == "z" { return "", nil } h := string(head[0] + 1) if h > "a" { digs = append(digs, "0") } else { digs = digs[1:] } return h + strings.Join(digs, ""), nil } return head + strings.Join(digs, ""), nil } func decrementInt(x string) (string, error) { err := validateInt(x) if err != nil { return "", err } digs := strings.Split(x, "") head := digs[0] digs = digs[1:] borrow := true for i := len(digs) - 1; borrow && i >= 0; i-- { d := strings.Index(base62Digits, digs[i]) - 1 if d == -1 { digs[i] = string(base62Digits[len(base62Digits)-1]) } else { digs[i] = string(base62Digits[d]) borrow = false } } if borrow { if head == "a" { return "Z" + string(base62Digits[len(base62Digits)-1]), nil } if head == "A" { return "", nil } h := head[0] - 1 if h < 'Z' { digs = append(digs, string(base62Digits[len(base62Digits)-1])) } else { digs = digs[1:] } return string(h) + strings.Join(digs, ""), nil } return head + strings.Join(digs, ""), nil } // Float64Approx converts a key as generated by KeyBetween() to a float64. // Because the range of keys is far larger than float64 can represent // accurately, this is necessarily approximate. But for many use cases it should // be, as they say, close enough for jazz. func Float64Approx(key string) (float64, error) { if key == "" { return 0.0, errors.New("invalid order key") } err := validateOrderKey(key) if err != nil { return 0.0, err } ip, err := getIntPart(key) if err != nil { return 0.0, err } digs := strings.Split(ip, "") head := digs[0] digs = digs[1:] rv := float64(0) for i := 0; i < len(digs); i++ { d := digs[len(digs)-i-1] p := strings.Index(base62Digits, d) if p == -1 { return 0.0, fmt.Errorf("invalid order key: %s", key) } rv += math.Pow(float64(len(base62Digits)), float64(i)) * float64(p) } fp := key[len(ip):] for i, d := range fp { p := strings.Index(base62Digits, string(d)) if p == -1 { return 0.0, fmt.Errorf("invalid key: %s", key) } rv += (float64(p) / math.Pow(float64(len(base62Digits)), float64(i+1))) } if head < "a" { rv *= -1 } return rv, nil } // NKeysBetween returns n keys between a and b that sorts lexicographically. // Either a or b can be empty strings. If a is empty it indicates smallest key, // If b is empty it indicates largest key. // b must be empty string or > a. func NKeysBetween(a, b string, n uint) ([]string, error) { if n == 0 { return []string{}, nil } if n == 1 { c, err := KeyBetween(a, b) if err != nil { return nil, err } return []string{c}, nil } if b == "" { c, err := KeyBetween(a, b) if err != nil { return nil, err } result := make([]string, 0, n) result = append(result, c) for i := 0; i < int(n)-1; i++ { c, err = KeyBetween(c, b) if err != nil { return nil, err } result = append(result, c) } return result, nil } if a == "" { c, err := KeyBetween(a, b) if err != nil { return nil, err } result := make([]string, 0, n) result = append(result, c) for i := 0; i < int(n)-1; i++ { c, err = KeyBetween(a, c) if err != nil { return nil, err } result = append(result, c) } reverse(result) return result, nil } mid := n / 2 c, err := KeyBetween(a, b) if err != nil { return nil, err } result := make([]string, 0, n) { r, err := NKeysBetween(a, c, mid) if err != nil { return nil, err } result = append(result, r...) } result = append(result, c) { r, err := NKeysBetween(c, b, n-mid-1) if err != nil { return nil, err } result = append(result, r...) } return result, nil } func reverse(values []string) { for i := 0; i < len(values)/2; i++ { j := len(values) - i - 1 values[i], values[j] = values[j], values[i] } } ================================================ FILE: fracdex/fracdex_test.go ================================================ // Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex package fracdex import ( "math" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestKeys(t *testing.T) { assert := assert.New(t) test := func(a, b, exp string) { act, err := KeyBetween(a, b) if err != nil { assert.Equal("", act) assert.Equal(exp, err.Error()) } else { assert.Nil(err) assert.Equal(exp, act) } } test("", "", "a0") test("", "a0", "Zz") test("", "Zz", "Zy") test("a0", "", "a1") test("a1", "", "a2") test("a0", "a1", "a0V") test("a1", "a2", "a1V") test("a0V", "a1", "a0l") test("Zz", "a0", "ZzV") test("Zz", "a1", "a0") test("", "Y00", "Xzzz") test("bzz", "", "c000") test("a0", "a0V", "a0G") test("a0", "a0G", "a08") test("b125", "b129", "b127") test("a0", "a1V", "a1") test("Zz", "a01", "a0") test("", "a0V", "a0") test("", "b999", "b99") test("aV", "aV0V", "aV0G") test( "", "A00000000000000000000000000", "invalid order key: A00000000000000000000000000", ) test("", "A000000000000000000000000001", "A000000000000000000000000000V") test("zzzzzzzzzzzzzzzzzzzzzzzzzzy", "", "zzzzzzzzzzzzzzzzzzzzzzzzzzz") test("zzzzzzzzzzzzzzzzzzzzzzzzzzz", "", "zzzzzzzzzzzzzzzzzzzzzzzzzzzV") test("a00", "", "invalid order key: a00") test("a00", "a1", "invalid order key: a00") test("0", "1", "invalid order key head: 0") test("a1", "a0", "a1 >= a0") } func TestNKeys(t *testing.T) { assert := assert.New(t) test := func(a, b string, n uint, exp string) { actSlice, err := NKeysBetween(a, b, n) act := strings.Join(actSlice, " ") if err != nil { assert.Equal("", act) assert.Equal(exp, err.Error()) } else { assert.Nil(err) assert.Equal(exp, act) } } test("", "", 5, "a0 a1 a2 a3 a4") test("a4", "", 10, "a5 a6 a7 a8 a9 aA aB aC aD aE") test("", "a0", 5, "Zv Zw Zx Zy Zz") test( "a0", "a2", 20, "a04 a08 a0G a0K a0O a0V a0Z a0d a0l a0t a1 a14 a18 a1G a1O a1V a1Z a1d a1l a1t", ) } func TestToFloat64Approx(t *testing.T) { assert := assert.New(t) test := func(key string, exp float64, expErr string) { act, err := Float64Approx(key) if expErr != "" { assert.Equal(0.0, act) assert.Equal(expErr, err.Error()) } else { assert.Equal(exp, act) assert.NoError(err) } } test("a0", 0.0, "") test("a1", 1.0, "") test("az", 61.0, "") test("b10", 62.0, "") test("z20000000000000000000000000", math.Pow(62.0, 25.0)*2.0, "") test("Z1", -1.0, "") test("Zz", -61.0, "") test("Y10", -62.0, "") test("A20000000000000000000000000", math.Pow(62.0, 25.0)*-2.0, "") test("a0V", 0.5, "") test("a00V", 31.0/math.Pow(62.0, 2.0), "") test("aVV", 31.5, "") test("ZVV", -31.5, "") test("", 0.0, "invalid order key") test("!", 0.0, "invalid order key head: !") test("a400", 0.0, "invalid order key: a400") test("a!", 0.0, "invalid order key: a!") } ================================================ FILE: go.mod ================================================ module github.com/gotify/server/v2 require ( github.com/fortytw2/leaktest v1.3.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/gzip v1.2.5 github.com/gin-gonic/gin v1.12.0 github.com/go-playground/validator/v10 v10.30.1 github.com/gorilla/websocket v1.5.3 github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 github.com/gotify/plugin-api v1.0.0 github.com/h2non/filetype v1.1.3 github.com/jinzhu/configor v1.2.2 github.com/mattn/go-isatty v0.0.20 github.com/robfig/cron v1.2.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) go 1.25.0 toolchain go1.26.0 ================================================ FILE: go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437/go.mod h1:5JgfyQg+71Ck3uXX/4FBHc4YxdKZ9shU8gs2AUj7Nj0= github.com/gotify/plugin-api v1.0.0 h1:kab40p2TEPLzjmcafOc7JOz75aTsYQyS2PXtElH8xmI= github.com/gotify/plugin-api v1.0.0/go.mod h1:xZfEyqVK/Zvu3RwA/CtpuiwFmzFDxifrrqMaH9BHnyU= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= ================================================ FILE: mode/mode.go ================================================ package mode import "github.com/gin-gonic/gin" const ( // Dev for development mode. Dev = "dev" // Prod for production mode. Prod = "prod" // TestDev used for tests. TestDev = "testdev" ) var mode = Dev // Set sets the new mode. func Set(newMode string) { mode = newMode updateGinMode() } // Get returns the current mode. func Get() string { return mode } // IsDev returns true if the current mode is dev mode. func IsDev() bool { return Get() == Dev || Get() == TestDev } func updateGinMode() { switch Get() { case Dev: gin.SetMode(gin.DebugMode) case TestDev: gin.SetMode(gin.TestMode) case Prod: gin.SetMode(gin.ReleaseMode) default: panic("unknown mode") } } ================================================ FILE: mode/mode_test.go ================================================ package mode import ( "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) func TestDevMode(t *testing.T) { Set(Dev) assert.Equal(t, Get(), Dev) assert.True(t, IsDev()) assert.Equal(t, gin.Mode(), gin.DebugMode) } func TestTestDevMode(t *testing.T) { Set(TestDev) assert.Equal(t, Get(), TestDev) assert.True(t, IsDev()) assert.Equal(t, gin.Mode(), gin.TestMode) } func TestProdMode(t *testing.T) { Set(Prod) assert.Equal(t, Get(), Prod) assert.False(t, IsDev()) assert.Equal(t, gin.Mode(), gin.ReleaseMode) } func TestInvalidMode(t *testing.T) { assert.Panics(t, func() { Set("asdasda") }) } ================================================ FILE: model/application.go ================================================ package model import "time" // Application Model // // The Application holds information about an app which can send notifications. // // swagger:model Application type Application struct { // The application id. // // read only: true // required: true // example: 5 ID uint `gorm:"primaryKey;autoIncrement" json:"id"` // The application token. Can be used as `appToken`. See Authentication. // // read only: true // required: true // example: AWH0wZ5r0Mbac.r Token string `gorm:"type:varchar(180);uniqueIndex:uix_applications_token" json:"token"` UserID uint `gorm:"index;uniqueIndex:uix_application_user_id_sort_key,priority:1" json:"-"` // The application name. This is how the application should be displayed to the user. // // required: true // example: Backup Server Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` // The description of the application. // // required: true // example: Backup server for the interwebs Description string `gorm:"type:text" form:"description" query:"description" json:"description"` // Whether the application is an internal application. Internal applications should not be deleted. // // read only: true // required: true // example: false Internal bool `form:"internal" query:"internal" json:"internal"` // The image of the application. // // read only: true // required: true // example: image/image.jpeg Image string `gorm:"type:text" json:"image"` Messages []MessageExternal `gorm:"-" json:"-"` // The default priority of messages sent by this application. Defaults to 0. // // required: false // example: 4 DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` // The last time the application token was used. // // read only: true // example: 2019-01-01T00:00:00Z LastUsed *time.Time `json:"lastUsed"` // The sort key of this application. Uses fractional indexing. // // required: true // example: a1 SortKey string `gorm:"type:bytes;uniqueIndex:uix_application_user_id_sort_key,priority:2,length:255" form:"sortKey" query:"sortKey" json:"sortKey"` } ================================================ FILE: model/client.go ================================================ package model import "time" // Client Model // // The Client holds information about a device which can receive notifications (and other stuff). // // swagger:model Client type Client struct { // The client id. // // read only: true // required: true // example: 5 ID uint `gorm:"primaryKey;autoIncrement" json:"id"` // The client token. Can be used as `clientToken`. See Authentication. // // read only: true // required: true // example: CWH0wZ5r0Mbac.r Token string `gorm:"type:varchar(180);uniqueIndex:uix_clients_token" json:"token"` UserID uint `gorm:"index" json:"-"` // The client name. This is how the client should be displayed to the user. // // required: true // example: Android Phone Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` // The last time the client token was used. // // read only: true // example: 2019-01-01T00:00:00Z LastUsed *time.Time `json:"lastUsed"` } ================================================ FILE: model/error.go ================================================ package model // Error Model // // The Error contains error relevant information. // // swagger:model Error type Error struct { // The general error message // // required: true // example: Unauthorized Error string `json:"error"` // The http error code. // // required: true // example: 401 ErrorCode int `json:"errorCode"` // The http error code. // // required: true // example: you need to provide a valid access token or user credentials to access this api ErrorDescription string `json:"errorDescription"` } ================================================ FILE: model/health.go ================================================ package model // Health Model // // Health represents how healthy the application is. // // swagger:model Health type Health struct { // The health of the overall application. // // required: true // example: green Health string `json:"health"` // The health of the database connection. // // required: true // example: green Database string `json:"database"` } const ( // StatusGreen everything is alright. StatusGreen = "green" // StatusOrange some things are alright. StatusOrange = "orange" // StatusRed nothing is alright. StatusRed = "red" ) ================================================ FILE: model/message.go ================================================ package model import ( "time" ) // Message holds information about a message. type Message struct { ID uint `gorm:"autoIncrement;primaryKey;index"` ApplicationID uint Message string `gorm:"type:text"` Title string `gorm:"type:text"` Priority int Extras []byte Date time.Time } // MessageExternal Model // // The MessageExternal holds information about a message which was sent by an Application. // // swagger:model Message type MessageExternal struct { // The message id. // // read only: true // required: true // example: 25 ID uint `json:"id"` // The application id that send this message. // // read only: true // required: true // example: 5 ApplicationID uint `json:"appid"` // The message. Markdown (excluding html) is allowed. // // required: true // example: **Backup** was successfully finished. Message string `form:"message" query:"message" json:"message" binding:"required"` // The title of the message. // // example: Backup Title string `form:"title" query:"title" json:"title"` // The priority of the message. If unset, then the default priority of the // application will be used. // // example: 2 Priority *int `form:"priority" query:"priority" json:"priority"` // The extra data sent along the message. // // The extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type. // // The keys should be in the following format: <top-namespace>::[<sub-namespace>::]<action> // // 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. // // example: {"home::appliances::thermostat::change_temperature":{"temperature":23},"home::appliances::lighting::on":{"brightness":15}} Extras map[string]interface{} `form:"-" query:"-" json:"extras,omitempty"` // The date the message was created. // // read only: true // required: true // example: 2018-02-27T19:36:10.5045044+01:00 Date time.Time `json:"date"` } ================================================ FILE: model/paging.go ================================================ package model // Paging Model // // The Paging holds information about the limit and making requests to the next page. // // swagger:model Paging type Paging struct { // The request url for the next page. Empty/Null when no next page is available. // // read only: true // required: false // example: http://example.com/message?limit=50&since=123456 Next string `json:"next,omitempty"` // The amount of messages that got returned in the current request. // // read only: true // required: true // example: 5 Size int `json:"size"` // The ID of the last message returned in the current request. Use this as alternative to the next link. // // read only: true // required: true // example: 5 // min: 0 Since uint `json:"since"` // The limit of the messages for the current request. // // read only: true // required: true // min: 1 // max: 200 // example: 123 Limit int `json:"limit"` } // PagedMessages Model // // Wrapper for the paging and the messages. // // swagger:model PagedMessages type PagedMessages struct { // The paging of the messages. // // read only: true // required: true Paging Paging `json:"paging"` // The messages. // // read only: true // required: true Messages []*MessageExternal `json:"messages"` } ================================================ FILE: model/pluginconf.go ================================================ package model // PluginConf holds information about the plugin. type PluginConf struct { ID uint `gorm:"primaryKey;autoIncrement"` UserID uint ModulePath string `gorm:"type:text"` Token string `gorm:"type:varchar(180);uniqueIndex:uix_plugin_confs_token"` ApplicationID uint Enabled bool Config []byte Storage []byte } // PluginConfExternal Model // // Holds information about a plugin instance for one user. // // swagger:model PluginConf type PluginConfExternal struct { // The plugin id. // // read only: true // required: true // example: 25 ID uint `json:"id"` // The plugin name. // // read only: true // required: true // example: RSS poller Name string `json:"name"` // The user name. For login. // // required: true // example: P1234 Token string `binding:"required" json:"token" query:"token" form:"token"` // The module path of the plugin. // // example: github.com/gotify/server/plugin/example/echo // read only: true // required: true ModulePath string `json:"modulePath" form:"modulePath" query:"modulePath"` // The author of the plugin. // // example: jmattheis // read only: true Author string `json:"author,omitempty" form:"author" query:"author"` // The website of the plugin. // // example: gotify.net // read only: true Website string `json:"website,omitempty" form:"website" query:"website"` // The license of the plugin. // // example: MIT // read only: true License string `json:"license,omitempty" form:"license" query:"license"` // Whether the plugin instance is enabled. // // example: true // required: true Enabled bool `json:"enabled"` // Capabilities the plugin provides // // example: ["webhook","display"] // required: true Capabilities []string `json:"capabilities"` } ================================================ FILE: model/user.go ================================================ package model // The User holds information about the credentials of a user and its application and client tokens. type User struct { ID uint `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(180);uniqueIndex:uix_users_name"` Pass []byte Admin bool Applications []Application Clients []Client Plugins []PluginConf } // UserExternal Model // // The User holds information about permission and other stuff. // // swagger:model User type UserExternal struct { // The user id. // // read only: true // required: true // example: 25 ID uint `json:"id"` // The user name. For login. // // required: true // example: unicorn Name string `binding:"required" json:"name" query:"name" form:"name"` // If the user is an administrator. // // required: true // example: true Admin bool `json:"admin" form:"admin" query:"admin"` } // CreateUserExternal Model // // Used for user creation. // // swagger:model CreateUserExternal type CreateUserExternal struct { // The user name. For login. // // required: true // example: unicorn Name string `binding:"required" json:"name" query:"name" form:"name"` // If the user is an administrator. // // required: true // example: true Admin bool `json:"admin" form:"admin" query:"admin"` // The user password. For login. // // required: true // example: nrocinu Pass string `json:"pass,omitempty" form:"pass" query:"pass" binding:"required"` } // UpdateUserExternal Model // // Used for updating a user. // // swagger:model UpdateUserExternal type UpdateUserExternal struct { // The user name. For login. // // required: true // example: unicorn Name string `binding:"required" json:"name" query:"name" form:"name"` // If the user is an administrator. // // required: true // example: true Admin bool `json:"admin" form:"admin" query:"admin"` // The user password. For login. Empty for using old password // // example: nrocinu Pass string `json:"pass,omitempty" form:"pass" query:"pass"` } // UserExternalPass Model // // The Password for updating the user. // // swagger:model UserPass type UserExternalPass struct { // The user password. For login. // // required: true // example: nrocinu Pass string `json:"pass,omitempty" form:"pass" query:"pass" binding:"required"` } ================================================ FILE: model/version.go ================================================ package model // VersionInfo Model // // swagger:model VersionInfo type VersionInfo struct { // The current version. // // required: true // example: 5.2.6 Version string `json:"version"` // The git commit hash on which this binary was built. // // required: true // example: ae9512b6b6feea56a110d59a3353ea3b9c293864 Commit string `json:"commit"` // The date on which this binary was built. // // required: true // example: 2018-02-27T19:36:10.5045044+01:00 BuildDate string `json:"buildDate"` } ================================================ FILE: plugin/compat/instance.go ================================================ package compat import ( "net/url" "github.com/gin-gonic/gin" ) // Capability is a capability the plugin provides. type Capability string const ( // Messenger sends notifications. Messenger = Capability("messenger") // Configurer are consigurables. Configurer = Capability("configurer") // Storager stores data. Storager = Capability("storager") // Webhooker registers webhooks. Webhooker = Capability("webhooker") // Displayer displays instructions. Displayer = Capability("displayer") ) // PluginInstance is an encapsulation layer of plugin instances of different backends. type PluginInstance interface { Enable() error Disable() error // GetDisplay see Displayer GetDisplay(location *url.URL) string // DefaultConfig see Configurer DefaultConfig() interface{} // ValidateAndSetConfig see Configurer ValidateAndSetConfig(c interface{}) error // SetMessageHandler see Messenger#SetMessageHandler SetMessageHandler(h MessageHandler) // RegisterWebhook see Webhooker#RegisterWebhook RegisterWebhook(basePath string, mux *gin.RouterGroup) // SetStorageHandler see Storager#SetStorageHandler. SetStorageHandler(handler StorageHandler) // Returns the supported modules, f.ex. storager Supports() Capabilities } // HasSupport tests a PluginInstance for a capability. func HasSupport(p PluginInstance, toCheck Capability) bool { for _, module := range p.Supports() { if module == toCheck { return true } } return false } // Capabilities is a slice of module. type Capabilities []Capability // Strings converts []Module to []string. func (m Capabilities) Strings() []string { var result []string for _, module := range m { result = append(result, string(module)) } return result } // MessageHandler see plugin.MessageHandler. type MessageHandler interface { // SendMessage see plugin.MessageHandler SendMessage(msg Message) error } // StorageHandler see plugin.StorageHandler. type StorageHandler interface { Save(b []byte) error Load() ([]byte, error) } // Message describes a message to be send by MessageHandler#SendMessage. type Message struct { Message string Title string Priority int Extras map[string]interface{} } ================================================ FILE: plugin/compat/plugin.go ================================================ package compat // Plugin is an abstraction of plugin handler. type Plugin interface { PluginInfo() Info NewPluginInstance(ctx UserContext) PluginInstance APIVersion() string } // Info is the plugin info. type Info struct { Version string Author string Name string Website string Description string License string ModulePath string } func (c Info) String() string { if c.Name != "" { return c.Name } return c.ModulePath } // UserContext is the user context used to create plugin instance. type UserContext struct { ID uint Name string Admin bool } ================================================ FILE: plugin/compat/plugin_test.go ================================================ package compat import ( "testing" "github.com/stretchr/testify/assert" ) const examplePluginPath = "github.com/gotify/server/v2/plugin/example/echo" func TestPluginInfoStringer(t *testing.T) { info := Info{ ModulePath: examplePluginPath, } assert.Equal(t, examplePluginPath, info.String()) info.Name = "test name" assert.Equal(t, "test name", info.String()) } ================================================ FILE: plugin/compat/v1.go ================================================ package compat import ( "net/url" "github.com/gin-gonic/gin" papiv1 "github.com/gotify/plugin-api" ) // PluginV1 is an abstraction of a plugin written in the v1 plugin API. Exported for testing purposes only. type PluginV1 struct { Info papiv1.Info Constructor func(ctx papiv1.UserContext) papiv1.Plugin } // APIVersion returns the API version. func (c PluginV1) APIVersion() string { return "v1" } // PluginInfo implements compat/Plugin. func (c PluginV1) PluginInfo() Info { return Info{ Version: c.Info.Version, Author: c.Info.Author, Name: c.Info.Name, Website: c.Info.Website, Description: c.Info.Description, License: c.Info.License, ModulePath: c.Info.ModulePath, } } // NewPluginInstance implements compat/Plugin. func (c PluginV1) NewPluginInstance(ctx UserContext) PluginInstance { instance := c.Constructor(papiv1.UserContext{ ID: ctx.ID, Name: ctx.Name, Admin: ctx.Admin, }) compat := &PluginV1Instance{ instance: instance, } if displayer, ok := instance.(papiv1.Displayer); ok { compat.displayer = displayer } if messenger, ok := instance.(papiv1.Messenger); ok { compat.messenger = messenger } if configurer, ok := instance.(papiv1.Configurer); ok { compat.configurer = configurer } if storager, ok := instance.(papiv1.Storager); ok { compat.storager = storager } if webhooker, ok := instance.(papiv1.Webhooker); ok { compat.webhooker = webhooker } return compat } // PluginV1Instance is an adapter for plugin using v1 API. type PluginV1Instance struct { instance papiv1.Plugin messenger papiv1.Messenger configurer papiv1.Configurer storager papiv1.Storager webhooker papiv1.Webhooker displayer papiv1.Displayer } // DefaultConfig see papiv1.Configurer. func (c *PluginV1Instance) DefaultConfig() interface{} { if c.configurer != nil { return c.configurer.DefaultConfig() } return struct{}{} } // ValidateAndSetConfig see papiv1.Configurer. func (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) error { if c.configurer != nil { return c.configurer.ValidateAndSetConfig(config) } return nil } // GetDisplay see papiv1.Displayer. func (c *PluginV1Instance) GetDisplay(location *url.URL) string { if c.displayer != nil { return c.displayer.GetDisplay(location) } return "" } // SetMessageHandler see papiv1.Messenger. func (c *PluginV1Instance) SetMessageHandler(h MessageHandler) { if c.messenger != nil { c.messenger.SetMessageHandler(&PluginV1MessageHandler{WrapperHandler: h}) } } // RegisterWebhook see papiv1.Webhooker. func (c *PluginV1Instance) RegisterWebhook(basePath string, mux *gin.RouterGroup) { if c.webhooker != nil { c.webhooker.RegisterWebhook(basePath, mux) } } // SetStorageHandler see papiv1.Storager. func (c *PluginV1Instance) SetStorageHandler(handler StorageHandler) { if c.storager != nil { c.storager.SetStorageHandler(&PluginV1StorageHandler{WrapperHandler: handler}) } } // Supports returns a slice of capabilities the plugin instance provides. func (c *PluginV1Instance) Supports() Capabilities { modules := Capabilities{} if c.configurer != nil { modules = append(modules, Configurer) } if c.displayer != nil { modules = append(modules, Displayer) } if c.messenger != nil { modules = append(modules, Messenger) } if c.storager != nil { modules = append(modules, Storager) } if c.webhooker != nil { modules = append(modules, Webhooker) } return modules } // PluginV1MessageHandler is an adapter for messenger plugin handler using v1 API. type PluginV1MessageHandler struct { WrapperHandler MessageHandler } // SendMessage implements papiv1.MessageHandler. func (c *PluginV1MessageHandler) SendMessage(msg papiv1.Message) error { return c.WrapperHandler.SendMessage(Message{ Message: msg.Message, Priority: msg.Priority, Title: msg.Title, Extras: msg.Extras, }) } // Enable implements wrapper.Plugin. func (c *PluginV1Instance) Enable() error { return c.instance.Enable() } // Disable implements wrapper.Plugin. func (c *PluginV1Instance) Disable() error { return c.instance.Disable() } // PluginV1StorageHandler is a wrapper for v1 storage handler. type PluginV1StorageHandler struct { WrapperHandler StorageHandler } // Save implements wrapper.Storager. func (c *PluginV1StorageHandler) Save(b []byte) error { return c.WrapperHandler.Save(b) } // Load implements wrapper.Storager. func (c *PluginV1StorageHandler) Load() ([]byte, error) { return c.WrapperHandler.Load() } ================================================ FILE: plugin/compat/v1_test.go ================================================ package compat import ( "testing" papiv1 "github.com/gotify/plugin-api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) type v1MockInstance struct { Enabled bool } func (c *v1MockInstance) Enable() error { c.Enabled = true return nil } func (c *v1MockInstance) Disable() error { c.Enabled = false return nil } type V1WrapperSuite struct { suite.Suite i PluginV1Instance } func (s *V1WrapperSuite) SetupSuite() { inst := new(v1MockInstance) s.i.instance = inst } func (s *V1WrapperSuite) TestConfigurer_notSupported_expectEmpty() { assert.Equal(s.T(), struct{}{}, s.i.DefaultConfig()) assert.Nil(s.T(), s.i.ValidateAndSetConfig(struct{}{})) } func (s *V1WrapperSuite) TestDisplayer_notSupported_expectEmpty() { assert.Equal(s.T(), "", s.i.GetDisplay(nil)) } type v1StorageHandler struct { storage []byte } func (c *v1StorageHandler) Save(b []byte) error { c.storage = b return nil } func (c *v1StorageHandler) Load() ([]byte, error) { return c.storage, nil } type v1Storager struct { handler papiv1.StorageHandler } func (c *v1Storager) Enable() error { return nil } func (c *v1Storager) Disable() error { return nil } func (c *v1Storager) SetStorageHandler(h papiv1.StorageHandler) { c.handler = h } func (s *V1WrapperSuite) TestStorager() { storager := new(v1Storager) s.i.storager = storager s.i.SetStorageHandler(new(v1StorageHandler)) assert.Nil(s.T(), storager.handler.Save([]byte("test"))) storage, err := storager.handler.Load() assert.Nil(s.T(), err) assert.Equal(s.T(), "test", string(storage)) } type v1MessengerHandler struct { msgSent Message } func (c *v1MessengerHandler) SendMessage(msg Message) error { c.msgSent = msg return nil } type v1Messenger struct { handler papiv1.MessageHandler } func (c *v1Messenger) Enable() error { return nil } func (c *v1Messenger) Disable() error { return nil } func (c *v1Messenger) SetMessageHandler(h papiv1.MessageHandler) { c.handler = h } func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { messenger := new(v1Messenger) s.i.messenger = messenger handler := new(v1MessengerHandler) s.i.SetMessageHandler(handler) msg := papiv1.Message{ Title: "test message", Message: "test", Priority: 2, Extras: map[string]interface{}{ "test::string": "test", }, } assert.Nil(s.T(), messenger.handler.SendMessage(msg)) assert.Equal(s.T(), Message{ Title: "test message", Message: "test", Priority: 2, Extras: map[string]interface{}{ "test::string": "test", }, }, handler.msgSent) } func (s *V1WrapperSuite) TestMessenger_sendMessageWithoutExtras() { messenger := new(v1Messenger) s.i.messenger = messenger handler := new(v1MessengerHandler) s.i.SetMessageHandler(handler) msg := papiv1.Message{ Title: "test message", Message: "test", Priority: 2, Extras: nil, } assert.Nil(s.T(), messenger.handler.SendMessage(msg)) assert.Equal(s.T(), Message{ Title: "test message", Message: "test", Priority: 2, Extras: nil, }, handler.msgSent) } func TestV1Wrapper(t *testing.T) { suite.Run(t, new(V1WrapperSuite)) } ================================================ FILE: plugin/compat/wrap.go ================================================ package compat import ( "errors" "fmt" "plugin" papiv1 "github.com/gotify/plugin-api" ) // Wrap wraps around a raw go plugin to provide typesafe access. func Wrap(p *plugin.Plugin) (Plugin, error) { getInfoHandle, err := p.Lookup("GetGotifyPluginInfo") if err != nil { return nil, errors.New("missing GetGotifyPluginInfo symbol") } switch getInfoHandle := getInfoHandle.(type) { case func() papiv1.Info: v1 := PluginV1{} v1.Info = getInfoHandle() newInstanceHandle, err := p.Lookup("NewGotifyPluginInstance") if err != nil { return nil, errors.New("missing NewGotifyPluginInstance symbol") } constructor, ok := newInstanceHandle.(func(ctx papiv1.UserContext) papiv1.Plugin) if !ok { return nil, fmt.Errorf("NewGotifyPluginInstance signature mismatch, func(ctx plugin.UserContext) plugin.Plugin expected, got %T", newInstanceHandle) } v1.Constructor = constructor return v1, nil default: return nil, fmt.Errorf("unknown plugin version (unrecogninzed GetGotifyPluginInfo signature %T)", getInfoHandle) } } ================================================ FILE: plugin/compat/wrap_test.go ================================================ //go:build linux || darwin // +build linux darwin package compat import ( "fmt" "os" "os/exec" "path" "plugin" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) type CompatSuite struct { suite.Suite p Plugin tmpDir test.TmpDir } func (s *CompatSuite) SetupSuite() { s.tmpDir = test.NewTmpDir("gotify_compatsuite") test.WithWd(path.Join(test.GetProjectDir(), "./plugin/example/echo"), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + s.tmpDir.Path("echo.so")} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(s.T(), cmd.Run()) }) plugin, err := plugin.Open(s.tmpDir.Path("echo.so")) assert.Nil(s.T(), err) wrappedPlugin, err := Wrap(plugin) assert.Nil(s.T(), err) s.p = wrappedPlugin } func (s *CompatSuite) TearDownSuite() { assert.Nil(s.T(), s.tmpDir.Clean()) } func (s *CompatSuite) TestGetPluginAPIVersion() { assert.Equal(s.T(), "v1", s.p.APIVersion()) } func (s *CompatSuite) TestGetPluginInfo() { info := s.p.PluginInfo() assert.Equal(s.T(), examplePluginPath, info.ModulePath) assert.True(s.T(), info.String() != "") } func (s *CompatSuite) TestInstantiatePlugin() { inst := s.p.NewPluginInstance(UserContext{ ID: 1, Name: "test", }) assert.NotNil(s.T(), inst) } func (s *CompatSuite) TestGetCapabilities() { inst := s.p.NewPluginInstance(UserContext{ ID: 2, Name: "test2", }) c := inst.Supports() assert.Contains(s.T(), c, Webhooker) assert.Contains(s.T(), c.Strings(), string(Webhooker)) assert.True(s.T(), HasSupport(inst, Webhooker)) assert.False(s.T(), HasSupport(inst, "not_exist")) } func (s *CompatSuite) TestSetConfig() { inst := s.p.NewPluginInstance(UserContext{ ID: 3, Name: "test3", }) defaultConfig := inst.DefaultConfig() assert.Nil(s.T(), inst.ValidateAndSetConfig(defaultConfig)) } func (s *CompatSuite) TestRegisterWebhook() { inst := s.p.NewPluginInstance(UserContext{ ID: 4, Name: "test4", }) e := gin.New() g := e.Group("/") assert.NotPanics(s.T(), func() { inst.RegisterWebhook("/plugin/4/custom/Pabcd/", g) }) } func (s *CompatSuite) TestEnableDisable() { inst := s.p.NewPluginInstance(UserContext{ ID: 5, Name: "test5", }) assert.Nil(s.T(), inst.Enable()) assert.Nil(s.T(), inst.Disable()) } func (s *CompatSuite) TestGetDisplay() { inst := s.p.NewPluginInstance(UserContext{ ID: 6, Name: "test6", }) assert.NotEqual(s.T(), "", inst.GetDisplay(nil)) } func TestCompatSuite(t *testing.T) { suite.Run(t, new(CompatSuite)) } func TestWrapIncompatiblePlugins(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testwrapincompatibleplugins") defer tmpDir.Clean() for i, modulePath := range []string{ "github.com/gotify/server/v2/plugin/testing/broken/noinstance", "github.com/gotify/server/v2/plugin/testing/broken/nothing", "github.com/gotify/server/v2/plugin/testing/broken/unknowninfo", "github.com/gotify/server/v2/plugin/testing/broken/malformedconstructor", } { fName := tmpDir.Path(fmt.Sprintf("broken_%d.so", i)) exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + fName} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) goBuildFlags = append(goBuildFlags, modulePath) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(t, cmd.Run()) plugin, err := plugin.Open(fName) assert.Nil(t, err) _, err = Wrap(plugin) assert.Error(t, err) os.Remove(fName) } } ================================================ FILE: plugin/compat/wrap_test_norace.go ================================================ //go:build !race // +build !race package compat var extraGoBuildFlags = []string{} ================================================ FILE: plugin/compat/wrap_test_race.go ================================================ //go:build race // +build race package compat var extraGoBuildFlags = []string{"-race"} ================================================ FILE: plugin/example/clock/main.go ================================================ package main import ( "time" "github.com/gotify/plugin-api" "github.com/robfig/cron" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ Name: "clock", Description: "Sends an hourly reminder", ModulePath: "github.com/gotify/server/v2/example/clock", } } // Plugin is plugin instance type Plugin struct { msgHandler plugin.MessageHandler enabled bool cronHandler *cron.Cron } // Enable implements plugin.Plugin func (c *Plugin) Enable() error { c.enabled = true c.cronHandler = cron.New() c.cronHandler.AddFunc("0 0 * * *", func() { c.msgHandler.SendMessage(plugin.Message{ Title: "Tick Tock!", Message: time.Now().Format("It is 15:04:05 now."), }) }) c.cronHandler.Start() return nil } // Disable implements plugin.Plugin func (c *Plugin) Disable() error { if c.cronHandler != nil { c.cronHandler.Stop() } c.enabled = false return nil } // SetMessageHandler implements plugin.Messenger. func (c *Plugin) SetMessageHandler(h plugin.MessageHandler) { c.msgHandler = h } // NewGotifyPluginInstance creates a plugin instance for a user context. func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { p := &Plugin{} return p } func main() { panic("this should be built as go plugin") } ================================================ FILE: plugin/example/echo/echo.go ================================================ package main import ( "encoding/json" "fmt" "log" "net/url" "github.com/gin-gonic/gin" "github.com/gotify/plugin-api" ) // GetGotifyPluginInfo returns gotify plugin info. func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ ModulePath: "github.com/gotify/server/v2/plugin/example/echo", Name: "test plugin", } } // EchoPlugin is the gotify plugin instance. type EchoPlugin struct { msgHandler plugin.MessageHandler storageHandler plugin.StorageHandler config *Config basePath string } // SetStorageHandler implements plugin.Storager func (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) { c.storageHandler = h } // SetMessageHandler implements plugin.Messenger. func (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) { c.msgHandler = h } // Storage defines the plugin storage scheme type Storage struct { CalledTimes int `json:"called_times"` } // Config defines the plugin config scheme type Config struct { MagicString string `yaml:"magic_string"` } // DefaultConfig implements plugin.Configurer func (c *EchoPlugin) DefaultConfig() interface{} { return &Config{ MagicString: "hello world", } } // ValidateAndSetConfig implements plugin.Configurer func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error { c.config = config.(*Config) return nil } // Enable enables the plugin. func (c *EchoPlugin) Enable() error { log.Println("echo plugin enabled") return nil } // Disable disables the plugin. func (c *EchoPlugin) Disable() error { log.Println("echo plugin disbled") return nil } // RegisterWebhook implements plugin.Webhooker. func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) { c.basePath = baseURL g.GET("/echo", func(ctx *gin.Context) { storage, _ := c.storageHandler.Load() conf := new(Storage) json.Unmarshal(storage, conf) conf.CalledTimes++ newStorage, _ := json.Marshal(conf) c.storageHandler.Save(newStorage) c.msgHandler.SendMessage(plugin.Message{ Title: "Hello received", Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes), Priority: 2, Extras: map[string]interface{}{ "plugin::name": "echo", }, }) ctx.Writer.WriteString(fmt.Sprintf("Magic string is: %s\r\nEcho server running at %secho", c.config.MagicString, c.basePath)) }) } // GetDisplay implements plugin.Displayer. func (c *EchoPlugin) GetDisplay(location *url.URL) string { loc := &url.URL{ Path: c.basePath, } if location != nil { loc.Scheme = location.Scheme loc.Host = location.Host } loc = loc.ResolveReference(&url.URL{ Path: "echo", }) return "Echo plugin running at: " + loc.String() } // NewGotifyPluginInstance creates a plugin instance for a user context. func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { return &EchoPlugin{} } func main() { panic("this should be built as go plugin") } ================================================ FILE: plugin/example/minimal/main.go ================================================ package main import ( "github.com/gotify/plugin-api" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ Name: "minimal plugin", ModulePath: "github.com/gotify/server/v2/example/minimal", } } // Plugin is plugin instance type Plugin struct{} // Enable implements plugin.Plugin func (c *Plugin) Enable() error { return nil } // Disable implements plugin.Plugin func (c *Plugin) Disable() error { return nil } // NewGotifyPluginInstance creates a plugin instance for a user context. func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { return &Plugin{} } func main() { panic("this should be built as go plugin") } ================================================ FILE: plugin/manager.go ================================================ package plugin import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "plugin" "strconv" "strings" "sync" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin/compat" "gopkg.in/yaml.v3" ) // The Database interface for encapsulating database access. type Database interface { GetUsers() ([]*model.User, error) GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error) CreatePluginConf(p *model.PluginConf) error GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error) UpdatePluginConf(p *model.PluginConf) error CreateMessage(message *model.Message) error GetPluginConfByID(id uint) (*model.PluginConf, error) GetPluginConfByToken(token string) (*model.PluginConf, error) GetUserByID(id uint) (*model.User, error) CreateApplication(application *model.Application) error UpdateApplication(app *model.Application) error GetApplicationsByUser(userID uint) ([]*model.Application, error) GetApplicationByToken(token string) (*model.Application, error) } // Notifier notifies when a new message was created. type Notifier interface { Notify(userID uint, message *model.MessageExternal) } // Manager is an encapsulating layer for plugins and manages all plugins and its instances. type Manager struct { mutex *sync.RWMutex instances map[uint]compat.PluginInstance plugins map[string]compat.Plugin messages chan MessageWithUserID db Database mux *gin.RouterGroup } // NewManager created a Manager from configurations. func NewManager(db Database, directory string, mux *gin.RouterGroup, notifier Notifier) (*Manager, error) { manager := &Manager{ mutex: &sync.RWMutex{}, instances: map[uint]compat.PluginInstance{}, plugins: map[string]compat.Plugin{}, messages: make(chan MessageWithUserID), db: db, mux: mux, } go func() { for { message := <-manager.messages internalMsg := &model.Message{ ApplicationID: message.Message.ApplicationID, Title: message.Message.Title, Priority: *message.Message.Priority, Date: message.Message.Date, Message: message.Message.Message, } if message.Message.Extras != nil { internalMsg.Extras, _ = json.Marshal(message.Message.Extras) } db.CreateMessage(internalMsg) message.Message.ID = internalMsg.ID notifier.Notify(message.UserID, &message.Message) } }() if err := manager.loadPlugins(directory); err != nil { return nil, err } users, err := manager.db.GetUsers() if err != nil { return nil, err } for _, user := range users { if err := manager.initializeForUser(*user); err != nil { return nil, err } } return manager, nil } // ErrAlreadyEnabledOrDisabled is returned on SetPluginEnabled call when a plugin is already enabled or disabled. var ErrAlreadyEnabledOrDisabled = errors.New("config is already enabled/disabled") func (m *Manager) applicationExists(token string) bool { app, _ := m.db.GetApplicationByToken(token) return app != nil } func (m *Manager) pluginConfExists(token string) bool { pluginConf, _ := m.db.GetPluginConfByToken(token) return pluginConf != nil } // SetPluginEnabled sets the plugins enabled state. func (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error { instance, err := m.Instance(pluginID) if err != nil { return errors.New("instance not found") } conf, err := m.db.GetPluginConfByID(pluginID) if err != nil { return err } if conf.Enabled == enabled { return ErrAlreadyEnabledOrDisabled } m.mutex.Lock() defer m.mutex.Unlock() if enabled { err = instance.Enable() } else { err = instance.Disable() } if err != nil { return err } if newConf, err := m.db.GetPluginConfByID(pluginID); /* conf might be updated by instance */ err == nil { conf = newConf } conf.Enabled = enabled return m.db.UpdatePluginConf(conf) } // PluginInfo returns plugin info. func (m *Manager) PluginInfo(modulePath string) compat.Info { m.mutex.RLock() defer m.mutex.RUnlock() if p, ok := m.plugins[modulePath]; ok { return p.PluginInfo() } fmt.Println("Could not get plugin info for", modulePath) return compat.Info{ Name: "UNKNOWN", ModulePath: modulePath, Description: "Oops something went wrong", } } // Instance returns an instance with the given ID. func (m *Manager) Instance(pluginID uint) (compat.PluginInstance, error) { m.mutex.RLock() defer m.mutex.RUnlock() if instance, ok := m.instances[pluginID]; ok { return instance, nil } return nil, errors.New("instance not found") } // HasInstance returns whether the given plugin ID has a corresponding instance. func (m *Manager) HasInstance(pluginID uint) bool { instance, err := m.Instance(pluginID) return err == nil && instance != nil } // RemoveUser disabled all plugins of a user when the user is disabled. func (m *Manager) RemoveUser(userID uint) error { for _, p := range m.plugins { pluginConf, err := m.db.GetPluginConfByUserAndPath(userID, p.PluginInfo().ModulePath) if err != nil { return err } if pluginConf == nil { continue } if pluginConf.Enabled { inst, err := m.Instance(pluginConf.ID) if err != nil { continue } m.mutex.Lock() err = inst.Disable() m.mutex.Unlock() if err != nil { return err } } delete(m.instances, pluginConf.ID) } return nil } type pluginFileLoadError struct { Filename string UnderlyingError error } func (c pluginFileLoadError) Error() string { return fmt.Sprintf("error while loading plugin %s: %s", c.Filename, c.UnderlyingError) } func (m *Manager) loadPlugins(directory string) error { if directory == "" { return nil } pluginFiles, err := os.ReadDir(directory) if err != nil { return fmt.Errorf("error while reading directory %s", err) } for _, f := range pluginFiles { if f.IsDir() { continue } name := f.Name() if strings.HasPrefix(name, ".") { continue } pluginPath := filepath.Join(directory, "./", name) fmt.Println("Loading plugin", pluginPath) pRaw, err := plugin.Open(pluginPath) if err != nil { return pluginFileLoadError{name, err} } compatPlugin, err := compat.Wrap(pRaw) if err != nil { return pluginFileLoadError{name, err} } if err := m.LoadPlugin(compatPlugin); err != nil { return pluginFileLoadError{name, err} } } return nil } // LoadPlugin loads a compat plugin, exported to sideload plugins for testing purposes. func (m *Manager) LoadPlugin(compatPlugin compat.Plugin) error { modulePath := compatPlugin.PluginInfo().ModulePath if _, ok := m.plugins[modulePath]; ok { return fmt.Errorf("plugin with module path %s is present at least twice", modulePath) } m.plugins[modulePath] = compatPlugin return nil } // InitializeForUserID initializes all plugin instances for a given user. func (m *Manager) InitializeForUserID(userID uint) error { m.mutex.Lock() defer m.mutex.Unlock() user, err := m.db.GetUserByID(userID) if err != nil { return err } if user != nil { return m.initializeForUser(*user) } return fmt.Errorf("user with id %d not found", userID) } func (m *Manager) initializeForUser(user model.User) error { userCtx := compat.UserContext{ ID: user.ID, Name: user.Name, Admin: user.Admin, } for _, p := range m.plugins { if err := m.initializeSingleUserPlugin(userCtx, p); err != nil { return err } } apps, err := m.db.GetApplicationsByUser(user.ID) if err != nil { return err } for _, app := range apps { conf, err := m.db.GetPluginConfByApplicationID(app.ID) if err != nil { return err } if conf != nil { _, compatExist := m.plugins[conf.ModulePath] app.Internal = compatExist } else { app.Internal = false } m.db.UpdateApplication(app) } return nil } func (m *Manager) initializeSingleUserPlugin(userCtx compat.UserContext, p compat.Plugin) error { info := p.PluginInfo() instance := p.NewPluginInstance(userCtx) userID := userCtx.ID pluginConf, err := m.db.GetPluginConfByUserAndPath(userID, info.ModulePath) if err != nil { return err } if pluginConf == nil { var err error pluginConf, err = m.createPluginConf(instance, info, userID) if err != nil { return err } } m.instances[pluginConf.ID] = instance if compat.HasSupport(instance, compat.Messenger) { instance.SetMessageHandler(redirectToChannel{ ApplicationID: pluginConf.ApplicationID, UserID: pluginConf.UserID, Messages: m.messages, }) } if compat.HasSupport(instance, compat.Storager) { instance.SetStorageHandler(dbStorageHandler{pluginConf.ID, m.db}) } if compat.HasSupport(instance, compat.Configurer) { m.initializeConfigurerForSingleUserPlugin(instance, pluginConf) } if compat.HasSupport(instance, compat.Webhooker) { id := pluginConf.ID g := m.mux.Group(pluginConf.Token+"/", requirePluginEnabled(id, m.db)) instance.RegisterWebhook(strings.Replace(g.BasePath(), ":id", strconv.Itoa(int(id)), 1), g) } if pluginConf.Enabled { err := instance.Enable() if err != nil { // Single user plugin cannot be enabled // Don't panic, disable for now and wait for user to update config log.Printf("Plugin initialize failed for user %s: %s. Disabling now...", userCtx.Name, err.Error()) pluginConf.Enabled = false m.db.UpdatePluginConf(pluginConf) } } return nil } func (m *Manager) initializeConfigurerForSingleUserPlugin(instance compat.PluginInstance, pluginConf *model.PluginConf) { if len(pluginConf.Config) == 0 { // The Configurer is newly implemented // Use the default config pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig()) m.db.UpdatePluginConf(pluginConf) } c := instance.DefaultConfig() if yaml.Unmarshal(pluginConf.Config, c) != nil || instance.ValidateAndSetConfig(c) != nil { pluginConf.Enabled = false log.Printf("Plugin %s for user %d failed to initialize because it rejected the current config. It might be outdated. A default config is used and the user would need to enable it again.", pluginConf.ModulePath, pluginConf.UserID) newConf := bytes.NewBufferString("# Plugin initialization failed because it rejected the current config. It might be outdated.\r\n# A default plugin configuration is used:\r\n") d, _ := yaml.Marshal(c) newConf.Write(d) newConf.WriteString("\r\n") newConf.WriteString("# The original configuration: \r\n") oldConf := bufio.NewScanner(bytes.NewReader(pluginConf.Config)) for oldConf.Scan() { newConf.WriteString("# ") newConf.WriteString(oldConf.Text()) newConf.WriteString("\r\n") } pluginConf.Config = newConf.Bytes() m.db.UpdatePluginConf(pluginConf) instance.ValidateAndSetConfig(instance.DefaultConfig()) } } func (m *Manager) createPluginConf(instance compat.PluginInstance, info compat.Info, userID uint) (*model.PluginConf, error) { pluginConf := &model.PluginConf{ UserID: userID, ModulePath: info.ModulePath, Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, m.pluginConfExists), } if compat.HasSupport(instance, compat.Configurer) { pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig()) } if compat.HasSupport(instance, compat.Messenger) { app := &model.Application{ Token: auth.GenerateNotExistingToken(auth.GenerateApplicationToken, m.applicationExists), Name: info.String(), UserID: userID, Internal: true, Description: fmt.Sprintf("auto generated application for %s", info.ModulePath), } if err := m.db.CreateApplication(app); err != nil { return nil, err } pluginConf.ApplicationID = app.ID } if err := m.db.CreatePluginConf(pluginConf); err != nil { return nil, err } return pluginConf, nil } ================================================ FILE: plugin/manager_test.go ================================================ //go:build linux || darwin // +build linux darwin package plugin import ( "errors" "fmt" "net/http/httptest" "os" "os/exec" "path" "testing" "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin/compat" "github.com/gotify/server/v2/plugin/testing/mock" "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) const ( examplePluginPath = "github.com/gotify/server/v2/plugin/example/echo" mockPluginPath = mock.ModulePath danglingPluginPath = "github.com/gotify/server/v2/plugin/testing/removed" ) type ManagerSuite struct { suite.Suite db *testdb.Database manager *Manager e *gin.Engine msgReceiver chan MessageWithUserID tmpDir test.TmpDir } func (s *ManagerSuite) Notify(uid uint, message *model.MessageExternal) { s.msgReceiver <- MessageWithUserID{ Message: *message, UserID: uid, } } func (s *ManagerSuite) SetupSuite() { s.tmpDir = test.NewTmpDir("gotify_managersuite") test.WithWd(path.Join(test.GetProjectDir(), "./plugin/example/echo"), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + s.tmpDir.Path("echo.so")} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(s.T(), cmd.Run()) }) s.db = testdb.NewDBWithDefaultUser(s.T()) s.makeDanglingPluginConf(1) e := gin.New() manager, err := NewManager(s.db.GormDatabase, s.tmpDir.Path(), e.Group("/plugin/:id/custom/"), s) s.e = e assert.Nil(s.T(), err) p := new(mock.Plugin) assert.Nil(s.T(), manager.LoadPlugin(p)) assert.Nil(s.T(), manager.initializeSingleUserPlugin(compat.UserContext{ ID: 1, Admin: true, }, p)) s.manager = manager s.msgReceiver = make(chan MessageWithUserID) assert.Contains(s.T(), s.manager.plugins, examplePluginPath) if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, examplePluginPath); assert.NoError(s.T(), err) { assert.NotNil(s.T(), pluginConf) } } func (s *ManagerSuite) TearDownSuite() { assert.Nil(s.T(), s.tmpDir.Clean()) } func (s *ManagerSuite) getConfForExamplePlugin(uid uint) *model.PluginConf { pluginConf, err := s.db.GetPluginConfByUserAndPath(uid, examplePluginPath) assert.NoError(s.T(), err) return pluginConf } func (s *ManagerSuite) getConfForMockPlugin(uid uint) *model.PluginConf { pluginConf, err := s.db.GetPluginConfByUserAndPath(uid, mockPluginPath) assert.NoError(s.T(), err) return pluginConf } func (s *ManagerSuite) getMockPluginInstance(uid uint) *mock.PluginInstance { pid := s.getConfForMockPlugin(uid).ID return s.manager.instances[pid].(*mock.PluginInstance) } func (s *ManagerSuite) makeDanglingPluginConf(uid uint) *model.PluginConf { conf := &model.PluginConf{ UserID: uid, ModulePath: danglingPluginPath, Token: auth.GeneratePluginToken(), Enabled: true, } s.db.CreatePluginConf(conf) return conf } func (s *ManagerSuite) TestWebhook_blockedIfDisabled() { conf := s.getConfForExamplePlugin(1) t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) r := httptest.NewRecorder() s.e.ServeHTTP(r, t) assert.Equal(s.T(), 400, r.Code) } func (s *ManagerSuite) TestWebhook_successIfEnabled() { conf := s.getConfForExamplePlugin(1) assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, true)) defer func() { assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, false)) }() assert.True(s.T(), s.getConfForExamplePlugin(1).Enabled) t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) r := httptest.NewRecorder() s.e.ServeHTTP(r, t) assert.Equal(s.T(), 200, r.Code) } func (s *ManagerSuite) TestInitializePlugin_noOpIfEmpty() { assert.Nil(s.T(), s.manager.loadPlugins("")) } func (s *ManagerSuite) TestInitializePlugin_noOpIfDotFile() { tmpDir := test.NewTmpDir("gotify_testinitializeplugin_dotfile") defer tmpDir.Clean() f, err := os.Create(tmpDir.Path(".test")) assert.NoError(s.T(), err) _, err = f.WriteString("dummy") assert.NoError(s.T(), err) assert.NoError(s.T(), f.Close()) assert.Nil(s.T(), s.manager.loadPlugins(tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_noOpIfSubDir() { tmpDir := test.NewTmpDir("gotify_testinitializeplugin_subdir") defer tmpDir.Clean() os.Mkdir(tmpDir.Path("subdir"), 0o755) assert.Nil(s.T(), s.manager.loadPlugins(tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_directoryInvalid_expectError() { assert.Error(s.T(), s.manager.loadPlugins("<<")) } func (s *ManagerSuite) TestInitializePlugin_invalidPlugin_expectError() { assert.Error(s.T(), s.manager.loadPlugins(test.GetProjectDir())) } func (s *ManagerSuite) TestInitializePlugin_brokenPlugin_expectError() { tmpDir := test.NewTmpDir("gotify_testbrokenplugin") defer tmpDir.Clean() test.WithWd(path.Join(test.GetProjectDir(), "./plugin/testing/broken/nothing"), func(origWd string) { exec.Command("go", "get", "-d").Run() goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + tmpDir.Path("empty.so")} goBuildFlags = append(goBuildFlags, extraGoBuildFlags...) cmd := exec.Command("go", goBuildFlags...) cmd.Stderr = os.Stderr assert.Nil(s.T(), cmd.Run()) }) assert.Error(s.T(), s.manager.loadPlugins(tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_alreadyLoaded_expectError() { assert.Error(s.T(), s.manager.loadPlugins(s.tmpDir.Path())) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_expectAutoEnable() { s.db.User(2) s.db.CreatePluginConf(&model.PluginConf{ UserID: 2, ModulePath: mockPluginPath, Token: "P1234", Enabled: true, }) assert.Nil(s.T(), s.manager.InitializeForUserID(2)) inst := s.getMockPluginInstance(2) assert.True(s.T(), inst.Enabled) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_failedToLoadConfig_disableAutomatically() { s.db.User(3) s.db.CreatePluginConf(&model.PluginConf{ UserID: 3, ModulePath: mockPluginPath, Token: "Ptttt", Enabled: true, Config: []byte(`invalid: """`), }) assert.Nil(s.T(), s.manager.InitializeForUserID(3)) inst := s.getMockPluginInstance(3) assert.False(s.T(), inst.Enabled) } func (s *ManagerSuite) TestInitializePlugin_alreadyEnabled_cannotEnable_disabledAutomatically() { s.db.NewUserWithName(4, "enable_fail_2") mock.ReturnErrorOnEnableForUser(4, errors.New("test error")) s.db.CreatePluginConf(&model.PluginConf{ UserID: 4, ModulePath: mockPluginPath, Token: "P5478", Enabled: true, }) assert.Nil(s.T(), s.manager.InitializeForUserID(4)) inst := s.getMockPluginInstance(4) assert.False(s.T(), inst.Enabled) assert.False(s.T(), s.getConfForMockPlugin(4).Enabled) } func (s *ManagerSuite) TestInitializePlugin_userIDNotExist_expectError() { assert.Error(s.T(), s.manager.InitializeForUserID(99)) } func (s *ManagerSuite) TestSetPluginEnabled() { pid := s.getConfForMockPlugin(1).ID assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.Error(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, false)) } func (s *ManagerSuite) TestSetPluginEnabled_EnableReturnsError_cannotEnable() { s.db.NewUserWithName(5, "enable_fail") errExpected := errors.New("test error") mock.ReturnErrorOnEnableForUser(5, errExpected) assert.Nil(s.T(), s.manager.InitializeForUserID(5)) pid := s.getConfForMockPlugin(5).ID assert.Error(s.T(), s.manager.SetPluginEnabled(pid, false)) assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, true), errExpected.Error()) assert.False(s.T(), s.getConfForMockPlugin(5).Enabled) } func (s *ManagerSuite) TestSetPluginEnabled_DisableReturnsError_cannotDisable() { s.db.NewUserWithName(6, "disable_fail") errExpected := errors.New("test error") mock.ReturnErrorOnDisableForUser(6, errExpected) assert.Nil(s.T(), s.manager.InitializeForUserID(6)) pid := s.getConfForMockPlugin(6).ID assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, false), errExpected.Error()) assert.True(s.T(), s.getConfForMockPlugin(6).Enabled) } func (s *ManagerSuite) TestAddRemoveNewUser() { s.db.User(7) s.makeDanglingPluginConf(7) assert.Nil(s.T(), s.manager.InitializeForUserID(7)) pid := s.getConfForExamplePlugin(7).ID assert.True(s.T(), s.manager.HasInstance(pid)) assert.Nil(s.T(), s.manager.SetPluginEnabled(s.getConfForMockPlugin(7).ID, true)) assert.Nil(s.T(), s.manager.RemoveUser(7)) assert.False(s.T(), s.manager.HasInstance(pid)) } func (s *ManagerSuite) TestRemoveUser_DisableFail_cannotRemove() { s.manager.initializeForUser(*s.db.NewUserWithName(8, "disable_fail_2")) errExpected := errors.New("test error") mock.ReturnErrorOnDisableForUser(8, errExpected) s.manager.SetPluginEnabled(s.getConfForMockPlugin(8).ID, true) assert.EqualError(s.T(), s.manager.RemoveUser(8), errExpected.Error()) } func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() { // make a dangling conf for this instance s.db.User(9) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: mockPluginPath, Enabled: true, UserID: 9, Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), }) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: examplePluginPath, Enabled: true, UserID: 9, Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), }) assert.Nil(s.T(), s.manager.RemoveUser(9)) } func (s *ManagerSuite) TestTriggerMessage() { inst := s.getMockPluginInstance(1) inst.TriggerMessage() select { case msg := <-s.msgReceiver: assert.Equal(s.T(), uint(1), msg.UserID) assert.NotEmpty(s.T(), msg.Message.Extras) case <-time.After(1 * time.Second): assert.Fail(s.T(), "read message time out") } } func (s *ManagerSuite) TestStorage() { inst := s.getMockPluginInstance(1) assert.Nil(s.T(), inst.SetStorage([]byte("test"))) storage, err := inst.GetStorage() assert.Nil(s.T(), err) assert.Equal(s.T(), "test", string(storage)) } func (s *ManagerSuite) TestGetPluginInfo() { assert.Equal(s.T(), mock.Name, s.manager.PluginInfo(mock.ModulePath).Name) } func (s *ManagerSuite) TestGetPluginInfo_notFound_doNotPanic() { assert.NotPanics(s.T(), func() { s.manager.PluginInfo("not/exist") }) } func (s *ManagerSuite) TestSetPluginEnabled_expectNotFound() { assert.Error(s.T(), s.manager.SetPluginEnabled(99, true)) } func TestManagerSuite(t *testing.T) { suite.Run(t, new(ManagerSuite)) } func TestNewManager_CannotLoadDirectory_expectError(t *testing.T) { _, err := NewManager(nil, "<>", nil, nil) assert.Error(t, err) } func TestNewManager_NonPluginFile_expectError(t *testing.T) { _, err := NewManager(nil, path.Join(test.GetProjectDir(), "test/assets/"), nil, nil) assert.Error(t, err) } func TestNewManager_InternalApplicationManagement(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) { // Application exist, no plugin conf db.CreateApplication(&model.Application{ Token: "Ainternal_obsolete", Internal: true, Name: "obsolete plugin application", UserID: 1, }) if app, err := db.GetApplicationByToken("Ainternal_obsolete"); assert.NoError(t, err) { assert.True(t, app.Internal) } _, err := NewManager(db, "", nil, nil) assert.Nil(t, err) if app, err := db.GetApplicationByToken("Ainternal_obsolete"); assert.NoError(t, err) { assert.False(t, app.Internal) } } { // Application exist, conf exist, no compat assert.NoError(t, db.CreateApplication(&model.Application{ Token: "Ainternal_not_loaded", Internal: true, Name: "not loaded plugin application", UserID: 1, })) if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.NoError(t, db.CreatePluginConf(&model.PluginConf{ ApplicationID: app.ID, UserID: 1, Enabled: true, Token: auth.GeneratePluginToken(), })) } if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.True(t, app.Internal) } _, err := NewManager(db, "", nil, nil) assert.Nil(t, err) if app, err := db.GetApplicationByToken("Ainternal_not_loaded"); assert.NoError(t, err) { assert.False(t, app.Internal) } } { // Application exist, conf exist, has compat assert.NoError(t, db.CreateApplication(&model.Application{ Token: "Ainternal_loaded", Internal: false, Name: "not loaded plugin application", UserID: 1, })) if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.NoError(t, db.CreatePluginConf(&model.PluginConf{ ApplicationID: app.ID, UserID: 1, Enabled: true, ModulePath: mock.ModulePath, Token: auth.GeneratePluginToken(), })) } if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.False(t, app.Internal) } manager, err := NewManager(db, "", nil, nil) assert.Nil(t, err) assert.Nil(t, manager.LoadPlugin(new(mock.Plugin))) assert.Nil(t, manager.InitializeForUserID(1)) if app, err := db.GetApplicationByToken("Ainternal_loaded"); assert.NoError(t, err) { assert.True(t, app.Internal) } } } func TestPluginFileLoadError(t *testing.T) { err := pluginFileLoadError{Filename: "test.so", UnderlyingError: errors.New("test error")} assert.Error(t, err) assert.Contains(t, err.Error(), "test.so") assert.Contains(t, err.Error(), "test error") } ================================================ FILE: plugin/manager_test_norace.go ================================================ //go:build !race // +build !race package plugin var extraGoBuildFlags = []string{} ================================================ FILE: plugin/manager_test_race.go ================================================ //go:build race // +build race package plugin var extraGoBuildFlags = []string{"-race"} ================================================ FILE: plugin/messagehandler.go ================================================ package plugin import ( "time" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin/compat" ) type redirectToChannel struct { ApplicationID uint UserID uint Messages chan MessageWithUserID } // MessageWithUserID encapsulates a message with a given user ID. type MessageWithUserID struct { Message model.MessageExternal UserID uint } // SendMessage sends a message to the underlying message channel. func (c redirectToChannel) SendMessage(msg compat.Message) error { c.Messages <- MessageWithUserID{ Message: model.MessageExternal{ ApplicationID: c.ApplicationID, Message: msg.Message, Title: msg.Title, Priority: &msg.Priority, Date: time.Now(), Extras: msg.Extras, }, UserID: c.UserID, } return nil } ================================================ FILE: plugin/pluginenabled.go ================================================ package plugin import ( "errors" "github.com/gin-gonic/gin" ) func requirePluginEnabled(id uint, db Database) gin.HandlerFunc { return func(c *gin.Context) { conf, err := db.GetPluginConfByID(id) if err != nil { c.AbortWithError(500, err) return } if conf == nil || !conf.Enabled { c.AbortWithError(400, errors.New("plugin is disabled")) } } } ================================================ FILE: plugin/pluginenabled_test.go ================================================ package plugin import ( "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" ) func TestRequirePluginEnabled(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) conf := &model.PluginConf{ ID: 1, UserID: 1, Enabled: true, } db.CreatePluginConf(conf) g := gin.New() mux := g.Group("/", requirePluginEnabled(1, db)) mux.GET("/", func(c *gin.Context) { c.Status(200) }) getCode := func() int { r := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() g.ServeHTTP(w, r) return w.Code } assert.Equal(t, 200, getCode()) conf.Enabled = false db.UpdatePluginConf(conf) assert.Equal(t, 400, getCode()) } ================================================ FILE: plugin/storagehandler.go ================================================ package plugin type dbStorageHandler struct { pluginID uint db Database } func (c dbStorageHandler) Save(b []byte) error { conf, err := c.db.GetPluginConfByID(c.pluginID) if err != nil { return err } conf.Storage = b return c.db.UpdatePluginConf(conf) } func (c dbStorageHandler) Load() ([]byte, error) { pluginConf, err := c.db.GetPluginConfByID(c.pluginID) if err != nil { return nil, err } return pluginConf.Storage, nil } ================================================ FILE: plugin/testing/broken/cantinstantiate/main.go ================================================ package main import ( "errors" "github.com/gotify/plugin-api" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/noinstance", } } // Plugin is plugin instance type Plugin struct{} // Enable implements plugin.Plugin func (c *Plugin) Enable() error { return errors.New("cannot instantiate") } // Disable implements plugin.Plugin func (c *Plugin) Disable() error { return nil } // NewGotifyPluginInstance creates a plugin instance for a user context. func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { return &Plugin{} } func main() { panic("this is a broken plugin for testing purposes") } ================================================ FILE: plugin/testing/broken/malformedconstructor/main.go ================================================ package main import ( "github.com/gotify/plugin-api" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/malformedconstructor", } } // Plugin is plugin instance type Plugin struct{} // Enable implements plugin.Plugin func (c *Plugin) Enable() error { return nil } // Disable implements plugin.Plugin func (c *Plugin) Disable() error { return nil } // NewGotifyPluginInstance creates a plugin instance for a user context. func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} { return &Plugin{} } func main() { panic("this is a broken plugin for testing purposes") } ================================================ FILE: plugin/testing/broken/noinstance/main.go ================================================ package main import ( "github.com/gotify/plugin-api" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ ModulePath: "github.com/gotify/server/v2/plugin/testing/broken/noinstance", } } func main() { panic("this is a broken plugin for testing purposes") } ================================================ FILE: plugin/testing/broken/nothing/main.go ================================================ package main func main() { panic("this is a broken plugin for testing purposes") } ================================================ FILE: plugin/testing/broken/unknowninfo/main.go ================================================ package main // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() string { return "github.com/gotify/server/v2/plugin/testing/broken/unknowninfo" } func main() { panic("this is a broken plugin for testing purposes") } ================================================ FILE: plugin/testing/mock/mock.go ================================================ package mock import ( "errors" "net/url" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/plugin/compat" ) // ModulePath is for convenient access of the module path of this mock plugin const ModulePath = "github.com/gotify/server/v2/plugin/testing/mock" // Name is for convenient access of the module path of the name of this mock plugin const Name = "mock plugin" // Plugin is a mock plugin. type Plugin struct { Instances []PluginInstance } // PluginInfo implements loader.PluginCompat func (c *Plugin) PluginInfo() compat.Info { return compat.Info{ ModulePath: ModulePath, Name: Name, } } // NewPluginInstance implements loader.PluginCompat func (c *Plugin) NewPluginInstance(ctx compat.UserContext) compat.PluginInstance { inst := PluginInstance{UserCtx: ctx, capabilities: compat.Capabilities{compat.Configurer, compat.Storager, compat.Messenger, compat.Displayer}} c.Instances = append(c.Instances, inst) return &inst } // APIVersion implements loader.PluginCompat func (c *Plugin) APIVersion() string { return "v1" } // PluginInstance is a mock plugin instance type PluginInstance struct { UserCtx compat.UserContext Enabled bool DisplayString string Config *PluginConfig storageHandler compat.StorageHandler messageHandler compat.MessageHandler capabilities compat.Capabilities BasePath string } // PluginConfig is a mock plugin config struct type PluginConfig struct { TestKey string IsNotValid bool } var ( disableFailUsers = make(map[uint]error) enableFailUsers = make(map[uint]error) ) // ReturnErrorOnEnableForUser registers a uid which will throw an error on enabling. func ReturnErrorOnEnableForUser(uid uint, err error) { enableFailUsers[uid] = err } // ReturnErrorOnDisableForUser registers a uid which will throw an error on disabling. func ReturnErrorOnDisableForUser(uid uint, err error) { disableFailUsers[uid] = err } // Enable implements compat.PluginInstance func (c *PluginInstance) Enable() error { if err, ok := enableFailUsers[c.UserCtx.ID]; ok { return err } c.Enabled = true return nil } // Disable implements compat.PluginInstance func (c *PluginInstance) Disable() error { if err, ok := disableFailUsers[c.UserCtx.ID]; ok { return err } c.Enabled = false return nil } // SetMessageHandler implements compat.Messenger func (c *PluginInstance) SetMessageHandler(h compat.MessageHandler) { c.messageHandler = h } // SetStorageHandler implements compat.Storager func (c *PluginInstance) SetStorageHandler(handler compat.StorageHandler) { c.storageHandler = handler } // SetStorage sets current storage func (c *PluginInstance) SetStorage(b []byte) error { return c.storageHandler.Save(b) } // GetStorage sets current storage func (c *PluginInstance) GetStorage() ([]byte, error) { return c.storageHandler.Load() } // RegisterWebhook implements compat.Webhooker func (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.RouterGroup) { c.BasePath = basePath } // SetCapability changes the capability of this plugin func (c *PluginInstance) SetCapability(p compat.Capability, enable bool) { if enable { for _, cap := range c.capabilities { if cap == p { return } } c.capabilities = append(c.capabilities, p) } else { newCap := make(compat.Capabilities, 0) for _, cap := range c.capabilities { if cap == p { continue } newCap = append(newCap, cap) } c.capabilities = newCap } } // Supports implements compat.PluginInstance func (c *PluginInstance) Supports() compat.Capabilities { return c.capabilities } // DefaultConfig implements compat.Configuror func (c *PluginInstance) DefaultConfig() interface{} { return &PluginConfig{ TestKey: "default", IsNotValid: false, } } // ValidateAndSetConfig implements compat.Configuror func (c *PluginInstance) ValidateAndSetConfig(config interface{}) error { if (config.(*PluginConfig)).IsNotValid { return errors.New("conf is not valid") } c.Config = config.(*PluginConfig) return nil } // GetDisplay implements compat.Displayer func (c *PluginInstance) GetDisplay(url *url.URL) string { return c.DisplayString } // TriggerMessage triggers a test message func (c *PluginInstance) TriggerMessage() { c.messageHandler.SendMessage(compat.Message{ Title: "test message", Message: "test", Priority: 2, Extras: map[string]interface{}{ "test::string": "test", }, }) } ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", ":semanticCommits", ":semanticCommitTypeAll(chore)" ], "labels": [ "dependencies" ], "reviewersFromCodeOwners": true, "enabledManagers": [ "npm", "gomod", "github-actions", "dockerfile", "custom.regex" ], "customManagers": [ { "customType": "regex", "managerFilePatterns": [ "/^GO_VERSION$/" ], "depTypeTemplate": "language", "matchStrings": [ "^(?[0-9.]+)" ], "extractVersionTemplate": "^(?.+)-linux-amd64$", "depNameTemplate": "docker.io/gotify/build", "autoReplaceStringTemplate": "{{{newValue}}}", "datasourceTemplate": "docker", "versioningTemplate": "docker" }, { "customType": "regex", "managerFilePatterns": [ "/^go.mod$/" ], "depTypeTemplate": "language", "matchStrings": [ "toolchain go(?[0-9.]+)\\n" ], "extractVersionTemplate": "^(?.+)-linux-amd64$", "depNameTemplate": "docker.io/gotify/build", "autoReplaceStringTemplate": "toolchain go{{{newValue}}}\n", "datasourceTemplate": "docker", "versioningTemplate": "docker" } ], "ignoreDeps": [ "go" ], "packageRules": [ { "matchManagers": [ "gomod" ], "matchUpdateTypes": [ "minor", "patch" ], "groupName": "Bump Go dependencies", "groupSlug": "bump-dependencies-go" }, { "matchManagers": [ "npm" ], "matchUpdateTypes": [ "minor", "patch" ], "groupName": "Bump npm dependencies", "groupSlug": "bump-dependencies-npm" }, { "matchDatasources": ["npm"], "minimumReleaseAge": "3 days" }, { "matchDepNames": [ "github.com/gotify/build" ], "groupName": "Bump gotify/build", "groupSlug": "bump-gotify-build" } ], "postUpdateOptions": [ "gomodTidy" ] } ================================================ FILE: router/router.go ================================================ package router import ( "fmt" "net/http" "path/filepath" "regexp" "strings" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/gotify/location" "github.com/gotify/server/v2/api" "github.com/gotify/server/v2/api/stream" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/database" "github.com/gotify/server/v2/docs" gerror "github.com/gotify/server/v2/error" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/plugin" "github.com/gotify/server/v2/ui" ) // Create creates the gin engine with all routes. func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Configuration) (*gin.Engine, func()) { g := gin.New() g.RemoveExtraSlash = true g.RemoteIPHeaders = []string{"X-Forwarded-For"} g.SetTrustedProxies(conf.Server.TrustedProxies) g.ForwardedByClientIP = true g.Use(func(ctx *gin.Context) { // Map sockets "@" to 127.0.0.1, because gin-gonic can only trust IPs. if ctx.Request.RemoteAddr == "@" { ctx.Request.RemoteAddr = "127.0.0.1:65535" } }) g.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default()) g.NoRoute(gerror.NotFound()) if conf.Server.SSL.Enabled && conf.Server.SSL.RedirectToHTTPS { g.Use(func(ctx *gin.Context) { if ctx.Request.TLS != nil { ctx.Next() return } if ctx.Request.Method != http.MethodGet && ctx.Request.Method != http.MethodHead { ctx.Data(http.StatusBadRequest, "text/plain; charset=utf-8", []byte("Use HTTPS")) ctx.Abort() return } host := ctx.Request.Host if idx := strings.LastIndex(host, ":"); idx != -1 { host = host[:idx] } if conf.Server.SSL.Port != 443 { host = fmt.Sprintf("%s:%d", host, conf.Server.SSL.Port) } ctx.Redirect(http.StatusFound, fmt.Sprintf("https://%s%s", host, ctx.Request.RequestURI)) ctx.Abort() }) } streamHandler := stream.New( time.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins) go func() { ticker := time.NewTicker(5 * time.Minute) for range ticker.C { connectedTokens := streamHandler.CollectConnectedClientTokens() now := time.Now() db.UpdateClientTokensLastUsed(connectedTokens, &now) } }() authentication := auth.Auth{DB: db} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} clientHandler := api.ClientAPI{ DB: db, ImageDir: conf.UploadedImagesDir, NotifyDeleted: streamHandler.NotifyDeletedClient, } applicationHandler := api.ApplicationAPI{ DB: db, ImageDir: conf.UploadedImagesDir, } userChangeNotifier := new(api.UserChangeNotifier) userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration} pluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group("/plugin/:id/custom/"), streamHandler) if err != nil { panic(err) } pluginHandler := api.PluginAPI{ Manager: pluginManager, Notifier: streamHandler, DB: db, } userChangeNotifier.OnUserDeleted(streamHandler.NotifyDeletedUser) userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser) userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID) ui.Register(g, *vInfo, conf.Registration) g.Match([]string{"GET", "HEAD"}, "/health", healthHandler.Health) g.GET("/swagger", docs.Serve) g.StaticFS("/image", &onlyImageFS{inner: gin.Dir(conf.UploadedImagesDir, false)}) g.GET("/docs", docs.UI) g.Use(func(ctx *gin.Context) { ctx.Header("Content-Type", "application/json") for header, value := range conf.Server.ResponseHeaders { ctx.Header(header, value) } }) g.Use(cors.New(auth.CorsConfig(conf))) { g.GET("/plugin", authentication.RequireClient(), pluginHandler.GetPlugins) pluginRoute := g.Group("/plugin/", authentication.RequireClient()) { pluginRoute.GET("/:id/config", pluginHandler.GetConfig) pluginRoute.POST("/:id/config", pluginHandler.UpdateConfig) pluginRoute.GET("/:id/display", pluginHandler.GetDisplay) pluginRoute.POST("/:id/enable", pluginHandler.EnablePlugin) pluginRoute.POST("/:id/disable", pluginHandler.DisablePlugin) } } g.Group("/user").Use(authentication.Optional()).POST("", userHandler.CreateUser) g.OPTIONS("/*any") // swagger:operation GET /version version getVersion // // Get version information. // // --- // produces: [application/json] // responses: // 200: // description: Ok // schema: // $ref: "#/definitions/VersionInfo" g.GET("version", func(ctx *gin.Context) { ctx.JSON(200, vInfo) }) g.Group("/").Use(authentication.RequireApplicationToken()).POST("/message", messageHandler.CreateMessage) clientAuth := g.Group("") { clientAuth.Use(authentication.RequireClient()) app := clientAuth.Group("/application") { app.GET("", applicationHandler.GetApplications) app.POST("", applicationHandler.CreateApplication) app.POST("/:id/image", applicationHandler.UploadApplicationImage) app.DELETE("/:id/image", applicationHandler.RemoveApplicationImage) app.PUT("/:id", applicationHandler.UpdateApplication) app.DELETE("/:id", applicationHandler.DeleteApplication) tokenMessage := app.Group("/:id/message") { tokenMessage.GET("", messageHandler.GetMessagesWithApplication) tokenMessage.DELETE("", messageHandler.DeleteMessageWithApplication) } } client := clientAuth.Group("/client") { client.GET("", clientHandler.GetClients) client.POST("", clientHandler.CreateClient) client.DELETE("/:id", clientHandler.DeleteClient) client.PUT("/:id", clientHandler.UpdateClient) } message := clientAuth.Group("/message") { message.GET("", messageHandler.GetMessages) message.DELETE("", messageHandler.DeleteMessages) message.DELETE("/:id", messageHandler.DeleteMessage) } clientAuth.GET("/stream", streamHandler.Handle) clientAuth.GET("current/user", userHandler.GetCurrentUser) clientAuth.POST("current/user/password", userHandler.ChangePassword) } authAdmin := g.Group("/user") { authAdmin.Use(authentication.RequireAdmin()) authAdmin.GET("", userHandler.GetUsers) authAdmin.DELETE("/:id", userHandler.DeleteUserByID) authAdmin.GET("/:id", userHandler.GetUserByID) authAdmin.POST("/:id", userHandler.UpdateUserByID) } return g, streamHandler.Close } var tokenRegexp = regexp.MustCompile("token=[^&]+") func logFormatter(param gin.LogFormatterParams) string { if (param.ClientIP == "127.0.0.1" || param.ClientIP == "::1") && param.Path == "/health" { return "" } var statusColor, methodColor, resetColor string if param.IsOutputColor() { statusColor = param.StatusCodeColor() methodColor = param.MethodColor() resetColor = param.ResetColor() } if param.Latency > time.Minute { param.Latency = param.Latency - param.Latency%time.Second } path := tokenRegexp.ReplaceAllString(param.Path, "token=[masked]") return fmt.Sprintf("%v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", param.TimeStamp.Format(time.RFC3339), statusColor, param.StatusCode, resetColor, param.Latency, param.ClientIP, methodColor, param.Method, resetColor, path, param.ErrorMessage, ) } type onlyImageFS struct { inner http.FileSystem } func (fs *onlyImageFS) Open(name string) (http.File, error) { ext := filepath.Ext(name) if !api.ValidApplicationImageExt(ext) { return nil, fmt.Errorf("invalid file") } return fs.inner.Open(name) } ================================================ FILE: router/router_test.go ================================================ package router import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) var ( client = &http.Client{} forbiddenJSON = `{"error":"Forbidden", "errorCode":403, "errorDescription":"you are not allowed to access this api"}` ) func TestIntegrationSuite(t *testing.T) { suite.Run(t, new(IntegrationSuite)) } type IntegrationSuite struct { suite.Suite db *testdb.Database server *httptest.Server closable func() } func (s *IntegrationSuite) BeforeTest(string, string) { mode.Set(mode.TestDev) var err error s.db = testdb.NewDBWithDefaultUser(s.T()) assert.Nil(s.T(), err) g, closable := Create(s.db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config.Configuration{PassStrength: 5}, ) s.closable = closable s.server = httptest.NewServer(g) } func (s *IntegrationSuite) AfterTest(string, string) { s.closable() s.db.Close() s.server.Close() } func (s *IntegrationSuite) TestVersionInfo() { req := s.newRequest("GET", "version", "") doRequestAndExpect(s.T(), req, 200, `{"version":"1.0.0", "commit":"asdasds", "buildDate":"2018-02-20-17:30:47"}`) } func (s *IntegrationSuite) TestHeaderInDev() { mode.Set(mode.TestDev) req := s.newRequest("GET", "version", "") // Needs an origin to indicate that it is a CORS request req.Header.Add("Origin", "some-origin") res, err := client.Do(req) assert.Nil(s.T(), err) assert.NotEmpty(s.T(), res.Header.Get("Access-Control-Allow-Origin")) } func (s *IntegrationSuite) TestHeaderInProd() { mode.Set(mode.Prod) req := s.newRequest("GET", "version", "") res, err := client.Do(req) assert.Nil(s.T(), err) assert.Empty(s.T(), res.Header.Get("Access-Control-Allow-Origin")) } func TestHeadersFromConfiguration(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.ResponseHeaders = map[string]string{ "New-Cool-Header": "Nice", "Access-Control-Allow-Origin": "http://test1.com", } g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Content-Type", "application/json") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "http://test1.com", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, "Nice", res.Header.Get("New-Cool-Header")) } func TestHeadersFromCORSConfig(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.Cors.AllowOrigins = []string{"---", "http://test.com"} g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Content-Type", "application/json") req.Header.Add("Origin", "http://test.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "http://test.com", res.Header.Get("Access-Control-Allow-Origin")) } func TestInvalidOrigin(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.Cors.AllowOrigins = []string{"---", "http://test.com"} g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Origin", "http://test1.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, http.StatusForbidden, res.StatusCode) } func TestAllowedOriginFromResponseHeaders(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.ResponseHeaders = map[string]string{ "Access-Control-Allow-Origin": "http://test1.com", "Access-Control-Allow-Methods": "GET,POST", } g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Origin", "http://test1.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "http://test1.com", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, http.StatusOK, res.StatusCode) req.Header.Set("Origin", "http://example.com") res, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, "http://test1.com", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, http.StatusForbidden, res.StatusCode) } func TestAllowedWildcardOriginInHeader(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.ResponseHeaders = map[string]string{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST", } g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Origin", "http://test1.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, http.StatusOK, res.StatusCode) } func TestCORSHeaderRegex(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.Cors.AllowOrigins = []string{"---", "^http://test\\d{3}.com$"} g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Content-Type", "application/json") req.Header.Add("Origin", "http://test123.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, "http://test123.com", res.Header.Get("Access-Control-Allow-Origin")) } // We want headers in cors config to override the responseheaders config. func TestCORSConfigOverride(t *testing.T) { mode.Set(mode.Prod) db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} config.Server.ResponseHeaders = map[string]string{ "New-Cool-Header": "Nice", "Access-Control-Allow-Origin": "http://example.com/", "Access-Control-Allow-Methods": "321test", "Access-Control-Allow-Headers": "some-headers", } config.Server.Cors.AllowOrigins = []string{"http://test123.com", "aaa"} config.Server.Cors.AllowMethods = []string{"GET", "OPTIONS"} config.Server.Cors.AllowHeaders = []string{"Content-Type"} g, closable := Create(db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config, ) server := httptest.NewServer(g) defer func() { closable() server.Close() }() req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s", server.URL, "version"), nil) req.Header.Add("Content-Type", "application/json") req.Header.Add("Origin", "http://test123.com") assert.Nil(t, err) res, err := client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusNoContent, res.StatusCode) assert.Equal(t, "Nice", res.Header.Get("New-Cool-Header")) assert.Equal(t, "http://test123.com", res.Header.Get("Access-Control-Allow-Origin")) assert.Equal(t, "GET,OPTIONS", res.Header.Get("Access-Control-Allow-Methods")) assert.Equal(t, "Content-Type", res.Header.Get("Access-Control-Allow-Headers")) req.Header.Set("Origin", "http://example.com") res, err = client.Do(req) assert.Nil(t, err) assert.Equal(t, http.StatusForbidden, res.StatusCode) } func (s *IntegrationSuite) TestOptionsRequest() { req := s.newRequest("OPTIONS", "version", "") res, err := client.Do(req) assert.Nil(s.T(), err) assert.Equal(s.T(), res.StatusCode, 200) } func (s *IntegrationSuite) TestSendMessage() { req := s.newRequest("POST", "application", `{"name": "backup-server"}`) req.SetBasicAuth("admin", "pw") res, err := client.Do(req) assert.Nil(s.T(), err) assert.Equal(s.T(), 200, res.StatusCode) token := &model.Application{} json.NewDecoder(res.Body).Decode(token) assert.Equal(s.T(), "backup-server", token.Name) req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup done"}`) req.Header.Add("X-Gotify-Key", token.Token) res, err = client.Do(req) assert.Nil(s.T(), err) assert.Equal(s.T(), 200, res.StatusCode) req = s.newRequest("GET", "message", "") req.SetBasicAuth("admin", "pw") res, err = client.Do(req) assert.Nil(s.T(), err) assert.Equal(s.T(), 200, res.StatusCode) msgs := &model.PagedMessages{} json.NewDecoder(res.Body).Decode(&msgs) assert.Len(s.T(), msgs.Messages, 1) msg := msgs.Messages[0] assert.Equal(s.T(), "backup done", msg.Message) assert.Equal(s.T(), "backup done", msg.Title) assert.Equal(s.T(), uint(1), msg.ID) assert.Equal(s.T(), token.ID, msg.ApplicationID) } func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() { db := testdb.NewDBWithDefaultUser(s.T()) defer db.Close() assert.Panics(s.T(), func() { Create(db.GormDatabase, new(model.VersionInfo), &config.Configuration{ PluginsDir: "", }) }) } func (s *IntegrationSuite) TestAuthentication() { req := s.newRequest("GET", "current/user", "") req.SetBasicAuth("admin", "pw") doRequestAndExpect(s.T(), req, 200, `{"id": 1, "name": "admin", "admin": true}`) req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("jmattheis", "pw") doRequestAndExpect(s.T(), req, 401, `{"error":"Unauthorized", "errorCode":401, "errorDescription":"you need to provide a valid access token or user credentials to access this api"}`) req = s.newRequest("POST", "user", `{"name": "normal", "pass": "secret"}`) req.SetBasicAuth("admin", "pw") doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false}`) req = s.newRequest("POST", "user", `{"name": "normal2", "pass": "secret"}`) req.SetBasicAuth("normal", "secret") doRequestAndExpect(s.T(), req, 403, forbiddenJSON) req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup"}`) req.SetBasicAuth("normal", "secret") doRequestAndExpect(s.T(), req, 403, forbiddenJSON) req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("normal", "secret") doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false}`) req = s.newRequest("POST", "client", `{"name": "android-client"}`) req.SetBasicAuth("normal", "secret") res, err := client.Do(req) assert.Nil(s.T(), err) assert.Equal(s.T(), 200, res.StatusCode) token := &model.Application{} json.NewDecoder(res.Body).Decode(token) assert.Equal(s.T(), "android-client", token.Name) } func (s *IntegrationSuite) newRequest(method, url, body string) *http.Request { req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", s.server.URL, url), strings.NewReader(body)) req.Header.Add("Content-Type", "application/json") assert.Nil(s.T(), err) return req } func doRequestAndExpect(t *testing.T, req *http.Request, code int, json string) { res, err := client.Do(req) assert.Nil(t, err) buf := new(bytes.Buffer) buf.ReadFrom(res.Body) assert.Equal(t, code, res.StatusCode) assert.JSONEq(t, json, buf.String()) } ================================================ FILE: runner/runner.go ================================================ package runner import ( "context" "fmt" "log" "net" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/gotify/server/v2/config" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" ) // Run starts the http server and if configured a https server. func Run(router http.Handler, conf *config.Configuration) error { shutdown := make(chan error) go doShutdownOnSignal(shutdown) httpListener, err := startListening("plain connection", conf.Server.ListenAddr, conf.Server.Port, conf.Server.KeepAlivePeriodSeconds) if err != nil { return err } defer httpListener.Close() s := &http.Server{Handler: router} if conf.Server.SSL.Enabled { if conf.Server.SSL.LetsEncrypt.Enabled { applyLetsEncrypt(s, conf) } else if conf.Server.SSL.CertFile == "" || conf.Server.SSL.CertKey == "" { log.Fatalln("CertFile and CertKey must be set to use HTTPS when LetsEncrypt is disabled, please set GOTIFY_SERVER_SSL_CERTFILE and GOTIFY_SERVER_SSL_CERTKEY") } httpsListener, err := startListening("TLS connection", conf.Server.SSL.ListenAddr, conf.Server.SSL.Port, conf.Server.KeepAlivePeriodSeconds) if err != nil { return err } defer httpsListener.Close() go func() { err := s.ServeTLS(httpsListener, conf.Server.SSL.CertFile, conf.Server.SSL.CertKey) doShutdown(shutdown, err) }() } go func() { err := s.Serve(httpListener) doShutdown(shutdown, err) }() err = <-shutdown fmt.Println("Shutting down:", err) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return s.Shutdown(ctx) } func doShutdownOnSignal(shutdown chan<- error) { onSignal := make(chan os.Signal, 1) signal.Notify(onSignal, os.Interrupt, syscall.SIGTERM) sig := <-onSignal doShutdown(shutdown, fmt.Errorf("received signal %s", sig)) } func doShutdown(shutdown chan<- error, err error) { select { case shutdown <- err: default: // If there is no one listening on the shutdown channel, then the // shutdown is already initiated and we can ignore these errors. } } func startListening(connectionType, listenAddr string, port, keepAlive int) (net.Listener, error) { network, addr := getNetworkAndAddr(listenAddr, port) lc := net.ListenConfig{KeepAlive: time.Duration(keepAlive) * time.Second} oldMask := umask(0) defer umask(oldMask) l, err := lc.Listen(context.Background(), network, addr) if err == nil { fmt.Println("Started listening for", connectionType, "on", l.Addr().Network(), l.Addr().String()) } return l, err } func getNetworkAndAddr(listenAddr string, port int) (string, string) { if strings.HasPrefix(listenAddr, "unix:") { return "unix", strings.TrimPrefix(listenAddr, "unix:") } return "tcp", fmt.Sprintf("%s:%d", listenAddr, port) } type LoggingRoundTripper struct { Name string RoundTripper http.RoundTripper } func (l *LoggingRoundTripper) RoundTrip(r *http.Request) (resp *http.Response, err error) { resp, err = l.RoundTripper.RoundTrip(r) if resp.StatusCode == 429 { log.Printf("%s Rate Limited: Retry-After %s on %s %s\n", l.Name, resp.Header.Get("Retry-After"), r.Method, r.URL.String()) } else if resp.StatusCode >= 400 { log.Printf("%s Request Failed: Unexpected status code %d on %s %s\n", l.Name, resp.StatusCode, r.Method, r.URL.String()) } else if err != nil { log.Printf("%s Request Failed: %s on %s %s\n", l.Name, err.Error(), r.Method, r.URL.String()) } return resp, err } func applyLetsEncrypt(s *http.Server, conf *config.Configuration) { httpClient := &http.Client{ Transport: &LoggingRoundTripper{Name: "Let's Encrypt", RoundTripper: http.DefaultTransport}, Timeout: 60 * time.Second, } acmeClient := &acme.Client{ HTTPClient: httpClient, DirectoryURL: conf.Server.SSL.LetsEncrypt.DirectoryURL, } certManager := autocert.Manager{ Client: acmeClient, Prompt: func(tosURL string) bool { if !conf.Server.SSL.LetsEncrypt.AcceptTOS { log.Fatalf("Let's Encrypt TOS must be accepted to use Let's Encrypt, please acknowledge TOS at %s and set GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS=true\n", tosURL) } return true }, HostPolicy: autocert.HostWhitelist(conf.Server.SSL.LetsEncrypt.Hosts...), Cache: autocert.DirCache(conf.Server.SSL.LetsEncrypt.Cache), } s.Handler = certManager.HTTPHandler(s.Handler) s.TLSConfig = certManager.TLSConfig() } ================================================ FILE: runner/umask.go ================================================ //go:build unix package runner import "syscall" var umask = syscall.Umask ================================================ FILE: runner/umask_fallback.go ================================================ //go:build !unix package runner func umask(_ int) int { return 0 } ================================================ FILE: test/asserts.go ================================================ package test import ( "encoding/json" "errors" "io" "net/http/httptest" "github.com/stretchr/testify/assert" ) // BodyEquals asserts the content from the response recorder with the encoded json of the provided instance. func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) { bytes, err := io.ReadAll(recorder.Body) assert.Nil(t, err) actual := string(bytes) JSONEquals(t, obj, actual) } // JSONEquals asserts the content of the string with the encoded json of the provided instance. func JSONEquals(t assert.TestingT, obj interface{}, expected string) { bytes, err := json.Marshal(obj) assert.Nil(t, err) objJSON := string(bytes) assert.JSONEq(t, expected, objJSON) } type unreadableReader struct{} func (c unreadableReader) Read([]byte) (int, error) { return 0, errors.New("this reader cannot be read") } // UnreadableReader returns an unreadable reader, used to mock IO issues. func UnreadableReader() io.Reader { return unreadableReader{} } ================================================ FILE: test/asserts_test.go ================================================ package test_test import ( "io" "net/http/httptest" "testing" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" ) type obj struct { Test string ID int } type fakeTesting struct { hasErrors bool } func (t *fakeTesting) Errorf(format string, args ...interface{}) { t.hasErrors = true } func Test_BodyEquals(t *testing.T) { recorder := httptest.NewRecorder() recorder.WriteString(`{"ID": 2, "Test": "asd"}`) fakeTesting := &fakeTesting{} test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) assert.False(t, fakeTesting.hasErrors) } func Test_BodyEquals_failing(t *testing.T) { recorder := httptest.NewRecorder() recorder.WriteString(`{"ID": 3, "Test": "asd"}`) fakeTesting := &fakeTesting{} test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) assert.True(t, fakeTesting.hasErrors) } func Test_UnreaableReader(t *testing.T) { _, err := io.ReadAll(test.UnreadableReader()) assert.Error(t, err) } ================================================ FILE: test/assets/text.txt ================================================ ================================================ FILE: test/auth.go ================================================ package test import ( "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" ) // WithUser fake an authentication for testing. func WithUser(ctx *gin.Context, userID uint) { ctx.Set("user", &model.User{ID: userID}) ctx.Set("userid", userID) } ================================================ FILE: test/auth_test.go ================================================ package test_test import ( "testing" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" ) func TestFakeAuth(t *testing.T) { mode.Set(mode.TestDev) ctx, _ := gin.CreateTestContext(nil) test.WithUser(ctx, 5) assert.Equal(t, uint(5), auth.GetUserID(ctx)) } ================================================ FILE: test/filepath.go ================================================ package test import ( "os" "path" "path/filepath" "runtime" ) // GetProjectDir returns the correct absolute path of this project. func GetProjectDir() string { _, f, _, _ := runtime.Caller(0) projectDir, _ := filepath.Abs(path.Join(filepath.Dir(f), "../")) return projectDir } // WithWd executes a function with the specified working directory. func WithWd(chDir string, f func(origWd string)) { wd, err := os.Getwd() if err != nil { panic(err) } if err := os.Chdir(chDir); err != nil { panic(err) } defer os.Chdir(wd) f(wd) } ================================================ FILE: test/filepath_test.go ================================================ package test import ( "os" "path" "testing" "github.com/stretchr/testify/assert" ) func TestProjectPath(t *testing.T) { _, err := os.Stat(path.Join(GetProjectDir(), "./README.md")) assert.Nil(t, err) } func TestWithWd(t *testing.T) { wd1, _ := os.Getwd() tmpDir := NewTmpDir("gotify_withwd") defer tmpDir.Clean() var wd2 string WithWd(tmpDir.Path(), func(origWd string) { assert.Equal(t, wd1, origWd) wd2, _ = os.Getwd() }) wd3, _ := os.Getwd() assert.Equal(t, wd1, wd3) assert.Equal(t, tmpDir.Path(), wd2) assert.Nil(t, os.RemoveAll(tmpDir.Path())) assert.Panics(t, func() { WithWd("non_exist", func(string) {}) }) assert.Nil(t, os.Mkdir(tmpDir.Path(), 0o644)) if os.Getuid() != 0 { // root is not subject to this check assert.Panics(t, func() { WithWd(tmpDir.Path(), func(string) {}) }) } assert.Nil(t, os.Remove(tmpDir.Path())) assert.Nil(t, os.Mkdir(tmpDir.Path(), 0o755)) assert.Panics(t, func() { WithWd(tmpDir.Path(), func(string) { assert.Nil(t, os.RemoveAll(tmpDir.Path())) WithWd(".", func(string) {}) }) }) } ================================================ FILE: test/testdb/database.go ================================================ package testdb import ( "fmt" "testing" "time" "github.com/gotify/server/v2/database" "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) // Database is the wrapper for the gorm database with sleek helper methods. type Database struct { *database.GormDatabase t *testing.T } // AppClientBuilder has helper methods to create applications and clients. type AppClientBuilder struct { userID uint db *Database } // MessageBuilder has helper methods to create messages. type MessageBuilder struct { appID uint db *Database } // NewDBWithDefaultUser creates a new test db instance with the default user. func NewDBWithDefaultUser(t *testing.T) *Database { db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true) assert.Nil(t, err) assert.NotNil(t, db) return &Database{GormDatabase: db, t: t} } // NewDB creates a new test db instance. func NewDB(t *testing.T) *Database { db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, false) assert.Nil(t, err) assert.NotNil(t, db) return &Database{GormDatabase: db, t: t} } // User creates a user and returns a builder for applications and clients. func (d *Database) User(id uint) *AppClientBuilder { d.NewUser(id) return &AppClientBuilder{db: d, userID: id} } // NewUser creates a user and returns the user. func (d *Database) NewUser(id uint) *model.User { return d.NewUserWithName(id, "user"+fmt.Sprint(id)) } // NewUserWithName creates a user with a name and returns the user. func (d *Database) NewUserWithName(id uint, name string) *model.User { user := &model.User{ID: id, Name: name} d.CreateUser(user) return user } // App creates an application and returns a message builder. func (ab *AppClientBuilder) App(id uint) *MessageBuilder { return ab.app(id, false) } // InternalApp creates an internal application and returns a message builder. func (ab *AppClientBuilder) InternalApp(id uint) *MessageBuilder { return ab.app(id, true) } func (ab *AppClientBuilder) app(id uint, internal bool) *MessageBuilder { return ab.appWithToken(id, "app"+fmt.Sprint(id), internal) } // AppWithToken creates an application with a token and returns a message builder. func (ab *AppClientBuilder) AppWithToken(id uint, token string) *MessageBuilder { return ab.appWithToken(id, token, false) } // InternalAppWithToken creates an internal application with a token and returns a message builder. func (ab *AppClientBuilder) InternalAppWithToken(id uint, token string) *MessageBuilder { return ab.appWithToken(id, token, true) } func (ab *AppClientBuilder) appWithToken(id uint, token string, internal bool) *MessageBuilder { ab.newAppWithToken(id, token, internal) return &MessageBuilder{db: ab.db, appID: id} } // NewAppWithToken creates an application with a token and returns the app. func (ab *AppClientBuilder) NewAppWithToken(id uint, token string) *model.Application { return ab.newAppWithToken(id, token, false) } // NewInternalAppWithToken creates an internal application with a token and returns the app. func (ab *AppClientBuilder) NewInternalAppWithToken(id uint, token string) *model.Application { return ab.newAppWithToken(id, token, true) } func (ab *AppClientBuilder) newAppWithToken(id uint, token string, internal bool) *model.Application { application := &model.Application{ID: id, UserID: ab.userID, Token: token, Internal: internal} ab.db.CreateApplication(application) return application } // AppWithTokenAndName creates an application with a token and name and returns a message builder. func (ab *AppClientBuilder) AppWithTokenAndName(id uint, token, name string) *MessageBuilder { return ab.appWithTokenAndName(id, token, name, false) } // InternalAppWithTokenAndName creates an internal application with a token and name and returns a message builder. func (ab *AppClientBuilder) InternalAppWithTokenAndName(id uint, token, name string) *MessageBuilder { return ab.appWithTokenAndName(id, token, name, true) } func (ab *AppClientBuilder) appWithTokenAndName(id uint, token, name string, internal bool) *MessageBuilder { ab.newAppWithTokenAndName(id, token, name, internal) return &MessageBuilder{db: ab.db, appID: id} } // NewAppWithTokenAndName creates an application with a token and name and returns the app. func (ab *AppClientBuilder) NewAppWithTokenAndName(id uint, token, name string) *model.Application { return ab.newAppWithTokenAndName(id, token, name, false) } // NewInternalAppWithTokenAndName creates an internal application with a token and name and returns the app. func (ab *AppClientBuilder) NewInternalAppWithTokenAndName(id uint, token, name string) *model.Application { return ab.newAppWithTokenAndName(id, token, name, true) } func (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, name string, internal bool) *model.Application { application := &model.Application{ID: id, UserID: ab.userID, Token: token, Name: name, Internal: internal} ab.db.CreateApplication(application) return application } // AppWithTokenAndDefaultPriority creates an application with a token and defaultPriority and returns a message builder. func (ab *AppClientBuilder) AppWithTokenAndDefaultPriority(id uint, token string, defaultPriority int) *MessageBuilder { application := &model.Application{ID: id, UserID: ab.userID, Token: token, DefaultPriority: defaultPriority} ab.db.CreateApplication(application) return &MessageBuilder{db: ab.db, appID: id} } // Client creates a client and returns itself. func (ab *AppClientBuilder) Client(id uint) *AppClientBuilder { return ab.ClientWithToken(id, "client"+fmt.Sprint(id)) } // ClientWithToken creates a client with a token and returns itself. func (ab *AppClientBuilder) ClientWithToken(id uint, token string) *AppClientBuilder { ab.NewClientWithToken(id, token) return ab } // NewClientWithToken creates a client with a token and returns the client. func (ab *AppClientBuilder) NewClientWithToken(id uint, token string) *model.Client { client := &model.Client{ID: id, Token: token, UserID: ab.userID} ab.db.CreateClient(client) return client } // Message creates a message and returns itself. func (mb *MessageBuilder) Message(id uint) *MessageBuilder { mb.NewMessage(id) return mb } // NewMessage creates a message and returns the message. func (mb *MessageBuilder) NewMessage(id uint) model.Message { message := model.Message{ID: id, ApplicationID: mb.appID} mb.db.CreateMessage(&message) return message } // AssertAppNotExist asserts that the app does not exist. func (d *Database) AssertAppNotExist(id uint) { if app, err := d.GetApplicationByID(id); assert.NoError(d.t, err) { assert.True(d.t, app == nil, "app %d must not exist", id) } } // AssertUserNotExist asserts that the user does not exist. func (d *Database) AssertUserNotExist(id uint) { if user, err := d.GetUserByID(id); assert.NoError(d.t, err) { assert.True(d.t, user == nil, "user %d must not exist", id) } } // AssertUsernameNotExist asserts that the user does not exist. func (d *Database) AssertUsernameNotExist(name string) { if user, err := d.GetUserByName(name); assert.NoError(d.t, err) { assert.True(d.t, user == nil, "user %d must not exist", name) } } // AssertClientNotExist asserts that the client does not exist. func (d *Database) AssertClientNotExist(id uint) { if client, err := d.GetClientByID(id); assert.NoError(d.t, err) { assert.True(d.t, client == nil, "client %d must not exist", id) } } // AssertMessageNotExist asserts that the messages does not exist. func (d *Database) AssertMessageNotExist(ids ...uint) { for _, id := range ids { if msg, err := d.GetMessageByID(id); assert.NoError(d.t, err) { assert.True(d.t, msg == nil, "message %d must not exist", id) } } } // AssertAppExist asserts that the app does exist. func (d *Database) AssertAppExist(id uint) { if app, err := d.GetApplicationByID(id); assert.NoError(d.t, err) { assert.False(d.t, app == nil, "app %d must exist", id) } } // AssertUserExist asserts that the user does exist. func (d *Database) AssertUserExist(id uint) { if user, err := d.GetUserByID(id); assert.NoError(d.t, err) { assert.False(d.t, user == nil, "user %d must exist", id) } } // AssertClientExist asserts that the client does exist. func (d *Database) AssertClientExist(id uint) { if client, err := d.GetClientByID(id); assert.NoError(d.t, err) { assert.False(d.t, client == nil, "client %d must exist", id) } } // AssertMessageExist asserts that the message does exist. func (d *Database) AssertMessageExist(id uint) { if msg, err := d.GetMessageByID(id); assert.NoError(d.t, err) { assert.False(d.t, msg == nil, "message %d must exist", id) } } ================================================ FILE: test/testdb/database_test.go ================================================ package testdb_test import ( "testing" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func Test_WithDefault(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) if user, err := db.GetUserByName("admin"); assert.NoError(t, err) { assert.NotNil(t, user) } db.Close() } func TestDatabaseSuite(t *testing.T) { suite.Run(t, new(DatabaseSuite)) } type DatabaseSuite struct { suite.Suite db *testdb.Database } func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.db = testdb.NewDB(s.T()) } func (s *DatabaseSuite) AfterTest(suiteName, testName string) { s.db.Close() } func (s *DatabaseSuite) Test_Users() { s.db.User(1) newUserActual := s.db.NewUser(2) s.db.NewUserWithName(3, "tom") newUserExpected := &model.User{ID: 2, Name: "user2"} assert.Equal(s.T(), newUserExpected, newUserActual) users := []*model.User{{ID: 1, Name: "user1"}, {ID: 2, Name: "user2"}, {ID: 3, Name: "tom"}} if usersActual, err := s.db.GetUsers(); assert.NoError(s.T(), err) { assert.Equal(s.T(), users, usersActual) } s.db.AssertUserExist(1) s.db.AssertUserExist(2) s.db.AssertUserExist(3) s.db.AssertUserNotExist(4) s.db.DeleteUserByID(2) s.db.AssertUserNotExist(2) } func (s *DatabaseSuite) Test_Clients() { userBuilder := s.db.User(1) userBuilder.Client(1) newClientActual := userBuilder.NewClientWithToken(2, "asdf") s.db.User(2).Client(5) newClientExpected := &model.Client{ID: 2, Token: "asdf", UserID: 1} assert.Equal(s.T(), newClientExpected, newClientActual) userOneExpected := []*model.Client{{ID: 1, Token: "client1", UserID: 1}, {ID: 2, Token: "asdf", UserID: 1}} if clients, err := s.db.GetClientsByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, clients) } userTwoExpected := []*model.Client{{ID: 5, Token: "client5", UserID: 2}} if clients, err := s.db.GetClientsByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, clients) } s.db.AssertClientExist(1) s.db.AssertClientExist(2) s.db.AssertClientNotExist(3) s.db.AssertClientNotExist(4) s.db.AssertClientExist(5) s.db.AssertClientNotExist(6) s.db.DeleteClientByID(2) s.db.AssertClientNotExist(2) } func (s *DatabaseSuite) Test_Apps() { userBuilder := s.db.User(1) userBuilder.App(1) newAppActual := userBuilder.NewAppWithToken(2, "asdf") newInternalAppActual := userBuilder.NewInternalAppWithToken(3, "qwer") s.db.User(2).InternalApp(5) newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"} newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"} assert.Equal(s.T(), newAppExpected, newAppActual) assert.Equal(s.T(), newInternalAppExpected, newInternalAppActual) userOneExpected := []*model.Application{ {ID: 1, Token: "app1", UserID: 1, SortKey: "a0"}, {ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"}, {ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"}, } if app, err := s.db.GetApplicationsByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, app) } userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true, SortKey: "a0"}} if app, err := s.db.GetApplicationsByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, app) } newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name") newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name", SortKey: "a3"} assert.Equal(s.T(), newAppWithNameExpected, newAppWithName) newInternalAppWithName := userBuilder.NewInternalAppWithTokenAndName(8, "test-tokeni", "app name") newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true, SortKey: "a4"} assert.Equal(s.T(), newInternalAppWithNameExpected, newInternalAppWithName) userBuilder.AppWithTokenAndName(9, "test-token-2", "app name") userBuilder.InternalAppWithTokenAndName(10, "test-tokeni-2", "app name") userBuilder.AppWithToken(11, "test-token-3") userBuilder.InternalAppWithToken(12, "test-tokeni-3") userBuilder.AppWithTokenAndDefaultPriority(13, "test-tokeni-4", 4) s.db.AssertAppExist(1) s.db.AssertAppExist(2) s.db.AssertAppExist(3) s.db.AssertAppNotExist(4) s.db.AssertAppExist(5) s.db.AssertAppNotExist(6) s.db.AssertAppExist(7) s.db.AssertAppExist(8) s.db.AssertAppExist(9) s.db.AssertAppExist(10) s.db.AssertAppExist(11) s.db.AssertAppExist(12) s.db.AssertAppExist(13) s.db.DeleteApplicationByID(2) s.db.AssertAppNotExist(2) } func (s *DatabaseSuite) Test_Messages() { s.db.User(1).App(1).Message(1).Message(2) s.db.User(2).App(2).Message(4).Message(5) userOneExpected := []*model.Message{{ID: 2, ApplicationID: 1}, {ID: 1, ApplicationID: 1}} if msgs, err := s.db.GetMessagesByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, msgs) } userTwoExpected := []*model.Message{{ID: 5, ApplicationID: 2}, {ID: 4, ApplicationID: 2}} if msgs, err := s.db.GetMessagesByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, msgs) } s.db.AssertMessageExist(1) s.db.AssertMessageExist(2) s.db.AssertMessageExist(4) s.db.AssertMessageExist(5) s.db.AssertMessageNotExist(3, 6, 7, 8) s.db.DeleteMessageByID(2) s.db.AssertMessageNotExist(2) } ================================================ FILE: test/tmpdir.go ================================================ package test import ( "os" "path" ) // TmpDir is a handler to temporary directory. type TmpDir struct { path string } // Path returns the path to the temporary directory joined by the elements provided. func (c TmpDir) Path(elem ...string) string { return path.Join(append([]string{c.path}, elem...)...) } // Clean removes the TmpDir. func (c TmpDir) Clean() error { return os.RemoveAll(c.path) } // NewTmpDir returns a new handle to a tmp dir. func NewTmpDir(prefix string) TmpDir { dir, _ := os.MkdirTemp("", prefix) return TmpDir{dir} } ================================================ FILE: test/tmpdir_test.go ================================================ package test import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestTmpDir(t *testing.T) { dir := NewTmpDir("test_prefix") assert.NotEmpty(t, dir) assert.Contains(t, dir.Path(), "test_prefix") testFilePath := dir.Path("testfile.txt") assert.Contains(t, testFilePath, "test_prefix") assert.Contains(t, testFilePath, "testfile.txt") assert.True(t, strings.HasPrefix(testFilePath, dir.Path())) } ================================================ FILE: test/token.go ================================================ package test import "sync" // Tokens returns a token generation function with takes a series of tokens and output them in order. func Tokens(tokens ...string) func() string { var i int lock := sync.Mutex{} return func() string { lock.Lock() defer lock.Unlock() res := tokens[i%len(tokens)] i++ return res } } ================================================ FILE: test/token_test.go ================================================ package test import ( "testing" "github.com/stretchr/testify/assert" ) func TestTokenGeneration(t *testing.T) { mockTokenFunc := Tokens("a", "b", "c") for _, expected := range []string{"a", "b", "c", "a", "b", "c"} { assert.Equal(t, expected, mockTokenFunc()) } } ================================================ FILE: ui/.gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # dependencies /node_modules # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: ui/.prettierrc ================================================ { "printWidth": 100, "tabWidth": 4, "useTabs": false, "semi": true, "singleQuote": true, "trailingComma": "es5", "bracketSpacing": false, "bracketSameLine": true, "arrowParens": "always", "parser": "typescript" } ================================================ FILE: ui/.yarnrc ================================================ enableTelemetry "0" ================================================ FILE: ui/eslint.config.mjs ================================================ // @ts-check import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended); ================================================ FILE: ui/index.html ================================================ Gotify
================================================ FILE: ui/package.json ================================================ { "name": "gotify-ui", "version": "0.2.0", "private": true, "homepage": ".", "proxy": "http://localhost:80", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@uiw/codemirror-theme-material": "^4.24.2", "@uiw/react-codemirror": "^4.24.2", "@vitejs/plugin-react": "^5.0.0", "axios": "^1.11.0", "detect-browser": "^5.3.0", "fractional-indexing": "^3.2.0", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", "mobx-utils": "^6.1.1", "notifyjs": "^3.0.0", "notistack": "^3.0.2", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", "react-timeago": "^8.3.0", "react-virtuoso": "^4.13.0", "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.2", "tss-react": "^4.9.19", "typeface-roboto": "1.1.13", "vite": "^7.0.6", "vitest": "^4.0.0" }, "scripts": { "start": "vite", "prebuild": "tsc", "build": "vite build", "test": "vitest --disable-console-intercept --no-file-parallelism", "lint": "eslint \"src/**/*.{ts,tsx}\"", "format": "prettier \"src/**/*.{ts,tsx}\" --write", "testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different" }, "devDependencies": { "@eslint/js": "^9.32.0", "@types/notifyjs": "^3.0.5", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@types/react-router-dom": "^5.3.3", "@types/remove-markdown": "^0.3.4", "eslint": "^9.32.0", "get-port": "^7.1.0", "prettier": "^3.6.2", "puppeteer": "^24.15.0", "rimraf": "^6.0.1", "tree-kill": "^1.2.0", "typescript": "^5.9.2", "typescript-eslint": "^8.38.0", "wait-on": "^9.0.0" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: ui/public/manifest.json ================================================ { "short_name": "Gotify", "name": "Gotify WebApp", "start_url": "./index.html", "display": "standalone", "theme_color": "#3f51b5", "background_color": "#303030" } ================================================ FILE: ui/serve.go ================================================ package ui import ( "embed" "encoding/json" "io/fs" "net/http" "strings" "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/model" ) //go:embed build/* var box embed.FS type uiConfig struct { Register bool `json:"register"` Version model.VersionInfo `json:"version"` } // Register registers the ui on the root path. func Register(r *gin.Engine, version model.VersionInfo, register bool) { uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register}) if err != nil { panic(err) } replaceConfig := func(content string) string { return strings.Replace(content, "%CONFIG%", string(uiConfigBytes), 1) } ui := r.Group("/", gzip.Gzip(gzip.DefaultCompression)) ui.GET("/", serveFile("index.html", "text/html", replaceConfig)) ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig)) ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop)) subBox, err := fs.Sub(box, "build") if err != nil { panic(err) } ui.GET("/static/*any", gin.WrapH(http.FileServer(http.FS(subBox)))) } func noop(s string) string { return s } func serveFile(name, contentType string, convert func(string) string) gin.HandlerFunc { content, err := box.ReadFile("build/" + name) if err != nil { panic(err) } converted := convert(string(content)) return func(ctx *gin.Context) { ctx.Header("Content-Type", contentType) ctx.String(200, converted) } } ================================================ FILE: ui/src/CurrentUser.ts ================================================ import axios, {AxiosError, AxiosResponse} from 'axios'; import * as config from './config'; import {detect} from 'detect-browser'; import {SnackReporter} from './snack/SnackManager'; import {observable, runInAction, action} from 'mobx'; import {IClient, IUser} from './types'; const tokenKey = 'gotify-login-key'; export class CurrentUser { private tokenCache: string | null = null; private reconnectTimeoutId: number | null = null; private reconnectTime = 7500; @observable accessor loggedIn = false; @observable accessor refreshKey = 0; @observable accessor authenticating = true; @observable accessor user: IUser = {name: 'unknown', admin: false, id: -1}; @observable accessor connectionErrorMessage: string | null = null; public constructor(private readonly snack: SnackReporter) {} public token = (): string => { if (this.tokenCache !== null) { return this.tokenCache; } const localStorageToken = window.localStorage.getItem(tokenKey); if (localStorageToken) { this.tokenCache = localStorageToken; return localStorageToken; } return ''; }; private readonly setToken = (token: string) => { this.tokenCache = token; window.localStorage.setItem(tokenKey, token); }; public register = async (name: string, pass: string): Promise => axios .create() .post(config.get('url') + 'user', {name, pass}) .then(() => { this.snack('User Created. Logging in...'); this.login(name, pass); return true; }) .catch((error: AxiosError<{error?: string; errorDescription?: string}>) => { if (!error || !error.response) { this.snack('No network connection or server unavailable.'); return false; } const {data} = error.response; this.snack( `Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}` ); return false; }); public login = async (username: string, password: string) => { runInAction(() => { this.loggedIn = false; this.authenticating = true; }); const browser = detect(); const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser'; axios .create() .request({ url: config.get('url') + 'client', method: 'POST', data: {name}, headers: {Authorization: 'Basic ' + btoa(username + ':' + password)}, }) .then((resp: AxiosResponse) => { this.snack(`A client named '${name}' was created for your session.`); this.setToken(resp.data.token); this.tryAuthenticate().catch(() => { console.log( 'create client succeeded, but authenticated with given token failed' ); }); }) .catch( action(() => { this.authenticating = false; return this.snack('Login failed'); }) ); }; public tryAuthenticate = async (): Promise> => { if (this.token() === '') { runInAction(() => { this.authenticating = false; }); return Promise.reject(); } return axios .create() .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}}) .then( action((passThrough) => { this.user = passThrough.data; this.loggedIn = true; this.authenticating = false; this.connectionErrorMessage = null; this.reconnectTime = 7500; return passThrough; }) ) .catch( action((error: AxiosError) => { this.authenticating = false; if (!error || !error.response) { this.connectionError('No network connection or server unavailable.'); return Promise.reject(error); } if (error.response.status >= 500) { this.connectionError( `${error.response.statusText} (code: ${error.response.status}).` ); return Promise.reject(error); } this.connectionErrorMessage = null; if (error.response.status >= 400 && error.response.status < 500) { this.logout(); } return Promise.reject(error); }) ); }; public logout = async () => { await axios .get(config.get('url') + 'client') .then((resp: AxiosResponse) => { resp.data .filter((client) => client.token === this.tokenCache) .forEach((client) => axios.delete(config.get('url') + 'client/' + client.id)); }) .catch(() => Promise.resolve()); window.localStorage.removeItem(tokenKey); this.tokenCache = null; runInAction(() => { this.loggedIn = false; }); }; public changePassword = (pass: string) => { axios .post(config.get('url') + 'current/user/password', {pass}) .then(() => this.snack('Password changed')); }; public tryReconnect = (quiet = false) => { this.tryAuthenticate().catch(() => { if (!quiet) { this.snack('Reconnect failed'); } }); }; private readonly connectionError = (message: string) => { this.connectionErrorMessage = message; if (this.reconnectTimeoutId !== null) { window.clearTimeout(this.reconnectTimeoutId); } this.reconnectTimeoutId = window.setTimeout( () => this.tryReconnect(true), this.reconnectTime ); this.reconnectTime = Math.min(this.reconnectTime * 2, 120000); }; } ================================================ FILE: ui/src/apiAuth.ts ================================================ import axios from 'axios'; import {CurrentUser} from './CurrentUser'; import {SnackReporter} from './snack/SnackManager'; export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => { axios.interceptors.request.use((config) => { if (!config.headers.has('x-gotify-key')) { config.headers['x-gotify-key'] = currentUser.token(); } return config; }); axios.interceptors.response.use(undefined, (error) => { if (!error.response) { snack('Gotify server is not reachable, try refreshing the page.'); return Promise.reject(error); } const status = error.response.status; if (status === 401) { currentUser.tryAuthenticate().then(() => snack('Could not complete request.')); } if (status === 400 || status === 403 || status === 500) { snack(error.response.data.error + ': ' + error.response.data.errorDescription); } return Promise.reject(error); }); }; ================================================ FILE: ui/src/application/AddApplicationDialog.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; import React, {useState} from 'react'; interface IProps { fClose: VoidFunction; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); const submitEnabled = name.length !== 0; const submitAndClose = async () => { await fOnSubmit(name, description, defaultPriority); fClose(); }; return ( Create an application An application is allowed to send messages. setName(e.target.value)} fullWidth /> setDescription(e.target.value)} fullWidth multiline /> setDefaultPriority(value)} fullWidth />
); }; ================================================ FILE: ui/src/application/AppStore.ts ================================================ import axios from 'axios'; import {generateKeyBetween} from 'fractional-indexing'; import {action, runInAction} from 'mobx'; import {BaseStore} from '../common/BaseStore'; import * as config from '../config'; import {SnackReporter} from '../snack/SnackManager'; import {IApplication} from '../types'; import {arrayMove} from '@dnd-kit/sortable'; export class AppStore extends BaseStore { public onDelete: () => void = () => {}; public constructor(private readonly snack: SnackReporter) { super(); } protected requestItems = (): Promise => axios .get(`${config.get('url')}application`) .then((response) => response.data); protected requestDelete = (id: number): Promise => axios.delete(`${config.get('url')}application/${id}`).then(() => { this.onDelete(); return this.snack('Application deleted'); }); @action public uploadImage = async (id: number, file: Blob): Promise => { const formData = new FormData(); formData.append('file', file); await axios.post(`${config.get('url')}application/${id}/image`, formData, { headers: {'content-type': 'multipart/form-data'}, }); await this.refresh(); this.snack('Application image updated'); }; public async deleteImage(id: number): Promise { try { await axios.delete(`${config.get('url')}application/${id}/image`); await this.refresh(); this.snack('Application image deleted'); } catch (error) { console.error('Error deleting application image:', error); throw error; } } @action public reorder = async (fromId: number, toId: number): Promise => { const fromIndex = this.items.findIndex((app) => app.id === fromId); const toIndex = this.items.findIndex((app) => app.id === toId); if (fromIndex === -1 || toIndex === -1) { throw Error('unknown apps'); } const toUpdate = this.items[fromIndex]; const normalizedIndex = toUpdate.sortKey > this.items[toIndex].sortKey ? toIndex - 1 : toIndex; const newSortKey = generateKeyBetween( this.items[normalizedIndex]?.sortKey, this.items[normalizedIndex + 1]?.sortKey ); runInAction(() => (this.items = arrayMove(this.items, fromIndex, toIndex))); await this.update({...toUpdate, sortKey: newSortKey}); }; @action public update = async ({ id, ...app }: Pick< IApplication, 'id' | 'name' | 'description' | 'defaultPriority' | 'sortKey' >): Promise => { await axios.put(`${config.get('url')}application/${id}`, app); await this.refresh(); this.snack('Application updated'); }; @action public create = async ( name: string, description: string, defaultPriority: number ): Promise => { await axios.post(`${config.get('url')}application`, { name, description, defaultPriority, }); await this.refresh(); this.snack('Application created'); }; public getName = (id: number): string => { const app = this.getByIDOrUndefined(id); return id === -1 ? 'All Messages' : app !== undefined ? app.name : 'unknown'; }; } ================================================ FILE: ui/src/application/Applications.tsx ================================================ import React, {ChangeEvent, useEffect, useRef, useState} from 'react'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import CloudUpload from '@mui/icons-material/CloudUpload'; import DragIndicator from '@mui/icons-material/DragIndicator'; import Button from '@mui/material/Button'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core'; import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable'; import {CSS} from '@dnd-kit/utilities'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; import {AddApplicationDialog} from './AddApplicationDialog'; import * as config from '../config'; import {UpdateApplicationDialog} from './UpdateApplicationDialog'; import {IApplication} from '../types'; import {LastUsedCell} from '../common/LastUsedCell'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; import {makeStyles} from 'tss-react/mui'; import {ButtonBase, Tooltip} from '@mui/material'; const useStyles = makeStyles()((theme) => ({ imageContainer: { '&::after': { content: '"×"', position: 'absolute', top: 0, left: 0, width: 40, height: 40, background: theme.palette.error.main, color: theme.palette.getContrastText(theme.palette.error.main), fontSize: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, }, '&:hover::after': {opacity: 1}, }, })); const Applications = observer(() => { const {appStore} = useStores(); const apps = appStore.getItems(); const [toDeleteApp, setToDeleteApp] = useState(); const [toDeleteImage, setToDeleteImage] = useState(); const [toUpdateApp, setToUpdateApp] = useState(); const [createDialog, setCreateDialog] = useState(false); const fileInputRef = useRef(null); const uploadId = useRef(-1); const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); useEffect(() => void appStore.refresh(), []); const validExtensions = ['.gif', '.png', '.jpg', '.jpeg']; const handleImageUploadClick = (id: number) => { uploadId.current = id; if (fileInputRef.current) { fileInputRef.current.click(); } }; const onUploadImage = (e: ChangeEvent) => { const file = e.target.files?.[0]; if (!file) { return; } appStore.uploadImage(uploadId.current, file); }; const handleDragEnd = (event: DragEndEvent) => { const {active, over} = event; if (over && active.id !== over.id) { appStore.reorder(active.id as number, over.id as number); } }; return ( setCreateDialog(true)}> Create Application } maxWidth={1000}> Name Token Description Priority Last Used {apps.map((app: IApplication) => ( handleImageUploadClick(app.id)} fDeleteImage={() => setToDeleteImage(app)} fDelete={() => setToDeleteApp(app)} fEdit={() => setToUpdateApp(app)} /> ))}
{createDialog && ( setCreateDialog(false)} fOnSubmit={appStore.create} /> )} {toUpdateApp != null && ( setToUpdateApp(undefined)} fOnSubmit={(name, description, defaultPriority) => appStore.update({...toUpdateApp, name, description, defaultPriority}) } initialDescription={toUpdateApp?.description} initialName={toUpdateApp?.name} initialDefaultPriority={toUpdateApp?.defaultPriority} /> )} {toDeleteApp != null && ( setToDeleteApp(undefined)} fOnSubmit={() => appStore.remove(toDeleteApp.id)} /> )} {toDeleteImage != null && ( setToDeleteImage(undefined)} fOnSubmit={() => appStore.deleteImage(toDeleteImage.id)} /> )}
); }); interface IRowProps { app: IApplication; fUpload: VoidFunction; fDeleteImage: VoidFunction; fDelete: VoidFunction; fEdit: VoidFunction; } const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { const {classes} = useStyles(); const isDefaultImage = app.image === 'static/defaultapp.png'; const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ id: app.id, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, backgroundColor: isDragging ? '#f5f5f5' : 'transparent', }; return (
app logo
{app.name} {app.description} {app.defaultPriority}
); }; export default Applications; ================================================ FILE: ui/src/application/UpdateApplicationDialog.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; import React, {useState} from 'react'; interface IProps { fClose: VoidFunction; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; initialName: string; initialDescription: string; initialDefaultPriority: number; } export const UpdateApplicationDialog = ({ initialName, initialDescription, initialDefaultPriority, fClose, fOnSubmit, }: IProps) => { const [name, setName] = useState(initialName); const [description, setDescription] = useState(initialDescription); const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority); const submitEnabled = name.length !== 0; const submitAndClose = async () => { await fOnSubmit(name, description, defaultPriority); fClose(); }; return ( Update an application An application is allowed to send messages. setName(e.target.value)} fullWidth /> setDescription(e.target.value)} fullWidth multiline /> setDefaultPriority(e)} fullWidth />
); }; ================================================ FILE: ui/src/client/AddClientDialog.tsx ================================================ import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; interface IProps { fClose: VoidFunction; fOnSubmit: (name: string) => Promise; } const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); const submitEnabled = name.length !== 0; const submitAndClose = async () => { await fOnSubmit(name); fClose(); }; return ( Create a client setName(e.target.value)} fullWidth />
); }; export default AddClientDialog; ================================================ FILE: ui/src/client/ClientStore.ts ================================================ import {BaseStore} from '../common/BaseStore'; import axios from 'axios'; import * as config from '../config'; import {action} from 'mobx'; import {SnackReporter} from '../snack/SnackManager'; import {IClient} from '../types'; export class ClientStore extends BaseStore { public constructor(private readonly snack: SnackReporter) { super(); } protected requestItems = (): Promise => axios.get(`${config.get('url')}client`).then((response) => response.data); protected requestDelete(id: number): Promise { return axios .delete(`${config.get('url')}client/${id}`) .then(() => this.snack('Client deleted')); } @action public update = async (id: number, name: string): Promise => { await axios.put(`${config.get('url')}client/${id}`, {name}); await this.refresh(); this.snack('Client updated'); }; @action public createNoNotifcation = async (name: string): Promise => { const client = await axios.post(`${config.get('url')}client`, {name}); await this.refresh(); return client.data; }; @action public create = async (name: string): Promise => { await this.createNoNotifcation(name); this.snack('Client added'); }; } ================================================ FILE: ui/src/client/Clients.tsx ================================================ import React, {useEffect, useState} from 'react'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import Button from '@mui/material/Button'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import AddClientDialog from './AddClientDialog'; import UpdateClientDialog from './UpdateClientDialog'; import {IClient} from '../types'; import CopyableSecret from '../common/CopyableSecret'; import {LastUsedCell} from '../common/LastUsedCell'; import {observer} from 'mobx-react-lite'; import {useStores} from '../stores'; const Clients = observer(() => { const {clientStore} = useStores(); const [toDeleteClient, setToDeleteClient] = useState(); const [toUpdateClient, setToUpdateClient] = useState(); const [createDialog, setCreateDialog] = useState(false); const clients = clientStore.getItems(); useEffect(() => void clientStore.refresh(), []); return ( setCreateDialog(true)}> Create Client }> Name Token Last Used {clients.map((client: IClient) => ( setToUpdateClient(client)} fDelete={() => setToDeleteClient(client)} /> ))}
{createDialog && ( setCreateDialog(false)} fOnSubmit={clientStore.create} /> )} {toUpdateClient != null && ( setToUpdateClient(undefined)} fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)} initialName={toUpdateClient.name} /> )} {toDeleteClient != null && ( setToDeleteClient(undefined)} fOnSubmit={() => clientStore.remove(toDeleteClient.id)} /> )}
); }); interface IRowProps { name: string; value: string; lastUsed: string | null; fEdit: VoidFunction; fDelete: VoidFunction; } const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => ( {name} ); export default Clients; ================================================ FILE: ui/src/client/UpdateClientDialog.tsx ================================================ import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; interface IProps { fClose: VoidFunction; fOnSubmit: (name: string) => Promise; initialName: string; } const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => { const [name, setName] = useState(initialName); const submitEnabled = name.length !== 0; const submitAndClose = async () => { await fOnSubmit(name); fClose(); }; return ( Update a Client A client manages messages, clients, applications and users (with admin permissions). setName(e.target.value)} fullWidth />
); }; export default UpdateClientDialog; ================================================ FILE: ui/src/common/BaseStore.ts ================================================ import {action, observable} from 'mobx'; interface HasID { id: number; } export interface IClearable { clear(): void; } /** * Base implementation for handling items with ids. */ export abstract class BaseStore implements IClearable { @observable protected accessor items: T[] = []; protected abstract requestItems(): Promise; protected abstract requestDelete(id: number): Promise; @action public remove = async (id: number): Promise => { await this.requestDelete(id); await this.refresh(); }; @action public refresh = (): Promise => this.requestItems().then( action((items) => { this.items = items || []; }) ); @action public refreshIfMissing = async (id: number): Promise => { if (this.getByIDOrUndefined(id) === undefined) { await this.refresh(); } }; public getByID = (id: number): T => { const item = this.getByIDOrUndefined(id); if (item === undefined) { throw new Error('cannot find item with id ' + id); } return item; }; public getByIDOrUndefined = (id: number): T | undefined => this.items.find((hasId: HasID) => hasId.id === id); public getItems = (): T[] => this.items; @action public clear = (): void => { this.items = []; }; } ================================================ FILE: ui/src/common/ConfirmDialog.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import React from 'react'; interface IProps { title: string; text: string; fClose: VoidFunction; fOnSubmit: VoidFunction; } export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) { const submitAndClose = () => { fOnSubmit(); fClose(); }; return ( {title} {text} ); } ================================================ FILE: ui/src/common/ConnectionErrorBanner.tsx ================================================ import React from 'react'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; interface ConnectionErrorBannerProps { height: number; retry: () => void; message: string; } export const ConnectionErrorBanner = ({height, retry, message}: ConnectionErrorBannerProps) => (
{message}{' '}
); ================================================ FILE: ui/src/common/Container.tsx ================================================ import Paper from '@mui/material/Paper'; import {makeStyles} from 'tss-react/mui'; import * as React from 'react'; const useStyles = makeStyles()(() => ({ paper: { padding: 16, }, })); interface IProps { style?: React.CSSProperties; } const Container: React.FC> = ({children, style}) => { const {classes} = useStyles(); return ( {children} ); }; export default Container; ================================================ FILE: ui/src/common/CopyableSecret.tsx ================================================ import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import Visibility from '@mui/icons-material/Visibility'; import Copy from '@mui/icons-material/FileCopyOutlined'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; import React, {CSSProperties} from 'react'; import {useStores} from '../stores'; interface IProps { value: string; style?: CSSProperties; } const CopyableSecret = ({value, style}: IProps) => { const [visible, setVisible] = React.useState(false); const text = visible ? value : '•••••••••••••••'; const {snackManager} = useStores(); const toggleVisibility = () => setVisible((b) => !b); const copyToClipboard = async () => { try { await navigator.clipboard.writeText(value); snackManager.snack('Copied to clipboard'); } catch (error) { console.error('Failed to copy to clipboard:', error); snackManager.snack('Failed to copy to clipboard'); } }; return (
{visible ? : } {text}
); }; export default CopyableSecret; ================================================ FILE: ui/src/common/DefaultPage.tsx ================================================ import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import React, {FC} from 'react'; interface IProps { title: string; rightControl?: React.ReactNode; maxWidth?: number; } const DefaultPage: FC> = ({ title, rightControl, maxWidth = 700, children, }) => (
{title} {rightControl} {children}
); export default DefaultPage; ================================================ FILE: ui/src/common/LastUsedCell.tsx ================================================ import {Typography} from '@mui/material'; import React from 'react'; import TimeAgo from 'react-timeago'; import {TimeAgoFormatter} from './TimeAgoFormatter'; export const LastUsedCell: React.FC<{lastUsed: string | null}> = ({lastUsed}) => { if (lastUsed === null) { return Never; } if (+new Date(lastUsed) + 300000 > Date.now()) { return Recently; } return ; }; ================================================ FILE: ui/src/common/LoadingSpinner.tsx ================================================ import CircularProgress from '@mui/material/CircularProgress'; import Grid from '@mui/material/Grid'; import React from 'react'; import DefaultPage from './DefaultPage'; export default function LoadingSpinner() { return ( ); } ================================================ FILE: ui/src/common/Markdown.tsx ================================================ import React from 'react'; import ReactMarkdown, {defaultUrlTransform} from 'react-markdown'; import type {UrlTransform} from 'react-markdown'; import gfm from 'remark-gfm'; // Copy from mlflow/server/js/src/shared/web-shared/genai-markdown-renderer/GenAIMarkdownRenderer.tsx // Related PR: https://github.com/mlflow/mlflow/pull/16761 const urlTransform: UrlTransform = (value) => { if ( value.startsWith('data:image/png;') || value.startsWith('data:image/jpeg;') || value.startsWith('data:image/gif;') ) { return value; } return defaultUrlTransform(value); }; export const Markdown = ({ children, onImageLoaded = () => {}, }: { children: string; onImageLoaded?: () => void; }) => ( }} remarkPlugins={[gfm]} urlTransform={urlTransform}> {children} ); ================================================ FILE: ui/src/common/NumberField.tsx ================================================ import {TextField, TextFieldProps} from '@mui/material'; import React from 'react'; export interface NumberFieldProps { value: number; onChange: (value: number) => void; } export const NumberField = ({ value, onChange, ...props }: NumberFieldProps & Omit) => { const [stringValue, setStringValue] = React.useState(value.toString()); const [error, setError] = React.useState(''); return ( { setStringValue(event.target.value); const i = parseInt(event.target.value, 10); if (!Number.isNaN(i)) { onChange(i); setError(''); } else { setError('Invalid number'); } }} {...props} /> ); }; ================================================ FILE: ui/src/common/ScrollUpButton.tsx ================================================ import Fab from '@mui/material/Fab'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; import React from 'react'; const ScrollUpButton = () => { const [state, setState] = React.useState({display: 'none', opacity: 0}); React.useEffect(() => { const scrollHandler = () => { const currentScrollPos = Math.max(window.pageYOffset - 1000, 0); const opacity = Math.min(currentScrollPos / 1000, 1); const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; if (state.display !== nextState.display || state.opacity !== nextState.opacity) { setState(nextState); } }; window.addEventListener('scroll', scrollHandler); return () => window.removeEventListener('scroll', scrollHandler); }, []); return ( window.scrollTo(0, 0)}> ); }; export default ScrollUpButton; ================================================ FILE: ui/src/common/SettingsDialog.tsx ================================================ import React, {useState} from 'react'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {observer} from 'mobx-react-lite'; import {useStores} from '../stores'; interface IProps { fClose: VoidFunction; } const SettingsDialog = observer(({fClose}: IProps) => { const [pass, setPass] = useState(''); const {currentUser} = useStores(); const submitAndClose = async () => { currentUser.changePassword(pass); fClose(); }; return ( Change Password setPass(e.target.value)} fullWidth />
); }); export default SettingsDialog; ================================================ FILE: ui/src/common/TimeAgoFormatter.ts ================================================ import {Formatter} from 'react-timeago'; import {makeIntlFormatter} from 'react-timeago/defaultFormatter'; export const TimeAgoFormatter: Record<'long' | 'narrow', Formatter> = { long: makeIntlFormatter({style: 'long', locale: 'en'}), narrow: makeIntlFormatter({style: 'narrow', locale: 'en'}), }; ================================================ FILE: ui/src/config.ts ================================================ import {IVersion} from './types'; export interface IConfig { url: string; register: boolean; version: IVersion; } declare global { interface Window { config?: Partial; } } const config: IConfig = { url: 'unset', register: false, version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, ...window.config, }; export function set(key: Key, value: IConfig[Key]): void { config[key] = value; } export function get(key: K): IConfig[K] { return config[key]; } ================================================ FILE: ui/src/index.tsx ================================================ import * as React from 'react'; import {createRoot} from 'react-dom/client'; import 'typeface-roboto'; import {initAxios} from './apiAuth'; import * as config from './config'; import Layout from './layout/Layout'; import {unregister} from './registerServiceWorker'; import {CurrentUser} from './CurrentUser'; import {AppStore} from './application/AppStore'; import {WebSocketStore} from './message/WebSocketStore'; import {SnackManager} from './snack/SnackManager'; import {UserStore} from './user/UserStore'; import {MessagesStore} from './message/MessagesStore'; import {ClientStore} from './client/ClientStore'; import {PluginStore} from './plugin/PluginStore'; import {registerReactions} from './reactions'; import {StoreContext, StoreMapping} from './stores'; const {port, hostname, protocol, pathname} = window.location; const slashes = protocol.concat('//'); const path = pathname.endsWith('/') ? pathname : pathname.substring(0, pathname.lastIndexOf('/')); const url = slashes.concat(port ? hostname.concat(':', port) : hostname) + path; const urlWithSlash = url.endsWith('/') ? url : url.concat('/'); const prodUrl = urlWithSlash; const initStores = (): StoreMapping => { const snackManager = new SnackManager(); const appStore = new AppStore(snackManager.snack); const userStore = new UserStore(snackManager.snack); const messagesStore = new MessagesStore(appStore, snackManager.snack); const currentUser = new CurrentUser(snackManager.snack); const clientStore = new ClientStore(snackManager.snack); const wsStore = new WebSocketStore(snackManager.snack, currentUser); const pluginStore = new PluginStore(snackManager.snack); appStore.onDelete = () => messagesStore.clearAll(); return { appStore, snackManager, userStore, messagesStore, currentUser, clientStore, wsStore, pluginStore, }; }; (function clientJS() { config.set('url', prodUrl); const stores = initStores(); initAxios(stores.currentUser, stores.snackManager.snack); registerReactions(stores); stores.currentUser.tryAuthenticate().catch(() => {}); window.onbeforeunload = () => { stores.wsStore.close(); }; createRoot(document.getElementById('root')!).render( ); unregister(); })(); ================================================ FILE: ui/src/layout/Header.tsx ================================================ import AppBar from '@mui/material/AppBar'; import Button, {ButtonProps} from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import {Theme} from '@mui/material/styles'; import {makeStyles} from 'tss-react/mui'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import AccountCircle from '@mui/icons-material/AccountCircle'; import Chat from '@mui/icons-material/Chat'; import DevicesOther from '@mui/icons-material/DevicesOther'; import ExitToApp from '@mui/icons-material/ExitToApp'; import Brightness4 from '@mui/icons-material/Brightness4'; import Brightness7 from '@mui/icons-material/Brightness7'; import BrightnessAuto from '@mui/icons-material/BrightnessAuto'; import GitHubIcon from '@mui/icons-material/GitHub'; import MenuIcon from '@mui/icons-material/Menu'; import Apps from '@mui/icons-material/Apps'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount'; import React, {CSSProperties} from 'react'; import {Link} from 'react-router-dom'; import {useMediaQuery} from '@mui/material'; import {ThemeKey} from './theme'; const themeIcons: Record = { dark: , light: , system: , }; const useStyles = makeStyles()((theme: Theme) => ({ appBar: { zIndex: theme.zIndex.drawer + 1, [theme.breakpoints.down('sm')]: { paddingBottom: 10, }, }, toolbar: { justifyContent: 'space-between', [theme.breakpoints.down('sm')]: { flexWrap: 'wrap', }, }, menuButtons: { display: 'flex', [theme.breakpoints.down('md')]: { flex: 1, }, justifyContent: 'center', [theme.breakpoints.down('sm')]: { flexBasis: '100%', marginTop: 5, order: 1, height: 50, justifyContent: 'space-between', alignItems: 'center', }, }, title: { [theme.breakpoints.up('md')]: { flex: 1, }, display: 'flex', alignItems: 'center', }, titleName: { paddingRight: 10, }, link: { color: 'inherit', textDecoration: 'none', }, })); interface IProps { loggedIn: boolean; name: string; admin: boolean; version: string; themeMode: ThemeKey; toggleTheme: VoidFunction; showSettings: VoidFunction; logout: VoidFunction; style: CSSProperties; setNavOpen: (open: boolean) => void; } const Header = ({ version, name, loggedIn, admin, toggleTheme, logout, style, setNavOpen, showSettings, themeMode, }: IProps) => { const {classes} = useStyles(); const themeLabel = `Toggle theme (current: ${themeMode})`; const themeIcon = themeIcons[themeMode]; return ( {loggedIn && ( )}
{themeIcon}
); }; const Buttons = ({ showSettings, name, admin, logout, setNavOpen, }: { name: string; admin: boolean; logout: VoidFunction; setNavOpen: (open: boolean) => void; showSettings: VoidFunction; }) => { const {classes} = useStyles(); return (
} onClick={() => setNavOpen(true)} label="menu" color="inherit" /> {admin && ( } label="users" color="inherit" /> )} } label="apps" color="inherit" /> } label="clients" color="inherit" /> } label="plugins" color="inherit" /> } label={name} onClick={showSettings} id="changepw" color="inherit" /> } label="Logout" onClick={logout} id="logout" color="inherit" />
); }; const ResponsiveButton: React.FC<{ color: 'inherit'; sx?: ButtonProps['sx']; label: string; id?: string; onClick?: () => void; icon: React.ReactNode; }> = ({icon, label, ...rest}) => { const matches = useMediaQuery('(max-width:1000px)'); if (matches) { return ( {icon} ); } return ( ); }; export default Header; ================================================ FILE: ui/src/layout/Layout.tsx ================================================ import { createTheme, ThemeProvider, StyledEngineProvider, Theme, useMediaQuery, } from '@mui/material'; import {makeStyles} from 'tss-react/mui'; import CssBaseline from '@mui/material/CssBaseline'; import * as React from 'react'; import {HashRouter, Navigate, Route, Routes} from 'react-router-dom'; import Header from './Header'; import Navigation from './Navigation'; import ScrollUpButton from '../common/ScrollUpButton'; import SettingsDialog from '../common/SettingsDialog'; import * as config from '../config'; import Applications from '../application/Applications'; import Clients from '../client/Clients'; import Plugins from '../plugin/Plugins'; import Login from '../user/Login'; import Messages from '../message/Messages'; import Users from '../user/Users'; import {observer} from 'mobx-react-lite'; import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner'; import {useStores} from '../stores'; import {SnackbarProvider} from 'notistack'; import LoadingSpinner from '../common/LoadingSpinner'; import {isThemeKey, ThemeKey} from './theme'; const useStyles = makeStyles()((theme: Theme) => ({ content: { margin: '0 auto', marginTop: 64, padding: theme.spacing(3), width: '100%', [theme.breakpoints.down('sm')]: { marginTop: 0, padding: theme.spacing(1), }, }, })); const localStorageThemeKey = 'gotify-theme'; const Layout = observer(() => { const { currentUser: { loggedIn, authenticating, user: {name, admin}, logout, tryReconnect, connectionErrorMessage, refreshKey, }, } = useStores(); const {classes} = useStyles(); const [currentTheme, setCurrentTheme] = React.useState(() => { const stored = window.localStorage.getItem(localStorageThemeKey); return isThemeKey(stored) ? stored : 'system'; }); const prefersDark = useMediaQuery('(prefers-color-scheme: dark)'); const paletteMode = currentTheme === 'system' ? (prefersDark ? 'dark' : 'light') : currentTheme; const theme = React.useMemo( () => createTheme({ palette: { mode: paletteMode, }, }), [paletteMode] ); const {version} = config.get('version'); const [navOpen, setNavOpen] = React.useState(false); const [showSettings, setShowSettings] = React.useState(false); const toggleTheme = () => { const nextMap: Record = { dark: 'light', light: 'system', system: 'dark', }; const next = nextMap[currentTheme]; setCurrentTheme(next); localStorage.setItem(localStorageThemeKey, next); }; const authed = (children: React.ReactNode) => ( {children} ); return ( {/* This forces all components to fully rerender including useEffects. The refreshKey is updated when store data was cleaned and pages should refetch their data. */}
{!connectionErrorMessage ? null : ( tryReconnect()} message={connectionErrorMessage} /> )}
setShowSettings(true)} logout={logout} setNavOpen={setNavOpen} />
} /> )} /> )} /> )} /> )} /> )} /> )} /> import('../plugin/PluginDetailView') } /> )} />
{showSettings && ( setShowSettings(false)} /> )}
); }); // eslint-disable-next-line const Lazy = ({component}: {component: () => Promise<{default: React.ComponentType}>}) => { const Component = React.lazy(component); return ( }> ); }; const RequireAuth: React.FC< React.PropsWithChildren<{loggedIn: boolean; authenticating: boolean}> > = ({children, authenticating, loggedIn}) => { if (authenticating) { return ; } if (!loggedIn) { return ; } return <>{children}; }; export default Layout; ================================================ FILE: ui/src/layout/Navigation.tsx ================================================ import Divider from '@mui/material/Divider'; import Drawer from '@mui/material/Drawer'; import {Theme} from '@mui/material/styles'; import React from 'react'; import {Link} from 'react-router-dom'; import {observer} from 'mobx-react-lite'; import {mayAllowPermission, requestPermission} from '../snack/browserNotification'; import { Button, IconButton, Typography, ListItemText, ListItemAvatar, Avatar, ListItemButton, } from '@mui/material'; import {DrawerProps} from '@mui/material/Drawer/Drawer'; import CloseIcon from '@mui/icons-material/Close'; import {makeStyles} from 'tss-react/mui'; import {useStores} from '../stores'; const useStyles = makeStyles()((theme: Theme) => ({ root: { height: '100%', }, drawerPaper: { position: 'relative', width: 250, minHeight: '100%', height: '100vh', }, // eslint-disable-next-line toolbar: theme.mixins.toolbar as any, link: { color: 'inherit', textDecoration: 'none', }, })); interface IProps { loggedIn: boolean; navOpen: boolean; setNavOpen: (open: boolean) => void; } const Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => { const [showRequestNotification, setShowRequestNotification] = React.useState(mayAllowPermission); const {classes} = useStyles(); const {appStore} = useStores(); const apps = appStore.getItems(); const userApps = apps.length === 0 ? null : apps.map((app) => ( setNavOpen(false)} className={`${classes.link} item`} to={'/messages/' + app.id} key={app.id}> )); const placeholderItems = [ , , ]; return (
setNavOpen(false)}>
{loggedIn ? userApps : placeholderItems}
{showRequestNotification ? ( ) : null} ); }); const ResponsiveDrawer: React.FC< DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void} > = ({navOpen, setNavOpen, children, ...rest}) => ( <> setNavOpen(false)} size="large"> {children} {children} ); export default Navigation; ================================================ FILE: ui/src/layout/theme.ts ================================================ export type ThemeKey = 'dark' | 'light' | 'system'; export const isThemeKey = (value: string | null): value is ThemeKey => value === 'light' || value === 'dark' || value === 'system'; ================================================ FILE: ui/src/message/Message.tsx ================================================ import {Button, Theme, useMediaQuery, useTheme} from '@mui/material'; import IconButton from '@mui/material/IconButton'; import {makeStyles} from 'tss-react/mui'; import Typography from '@mui/material/Typography'; import {ExpandLess, ExpandMore} from '@mui/icons-material'; import Delete from '@mui/icons-material/Delete'; import React from 'react'; import TimeAgo from 'react-timeago'; import Container from '../common/Container'; import {Markdown} from '../common/Markdown'; import * as config from '../config'; import {IMessageExtras} from '../types'; import {contentType, RenderMode} from './extras'; import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; const PREVIEW_LENGTH = 500; const useStyles = makeStyles()((theme: Theme) => ({ header: { display: 'flex', width: '100%', alignItems: 'start', alignContent: 'center', paddingBottom: 5, wordBreak: 'break-all', }, headerTitle: { flex: 1, }, trash: { marginTop: -15, marginRight: -15, }, wrapperPadding: { marginBottom: theme.spacing(2), [theme.breakpoints.down('sm')]: { marginBottom: theme.spacing(1), }, }, messageContentWrapper: { minWidth: 200, width: '100%', }, image: { width: 50, height: 50, [theme.breakpoints.down('md')]: { width: 30, height: 30, }, }, date: { [theme.breakpoints.down('md')]: { order: 1, flexBasis: '100%', opacity: 0.7, }, }, imageWrapper: { marginRight: 15, width: 50, height: 50, }, plainContent: { whiteSpace: 'pre-wrap', }, content: { maxHeight: PREVIEW_LENGTH, wordBreak: 'break-all', overflowY: 'hidden', '&.expanded': { maxHeight: 'none', }, '& p': { margin: 0, wordBreak: 'break-word', }, '& a': { color: '#ff7f50', }, '& pre': { overflow: 'auto', borderRadius: '0.25em', backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', padding: theme.spacing(1), }, '& img': { maxWidth: '100%', }, }, })); interface IProps { title: string; image?: string; date: string; content: string; priority: number; appName: string; fDelete: VoidFunction; extras?: IMessageExtras; expanded: boolean; onExpand: (expand: boolean) => void; } const priorityColor = (priority: number) => { if (priority >= 4 && priority <= 7) { return 'rgba(230, 126, 34, 0.7)'; } else if (priority > 7) { return '#e74c3c'; } else { return 'transparent'; } }; const Message = ({ fDelete, title, date, image, priority, content, extras, appName, onExpand, expanded: initialExpanded, }: IProps) => { const theme = useTheme(); const contentRef = React.useRef(null); const {classes} = useStyles(); const [expanded, setExpanded] = React.useState(initialExpanded); const [isOverflowing, setOverflowing] = React.useState(false); const smallHeader = useMediaQuery(theme.breakpoints.down('md')); const refreshOverflowing = React.useCallback(() => { const ref = contentRef.current; if (!ref) { return; } setOverflowing((overflowing) => overflowing || ref.scrollHeight > ref.clientHeight); }, [contentRef, setOverflowing]); const onContentRef = React.useCallback( (ref: HTMLDivElement | null) => { contentRef.current = ref; refreshOverflowing(); }, [contentRef, refreshOverflowing] ); React.useEffect(() => void onExpand(expanded), [expanded]); const togglePreviewHeight = () => setExpanded((b) => !b); const renderContent = () => { switch (contentType(extras)) { case RenderMode.Markdown: return {content}; case RenderMode.Plain: default: return {content}; } }; return (
{smallHeader ? ( ) : ( )}
{renderContent()}
{isOverflowing && ( )}
); }; const HeaderWide = ({ appName, image, date, fDelete, title, }: Pick) => { const {classes} = useStyles(); return (
{image !== null ? ( {`${appName} ) : null}
{title} {appName}
); }; const HeaderSmall = ({ appName, image, date, fDelete, title, }: Pick) => { const {classes} = useStyles(); return (
{title} {appName}
{image !== null ? ( {`${appName} ) : null}
); }; export default Message; ================================================ FILE: ui/src/message/Messages.tsx ================================================ import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import React from 'react'; import {useParams} from 'react-router'; import DefaultPage from '../common/DefaultPage'; import Button from '@mui/material/Button'; import Message from './Message'; import {observer} from 'mobx-react-lite'; import {IMessage} from '../types'; import ConfirmDialog from '../common/ConfirmDialog'; import LoadingSpinner from '../common/LoadingSpinner'; import {useStores} from '../stores'; import {Virtuoso} from 'react-virtuoso'; import {PushMessageDialog} from './PushMessageDialog'; import {enqueueSnackbar} from 'notistack'; const UndoAutoHideMs = 5000; const Messages = observer(() => { const {id} = useParams<{id: string}>(); const appId = id == null ? -1 : parseInt(id as string, 10); const [deleteAll, setDeleteAll] = React.useState(false); const [pushMessageOpen, setPushMessageOpen] = React.useState(false); const [isLoadingMore, setLoadingMore] = React.useState(false); const {messagesStore, appStore} = useStores(); const messages = messagesStore.get(appId); const hasMore = messagesStore.canLoadMore(appId); const name = appStore.getName(appId); const hasMessages = messages.length !== 0; const expandedState = React.useRef>({}); const app = appId === -1 ? undefined : appStore.getByIDOrUndefined(appId); const deleteMessage = (message: IMessage) => { const key = enqueueSnackbar({ message: 'Message deleted', variant: 'info', action: () => ( ), disableWindowBlurListener: true, transitionDuration: {enter: 0, exit: 0}, autoHideDuration: UndoAutoHideMs, onExited: () => messagesStore.removeSingle(message), }); messagesStore.addPendingDelete({message, key}); }; React.useEffect(() => { if (!messagesStore.loaded(appId)) { messagesStore.loadMore(appId); } }, [appId]); const renderMessage = (_index: number, message: IMessage) => ( deleteMessage(message)} onExpand={(expanded) => (expandedState.current[message.id] = expanded)} title={message.title} date={message.date} appName={appStore.getName(message.appid)} expanded={expandedState.current[message.id] ?? false} content={message.message} image={message.image} extras={message.extras} priority={message.priority} /> ); const checkIfLoadMore = () => { if (!isLoadingMore && messagesStore.canLoadMore(appId)) { setLoadingMore(true); messagesStore.loadMore(appId).then(() => setLoadingMore(false)); } }; const messageFooter = () => { if (hasMore) { return ; } if (hasMessages) { return label("You've reached the end"); } return null; }; const renderMessages = () => ( label('No messages'), }} /> ); const label = (text: string) => ( {text} ); return ( {app && ( )}
}> {!messagesStore.loaded(appId) ? : renderMessages()} {deleteAll && ( setDeleteAll(false)} fOnSubmit={() => messagesStore.removeByApp(appId)} /> )} {pushMessageOpen && app && ( setPushMessageOpen(false)} fOnSubmit={(message, title, priority) => messagesStore.sendMessage(app.id, message, title, priority) } /> )} ); }); export default Messages; ================================================ FILE: ui/src/message/MessagesStore.ts ================================================ import {BaseStore} from '../common/BaseStore'; import {action, IObservableArray, observable, reaction, runInAction} from 'mobx'; import axios, {AxiosResponse} from 'axios'; import * as config from '../config'; import {createTransformer} from 'mobx-utils'; import {SnackReporter} from '../snack/SnackManager'; import {IApplication, IMessage, IPagedMessages} from '../types'; import {closeSnackbar, SnackbarKey} from 'notistack'; const AllMessages = -1; interface MessagesState { messages: IObservableArray; hasMore: boolean; nextSince: number; loaded: boolean; } interface PendingDelete { key: SnackbarKey; message: IMessage; } export class MessagesStore { @observable private accessor state: Record = {}; @observable private accessor pendingDeletes: Map = observable.map(); private loading = false; public constructor( private readonly appStore: BaseStore, private readonly snack: SnackReporter ) { reaction(() => appStore.getItems(), this.createEmptyStatesForApps); } private stateOf = (appId: number, create = true) => { if (!this.state[appId] && create) { this.state[appId] = this.emptyState(); } return this.state[appId] || this.emptyState(); }; public loaded = (appId: number) => this.stateOf(appId, /*create*/ false).loaded; public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore; @action public loadMore = async (appId: number) => { const state = this.stateOf(appId); if (!state.hasMore || this.loading) { return Promise.resolve(); } this.loading = true; try { const pagedResult = await this.fetchMessages(appId, state.nextSince).then( (resp) => resp.data ); runInAction(() => { state.messages.replace([...state.messages, ...pagedResult.messages]); state.nextSince = pagedResult.paging.since ?? 0; state.hasMore = 'next' in pagedResult.paging; state.loaded = true; }); } finally { this.loading = false; } return Promise.resolve(); }; @action public publishSingleMessage = (message: IMessage) => { if (this.exists(AllMessages)) { this.stateOf(AllMessages).messages.unshift(message); } if (this.exists(message.appid)) { this.stateOf(message.appid).messages.unshift(message); } }; @action public removeByApp = async (appId: number) => { if (appId === AllMessages) { await axios.delete(config.get('url') + 'message'); this.snack('Deleted all messages'); this.clearAll(); } else { await axios.delete(config.get('url') + 'application/' + appId + '/message'); this.snack(`Deleted all messages from ${this.appStore.getByID(appId).name}`); this.clear(AllMessages); this.clear(appId); } await this.loadMore(appId); }; @action public addPendingDelete = (pending: PendingDelete) => this.pendingDeletes.set(pending.message.id, pending); @action public cancelPendingDelete = (message: IMessage): boolean => { const pending = this.pendingDeletes.get(message.id); if (pending) { this.pendingDeletes.delete(message.id); closeSnackbar(pending.key); } return !!pending; }; @action public executePendingDeletes = () => Array.from(this.pendingDeletes.values()).forEach(({message}) => this.removeSingle(message)); public visible = (message: number): boolean => !this.pendingDeletes.has(message); @action public removeSingle = async (message: IMessage) => { if (!this.pendingDeletes.has(message.id)) { return; } await axios.delete(config.get('url') + 'message/' + message.id, { adapter: 'fetch', fetchOptions: {keepalive: true}, }); if (this.exists(AllMessages)) { this.removeFromList(this.state[AllMessages].messages, message); } if (this.exists(message.appid)) { this.removeFromList(this.state[message.appid].messages, message); } this.cancelPendingDelete(message); }; public sendMessage = async ( appId: number, message: string, title: string, priority: number ): Promise => { const app = this.appStore.getByID(appId); const payload: Pick = { message, priority, title, }; await axios.post(`${config.get('url')}message`, payload, { headers: {'X-Gotify-Key': app.token}, }); this.snack(`Message sent to ${app.name}`); }; @action public clearAll = () => { this.state = {}; this.createEmptyStatesForApps(this.appStore.getItems()); }; @action public refreshByApp = async (appId: number) => { this.clearAll(); this.loadMore(appId); }; public exists = (id: number) => this.stateOf(id).loaded; @action private removeFromList(messages: IMessage[], messageToDelete: IMessage): false | number { if (messages) { const index = messages.findIndex((message) => message.id === messageToDelete.id); if (index !== -1) { messages.splice(index, 1); return index; } } return false; } @action private clear = (appId: number) => (this.state[appId] = this.emptyState()); private fetchMessages = ( appId: number, since: number ): Promise> => { if (appId === AllMessages) { return axios.get(config.get('url') + 'message?since=' + since); } else { return axios.get( config.get('url') + 'application/' + appId + '/message?since=' + since ); } }; private getUnCached = (appId: number): Array => { const appToImage: Partial> = this.appStore .getItems() .reduce((all, app) => ({...all, [app.id]: app.image}), {}); return this.stateOf(appId, false) .messages.filter((message) => !this.pendingDeletes.has(message.id)) .map((message: IMessage): IMessage => ({...message, image: appToImage[message.appid]})); }; public get = createTransformer(this.getUnCached); private clearCache = () => (this.get = createTransformer(this.getUnCached)); private createEmptyStatesForApps = (apps: IApplication[]) => { apps.map((app) => app.id).forEach((id) => this.stateOf(id, /*create*/ true)); this.clearCache(); }; private emptyState = (): MessagesState => ({ messages: observable.array(), hasMore: true, nextSince: 0, loaded: false, }); } ================================================ FILE: ui/src/message/PushMessageDialog.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import React, {useState} from 'react'; import {NumberField} from '../common/NumberField'; interface IProps { appName: string; defaultPriority: number; fClose: VoidFunction; fOnSubmit: (message: string, title: string, priority: number) => Promise; } export const PushMessageDialog = ({appName, defaultPriority, fClose, fOnSubmit}: IProps) => { const [title, setTitle] = useState(''); const [message, setMessage] = useState(''); const [priority, setPriority] = useState(defaultPriority); const submitEnabled = message.trim().length !== 0; const submitAndClose = async () => { await fOnSubmit(message, title, priority); fClose(); }; return ( Push message Send a push message via {appName}. Leave the title empty to use the application name. setTitle(e.target.value)} fullWidth /> setMessage(e.target.value)} fullWidth multiline minRows={4} /> setPriority(value)} fullWidth />
); }; ================================================ FILE: ui/src/message/WebSocketStore.ts ================================================ import {SnackReporter} from '../snack/SnackManager'; import {CurrentUser} from '../CurrentUser'; import * as config from '../config'; import {AxiosError} from 'axios'; import {IMessage} from '../types'; export class WebSocketStore { private wsActive = false; private ws: WebSocket | null = null; public constructor( private readonly snack: SnackReporter, private readonly currentUser: CurrentUser ) {} public listen = (callback: (msg: IMessage) => void) => { if (!this.currentUser.token() || this.wsActive) { return; } this.wsActive = true; const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss'); const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token()); ws.onerror = (e) => { this.wsActive = false; console.log('WebSocket connection errored', e); }; ws.onmessage = (data) => callback(JSON.parse(data.data)); ws.onclose = () => { this.wsActive = false; this.currentUser .tryAuthenticate() .then(() => { this.snack('WebSocket connection closed, trying again in 30 seconds.'); setTimeout(() => this.listen(callback), 30000); }) .catch((error: AxiosError) => { if (error?.response?.status === 401) { this.snack('Could not authenticate with client token, logging out.'); } }); }; this.ws = ws; }; public close = () => this.ws?.close(1000, 'WebSocketStore#close'); } ================================================ FILE: ui/src/message/extras.ts ================================================ import {IMessageExtras} from '../types'; export enum RenderMode { Markdown = 'text/markdown', Plain = 'text/plain', } export const contentType = (extras?: IMessageExtras): RenderMode => { const type = extract(extras, 'client::display', 'contentType'); const valid = Object.values(RenderMode).includes(type); return valid ? type : RenderMode.Plain; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const extract = (extras: IMessageExtras | undefined, key: string, path: string): any => { if (!extras) { return null; } if (!extras[key]) { return null; } if (!extras[key][path]) { return null; } return extras[key][path]; }; ================================================ FILE: ui/src/plugin/PluginDetailView.tsx ================================================ import React from 'react'; import {useParams} from 'react-router'; import {Markdown} from '../common/Markdown'; import {material} from '@uiw/codemirror-theme-material'; import CodeMirror from '@uiw/react-codemirror'; import Info from '@mui/icons-material/Info'; import Build from '@mui/icons-material/Build'; import Subject from '@mui/icons-material/Subject'; import Refresh from '@mui/icons-material/Refresh'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import DefaultPage from '../common/DefaultPage'; import * as config from '../config'; import Container from '../common/Container'; import {IPlugin} from '../types'; import LoadingSpinner from '../common/LoadingSpinner'; import {useStores} from '../stores'; const PluginDetailView = () => { const {id} = useParams<{id: string}>(); const pluginID = parseInt(id as string, 10); const {pluginStore} = useStores(); const [currentConfig, setCurrentConfig] = React.useState(); const [displayText, setDisplayText] = React.useState(); const pluginInfo = pluginStore.getByIDOrUndefined(pluginID); const refreshFeatures = async () => { await pluginStore.refreshIfMissing(pluginID); await Promise.all([refreshConfigurer(), refreshDisplayer()]); }; React.useEffect(() => void refreshFeatures(), [pluginID]); const refreshConfigurer = async () => { if (pluginInfo?.capabilities.indexOf('configurer') !== -1) { setCurrentConfig(await pluginStore.requestConfig(pluginID)); } }; const refreshDisplayer = async () => { if (pluginInfo?.capabilities.indexOf('displayer') !== -1) { setDisplayText(await pluginStore.requestDisplay(pluginID)); } }; if (pluginInfo == null) { return ; } const handleSaveConfig = async (newConfig: string) => { await pluginStore.changeConfig(pluginID, newConfig); await refreshFeatures(); }; return ( {pluginInfo.capabilities.indexOf('configurer') !== -1 ? ( ) : null}{' '} {pluginInfo.capabilities.indexOf('displayer') !== -1 ? ( ) : null} ); }; interface IPanelWrapperProps { name: string; description?: string; refresh?: () => Promise; icon?: React.ComponentType; } const PanelWrapper: React.FC> = ({ name, description, refresh, icon, children, }) => { const Icon = icon; return (
{Icon ? (   ) : null} {name} {refresh ? ( ) : null} {description ? {description} : null}
{children}
); }; interface IConfigurerPanelProps { pluginInfo: IPlugin; initialConfig: string; save: (newConfig: string) => Promise; } const ConfigurerPanel = ({initialConfig, save}: IConfigurerPanelProps) => { const [unsavedChanges, setUnsavedChanges] = React.useState(null); const onChange = React.useCallback( (value: string | null) => { let newConf: string | null = value; if (value === initialConfig) { newConf = null; } setUnsavedChanges(newConf); }, [initialConfig] ); return (

); }; interface IDisplayerPanelProps { pluginInfo: IPlugin; displayText: string; } const DisplayerPanel: React.FC = ({displayText}) => ( {displayText} ); interface IPluginInfo { pluginInfo: IPlugin; } const PluginInfo = ({pluginInfo}: IPluginInfo) => { const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo; return (
{name ? ( Name: {name} ) : null} {author ? ( Author: {author} ) : null} Module Path: {modulePath} {website ? ( Website: {website} ) : null} {license ? ( License: {license} ) : null} Capabilities: {capabilities.join(', ')} {capabilities.indexOf('webhooker') !== -1 ? ( Custom Route Prefix:{' '} {((url) => ( {url} ))(`${config.get('url')}plugin/${id}/custom/${token}/`)} ) : null}
); }; export default PluginDetailView; ================================================ FILE: ui/src/plugin/PluginStore.ts ================================================ import axios from 'axios'; import {action} from 'mobx'; import {BaseStore} from '../common/BaseStore'; import * as config from '../config'; import {SnackReporter} from '../snack/SnackManager'; import {IPlugin} from '../types'; export class PluginStore extends BaseStore { public onDelete: () => void = () => {}; public constructor(private readonly snack: SnackReporter) { super(); } public requestConfig = (id: number): Promise => axios.get(`${config.get('url')}plugin/${id}/config`).then((response) => response.data); public requestDisplay = (id: number): Promise => axios.get(`${config.get('url')}plugin/${id}/display`).then((response) => response.data); protected requestItems = (): Promise => axios.get(`${config.get('url')}plugin`).then((response) => response.data); protected requestDelete = (): Promise => { this.snack('Cannot delete plugin'); throw new Error('Cannot delete plugin'); }; public getName = (id: number): string => { const plugin = this.getByIDOrUndefined(id); return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown'; }; @action public changeConfig = async (id: number, newConfig: string): Promise => { await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, { headers: {'content-type': 'application/x-yaml'}, }); this.snack(`Plugin config updated`); await this.refresh(); }; @action public changeEnabledState = async (id: number, enabled: boolean): Promise => { await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`); this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`); await this.refresh(); }; } ================================================ FILE: ui/src/plugin/Plugins.tsx ================================================ import React from 'react'; import {Link} from 'react-router-dom'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Settings from '@mui/icons-material/Settings'; import {Switch, Button} from '@mui/material'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; import {observer} from 'mobx-react-lite'; import {IPlugin} from '../types'; import {useStores} from '../stores'; const Plugins = observer(() => { const {pluginStore} = useStores(); React.useEffect(() => void pluginStore.refresh(), []); const plugins = pluginStore.getItems(); return ( ID Enabled Name Token Details {plugins.map((plugin: IPlugin) => ( pluginStore.changeEnabledState(plugin.id, !plugin.enabled) } /> ))}
); }); interface IRowProps { id: number; name: string; token: string; enabled: boolean; fToggleStatus: VoidFunction; } const Row: React.FC = observer(({name, id, token, enabled, fToggleStatus}) => ( {id} {name} )); export default Plugins; ================================================ FILE: ui/src/react-app-env.d.ts ================================================ /// ================================================ FILE: ui/src/reactions.ts ================================================ import {reaction} from 'mobx'; import * as Notifications from './snack/browserNotification'; import {StoreMapping} from './stores'; const AUDIO_REPEAT_DELAY = 1000; export const registerReactions = (stores: StoreMapping) => { window.addEventListener('pagehide', stores.messagesStore.executePendingDeletes); window.addEventListener('beforeunload', stores.messagesStore.executePendingDeletes); const clearAll = () => { stores.messagesStore.clearAll(); stores.appStore.clear(); stores.clientStore.clear(); stores.userStore.clear(); stores.wsStore.close(); }; let audio: HTMLAudioElement | undefined; let lastAudio = 0; const loadAll = () => { stores.wsStore.listen((message) => { stores.messagesStore.publishSingleMessage(message); Notifications.notifyNewMessage(message); if (message.priority >= 4 && Date.now() > lastAudio + AUDIO_REPEAT_DELAY) { lastAudio = Date.now(); audio ??= new Audio('static/notification.ogg'); audio.currentTime = 0; audio.play(); } }); stores.appStore.refresh(); }; reaction( () => stores.currentUser.loggedIn, (loggedIn) => { if (loggedIn) { loadAll(); } else { clearAll(); } } ); reaction( () => stores.currentUser.connectionErrorMessage, (connectionErrorMessage) => { if (!connectionErrorMessage) { clearAll(); loadAll(); stores.currentUser.refreshKey++; } } ); }; ================================================ FILE: ui/src/registerServiceWorker.ts ================================================ export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((registration) => { registration.unregister(); }); } } ================================================ FILE: ui/src/snack/SnackManager.ts ================================================ import {enqueueSnackbar} from 'notistack'; export interface SnackReporter { (message: string): void; } export class SnackManager { public snack: SnackReporter = (message: string): void => { enqueueSnackbar({message, variant: 'info'}); }; } ================================================ FILE: ui/src/snack/browserNotification.ts ================================================ import Notify from 'notifyjs'; import removeMarkdown from 'remove-markdown'; import {IMessage} from '../types'; export function mayAllowPermission(): boolean { return Notify.needsPermission && Notify.isSupported() && Notification.permission !== 'denied'; } export function requestPermission() { if (Notify.needsPermission && Notify.isSupported()) { Notify.requestPermission( () => console.log('granted notification permissions'), () => console.log('notification permission denied') ); } } export function notifyNewMessage(msg: IMessage) { const notify = new Notify(msg.title, { body: removeMarkdown(msg.message), icon: msg.image, silent: true, notifyClick: closeAndFocus, notifyShow: closeAfterTimeout, }); notify.show(); } function closeAndFocus(event: Event) { if (window.parent) { window.parent.focus(); } window.focus(); window.location.href = '/'; const target = event.target as Notification; target.close(); } function closeAfterTimeout(event: Event) { setTimeout(() => { const target = event.target as Notification; target.close(); }, 5000); } ================================================ FILE: ui/src/stores.tsx ================================================ import * as React from 'react'; import {UserStore} from './user/UserStore'; import {SnackManager} from './snack/SnackManager'; import {MessagesStore} from './message/MessagesStore'; import {CurrentUser} from './CurrentUser'; import {ClientStore} from './client/ClientStore'; import {AppStore} from './application/AppStore'; import {WebSocketStore} from './message/WebSocketStore'; import {PluginStore} from './plugin/PluginStore'; export interface StoreMapping { userStore: UserStore; snackManager: SnackManager; messagesStore: MessagesStore; currentUser: CurrentUser; clientStore: ClientStore; appStore: AppStore; pluginStore: PluginStore; wsStore: WebSocketStore; } export const StoreContext = React.createContext(undefined); export const useStores = (): StoreMapping => { const mapping = React.useContext(StoreContext); if (!mapping) throw new Error('uninitialized'); return mapping; }; ================================================ FILE: ui/src/tests/application.test.ts ================================================ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; let page: Page; let gotify: GotifyTest; beforeAll(async () => { gotify = await newTest(); page = gotify.page; }); afterAll(async () => await gotify.close()); enum Col { Name = 3, Token = 4, Description = 5, DefaultPriority = 6, LastUsed = 7, EditUpdate = 8, EditDelete = 9, } const hiddenToken = '•••••••••••••••'; const $table = selector.table('#app-table'); const $dialog = selector.form('#app-dialog'); const waitforApp = (name: string, description: string, row: number): (() => Promise) => async () => { await waitForExists(page, $table.cell(row, Col.Name), name); expect(await innerText(page, $table.cell(row, Col.Token))).toBe(hiddenToken); expect(await innerText(page, $table.cell(row, Col.Description))).toBe(description); }; const updateApp = (id: number, data: {name?: string; description?: string}): (() => Promise) => async () => { await page.click($table.cell(id, Col.EditUpdate, '.edit')); await page.waitForSelector($dialog.selector()); if (data.name) { const nameSelector = $dialog.input('.name'); await clearField(page, nameSelector); await page.type(nameSelector, data.name); } if (data.description) { const descSelector = $dialog.textarea('.description'); await clearField(page, descSelector); await page.type(descSelector, data.description); } await page.click($dialog.button('.update')); await waitToDisappear(page, $dialog.selector()); }; const createApp = (name: string, description: string): (() => Promise) => async () => { await page.click('#create-app'); await page.waitForSelector($dialog.selector()); await page.type($dialog.input('.name'), name); await page.type($dialog.textarea('.description'), description); await page.click($dialog.button('.create')); await waitToDisappear(page, $dialog.selector()); }; describe('Application', () => { it('does login', async () => await auth.login(page)); it('navigates to applications', async () => { await page.click('#navigate-apps'); await waitForExists(page, selector.heading(), 'Applications'); }); it('has changed url', async () => { expect(page.url()).toContain('/applications'); }); it('does not have any applications', async () => { expect(await count(page, $table.rows())).toBe(0); }); describe('create apps', () => { it('server', createApp('server', '#1')); it('desktop', createApp('desktop', '#2')); it('raspberry', createApp('raspberry', '#3')); }); describe('has created apps', () => { it('has three apps', async () => { await page.waitForSelector($table.row(3)); expect(await count(page, $table.rows())).toBe(3); }); it('has server app', waitforApp('server', '#1', 1)); it('has desktop app', waitforApp('desktop', '#2', 2)); it('has raspberry app', waitforApp('raspberry', '#3', 3)); it('shows token', async () => { await page.click($table.cell(3, Col.Token, '.toggle-visibility')); const token = await innerText(page, $table.cell(3, Col.Token)); expect(token.startsWith('A')).toBeTruthy(); await page.click($table.cell(3, Col.Token, '.toggle-visibility')); }); }); it('updates application', async () => { await updateApp(1, {name: 'server_linux'})(); await updateApp(2, {description: 'kitchen_computer'})(); await updateApp(3, {name: 'raspberry_pi', description: 'home_pi'})(); }); it('has updated application', async () => { await waitforApp('server_linux', '#1', 1)(); await waitforApp('desktop', 'kitchen_computer', 2)(); await waitforApp('raspberry_pi', 'home_pi', 3)(); }); it('deletes application', async () => { await page.click($table.cell(2, Col.EditDelete, '.delete')); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); }); it('has deleted application', async () => { await waitToDisappear(page, $table.row(3)); expect(await count(page, $table.rows())).toBe(2); }); it('does logout', async () => await auth.logout(page)); }); ================================================ FILE: ui/src/tests/authentication.ts ================================================ import {Page} from 'puppeteer'; import {waitForExists} from './utils'; import {expect} from 'vitest'; import * as selector from './selector'; const $loginForm = selector.form('#login-form'); export const login = async (page: Page, user = 'admin', pass = 'admin'): Promise => { await waitForExists(page, selector.heading(), 'Login'); expect(page.url()).toContain('/login'); await page.type($loginForm.input('.name'), user); await page.type($loginForm.input('.password'), pass); await page.click($loginForm.button('.login')); await waitForExists(page, selector.heading(), 'All Messages'); await waitForExists(page, 'button', 'logout'); }; export const logout = async (page: Page): Promise => { await page.click('#logout'); await waitForExists(page, selector.heading(), 'Login'); expect(page.url()).toContain('/login'); }; ================================================ FILE: ui/src/tests/client.test.ts ================================================ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; let page: Page; let gotify: GotifyTest; beforeAll(async () => { gotify = await newTest(); page = gotify.page; }); afterAll(async () => await gotify.close()); enum Col { Name = 1, Token = 2, LastSeen = 3, Edit = 4, Delete = 5, } const waitForClient = (name: string, row: number): (() => Promise) => async () => { await waitForExists(page, $table.cell(row, Col.Name), name); }; const updateClient = (id: number, data: {name?: string}): (() => Promise) => async () => { await page.click($table.cell(id, Col.Edit, '.edit')); await page.waitForSelector($dialog.selector()); if (data.name) { const nameSelector = $dialog.input('.name'); await clearField(page, nameSelector); await page.type(nameSelector, data.name); } await page.click($dialog.button('.update')); await waitToDisappear(page, $dialog.selector()); }; const $table = selector.table('#client-table'); const $dialog = selector.form('#client-dialog'); describe('Client', () => { it('does login', async () => await auth.login(page)); it('navigates to clients', async () => { await page.click('#navigate-clients'); await waitForExists(page, selector.heading(), 'Clients'); }); it('has changed url', async () => { expect(page.url()).toContain('/clients'); }); it('has one client (the current session)', async () => { expect(await count(page, $table.rows())).toBe(1); }); describe('create clients', () => { const createClient = (name: string): (() => Promise) => async () => { await page.click('#create-client'); await page.waitForSelector($dialog.selector()); await page.type($dialog.input('.name'), name); await page.click($dialog.button('.create')); await waitToDisappear(page, $dialog.selector()); }; it('phone', createClient('phone')); it('desktop app', createClient('desktop app')); }); it('has created clients', async () => { await page.waitForSelector($table.row(3)); expect(await count(page, $table.rows())).toBe(3); expect(await innerText(page, $table.cell(1, Col.Name))).toContain('chrome'); expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone'); expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app'); }); it('updates client', updateClient(1, {name: 'firefox'})); it('has updated client name', waitForClient('firefox', 1)); it('shows token', async () => { await page.click($table.cell(3, Col.Token, '.toggle-visibility')); expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy(); }); it('shows last seen', async () => { expect(await innerText(page, $table.cell(3, Col.LastSeen))).toBeTruthy(); }); it('deletes client', async () => { await page.click($table.cell(2, Col.Delete, '.delete')); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); }); it('has deleted client', async () => { await waitToDisappear(page, $table.row(3)); expect(await count(page, $table.rows())).toBe(2); }); it('deletes own client', async () => { await page.click($table.cell(1, Col.Delete, '.delete')); // confirm delete await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); }); it('automatically logs out', async () => { await waitForExists(page, selector.heading(), 'Login'); }); }); ================================================ FILE: ui/src/tests/message.test.ts ================================================ // todo before all tests jest start puppeteer import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; import { clearField, clickByText, count, innerText, waitForCount, waitForExists, waitToDisappear, } from './utils'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; import axios from 'axios'; import {IApplication, IMessage, IMessageExtras} from '../types'; let page: Page; let gotify: GotifyTest; beforeAll(async () => { gotify = await newTest(); page = gotify.page; }); afterAll(async () => await gotify.close()); const axiosAuth = {auth: {username: 'admin', password: 'admin'}}; let windowsServerToken: string; let linuxServerToken: string; let backupServerToken: string; const naviId = '#message-navigation'; interface Msg { message: string; title: string; } const navigate = async (appName: string) => { await clickByText(page, 'a', appName); await waitForExists(page, selector.heading(), appName); }; describe('Messages', () => { it('does login', async () => await auth.login(page)); it('is on messages', async () => { await waitForExists(page, selector.heading(), 'All Messages'); }); it('has url', async () => { expect(page.url()).toContain('/'); }); const createApp = (name: string) => axios .post(`${gotify.url}/application`, {name}, axiosAuth) .then((resp) => resp.data.token); it('shows navigation', async () => { await page.waitForSelector(naviId); }); it('has all messages button', async () => { await page.waitForSelector(`${naviId} .all`); }); it('has no applications', async () => { expect(await count(page, `${naviId} .item`)).toBe(0); }); describe('create apps', () => { it('Windows', async () => { windowsServerToken = await createApp('Windows'); await page.reload(); await waitForExists(page, 'a', 'Windows'); }); it('Backup', async () => { backupServerToken = await createApp('Backup'); await page.reload(); await waitForExists(page, 'a', 'Backup'); }); it('Linux', async () => { linuxServerToken = await createApp('Linux'); await page.reload(); await waitForExists(page, 'a', 'Linux'); }); }); it('has three applications', async () => { expect(await count(page, `${naviId} .item`)).toBe(3); }); it('changes url when navigating to application', async () => { await navigate('Windows'); expect(page.url()).toContain('/messages/1'); await navigate('All Messages'); }); it('has no messages', async () => { expect(await count(page, '#messages .message')).toBe(0); }); it('has no messages in app', async () => { await navigate('Windows'); expect(await count(page, '#messages .message')).toBe(0); await navigate('All Messages'); }); it('hides push message on all messages', async () => { await navigate('All Messages'); expect(await count(page, '#push-message')).toBe(0); }); it('pushes a message via ui', async () => { await navigate('Windows'); await page.waitForSelector('#push-message'); await page.click('#push-message'); await page.waitForSelector('#push-message-dialog'); await page.type('#push-message-dialog .title input', 'UI Test'); await page.type('#push-message-dialog .message textarea', 'Hello from UI'); await clearField(page, '#push-message-dialog .priority input'); await page.type('#push-message-dialog .priority input', '2'); await page.click('#push-message-dialog .send'); await waitToDisappear(page, '#push-message-dialog'); expect(await extractMessages(1)).toEqual([m('UI Test', 'Hello from UI')]); await page.click('#messages .message .delete'); expect(await extractMessages(0)).toEqual([]); await navigate('All Messages'); }); const extractMessages = async (expectCount: number) => { await waitForCount(page, '#messages .message', expectCount); const messages = await page.$$(`#messages .message`); const result: Msg[] = []; for (const item of messages) { const message = await innerText(item, '.content'); const title = await innerText(item, '.title'); result.push({message, title}); } return result; }; const m = (title: string, message: string, extras?: IMessageExtras) => ({ title, message, extras, }); const windows1 = m('Login', 'User jmattheis logged in.'); const windows2 = m('Shutdown', 'Windows will be shut down.'); const windows3 = m('Login', 'User nicories logged in.'); const linux1 = m('SSH-Login', 'root@127.0.0.1 did a ssh login.'); const linux2 = m('Reboot', 'Linux server just rebooted.'); const linux3 = m('SSH-Login', 'jmattheis@localhost did a ssh login.'); const backup1 = m('Backup done', 'Linux Server Backup finished (1.6GB).'); const backup2 = m('Backup done', 'Windows Server Backup finished (6.2GB).'); const backup3 = m('Backup done', 'Gotify Backup finished (0.1MB).'); const createMessage = (msg: Partial, token: string) => axios.post(`${gotify.url}/message`, msg, { headers: {'X-Gotify-Key': token}, }); const expectMessages = async (toCheck: { all: Msg[]; windows: Msg[]; linux: Msg[]; backup: Msg[]; }) => { await navigate('All Messages'); expect(await extractMessages(toCheck.all.length)).toEqual(toCheck.all); await navigate('Windows'); expect(await extractMessages(toCheck.windows.length)).toEqual(toCheck.windows); await navigate('Linux'); expect(await extractMessages(toCheck.linux.length)).toEqual(toCheck.linux); await navigate('Backup'); expect(await extractMessages(toCheck.backup.length)).toEqual(toCheck.backup); await navigate('All Messages'); }; it('create a message', async () => { await createMessage(windows1, windowsServerToken); expect(await extractMessages(1)).toEqual([windows1]); }); it('has one message in windows app', async () => { await navigate('Windows'); expect(await extractMessages(1)).toEqual([windows1]); }); it('has no message in linux app', async () => { await navigate('Linux'); expect(await extractMessages(0)).toEqual([]); await navigate('All Messages'); }); describe('add some messages', () => { it('1', async () => { await createMessage(windows2, windowsServerToken); await expectMessages({ all: [windows2, windows1], windows: [windows2, windows1], linux: [], backup: [], }); }); it('2', async () => { await createMessage(linux1, linuxServerToken); await expectMessages({ all: [linux1, windows2, windows1], windows: [windows2, windows1], linux: [linux1], backup: [], }); }); it('3', async () => { await createMessage(backup1, backupServerToken); await expectMessages({ all: [backup1, linux1, windows2, windows1], windows: [windows2, windows1], linux: [linux1], backup: [backup1], }); }); it('4', async () => { await createMessage(windows3, windowsServerToken); await expectMessages({ all: [windows3, backup1, linux1, windows2, windows1], windows: [windows3, windows2, windows1], linux: [linux1], backup: [backup1], }); }); it('5', async () => { await createMessage(linux2, linuxServerToken); await expectMessages({ all: [linux2, windows3, backup1, linux1, windows2, windows1], windows: [windows3, windows2, windows1], linux: [linux2, linux1], backup: [backup1], }); }); }); it('deletes a windows message', async () => { await navigate('Windows'); await page.evaluate(() => ( document.querySelectorAll('#messages .message .delete')[1] as HTMLButtonElement ).click() ); await expectMessages({ all: [linux2, windows3, backup1, linux1, windows1], windows: [windows3, windows1], linux: [linux2, linux1], backup: [backup1], }); }); it('deletes all linux messages', async () => { await navigate('Linux'); await page.click('#delete-all'); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); await page.waitForSelector('#delete-all:disabled'); await expectMessages({ all: [windows3, backup1, windows1], windows: [windows3, windows1], linux: [], backup: [backup1], }); }); describe('add some more messages', () => { it('1', async () => { await createMessage(linux3, linuxServerToken); await expectMessages({ all: [linux3, windows3, backup1, windows1], windows: [windows3, windows1], linux: [linux3], backup: [backup1], }); }); it('2', async () => { await createMessage(backup2, backupServerToken); await expectMessages({ all: [backup2, linux3, windows3, backup1, windows1], windows: [windows3, windows1], linux: [linux3], backup: [backup2, backup1], }); }); }); it('deletes all messages', async () => { await navigate('All Messages'); await page.click('#delete-all'); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); await page.waitForSelector('#delete-all:disabled'); await expectMessages({ all: [], windows: [], linux: [], backup: [], }); }); it('adds one last message', async () => { await createMessage(backup3, backupServerToken); await expectMessages({ all: [backup3], windows: [], linux: [], backup: [backup3], }); }); it('deletes all backup messages and navigates to all messages', async () => { await navigate('Backup'); await page.click('#delete-all'); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); await page.waitForSelector('#delete-all:disabled'); await navigate('All Messages'); await createMessage(backup3, backupServerToken); await waitForExists(page, '.message .title', backup3.title); expect(await extractMessages(1)).toEqual([backup3]); }); it('does logout', async () => await auth.logout(page)); }); ================================================ FILE: ui/src/tests/plugin.test.ts ================================================ import * as os from 'os'; import {Page} from 'puppeteer'; import axios from 'axios'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; import {GotifyTest, newTest, newPluginDir} from './setup'; import {innerText, waitForCount, waitForExists} from './utils'; const pluginSupported = ['linux', 'darwin'].indexOf(os.platform()) !== -1; let page: Page; let gotify: GotifyTest; beforeAll(async () => { const gotifyPluginDir = pluginSupported ? await newPluginDir(['github.com/gotify/server/v2/plugin/example/echo']) : ''; gotify = await newTest(gotifyPluginDir); page = gotify.page; }); afterAll(async () => await gotify.close()); enum Col { ID = 1, SetEnabled = 2, Name = 3, Token = 4, Details = 5, } const hiddenToken = '•••••••••••••••'; const $table = selector.table('#plugin-table'); const switchSelctor = (id: number) => $table.cell(id, Col.SetEnabled, '[data-enabled]'); const enabledState = async (id: number) => (await page.$eval(switchSelctor(id), (el) => el.getAttribute('data-enabled'))) === 'true'; const toggleEnabled = async (id: number) => { const origEnabled = (await enabledState(id)).toString(); await page.click(switchSelctor(id)); await page.waitForFunction( `document.querySelector("${switchSelctor( id )}").getAttribute("data-enabled") !== "${origEnabled}"` ); }; const pluginInfo = async (className: string) => await innerText(page, `.plugin-info .${className} > span`); const getDisplayer = async () => await innerText(page, '.displayer'); const hasReceivedMessage = async (title: RegExp, content: RegExp) => { await page.click('#message-navigation a'); await waitForExists(page, selector.heading(), 'All Messages'); await waitForCount(page, '#messages .message', 1); expect(await innerText(page, '.title')).toMatch(title); expect(await innerText(page, '.content')).toMatch(content); await page.click('#navigate-plugins'); await waitForExists(page, selector.heading(), 'Plugins'); }; const inDetailPage = async (id: number, callback: () => Promise) => { const name = await innerText(page, $table.cell(id, Col.Name)); await page.click($table.cell(id, Col.Details, 'button')); await waitForExists(page, '.plugin-info .name > span', name); await callback(); await page.click('#navigate-plugins'); await waitForExists(page, selector.heading(), 'Plugins'); await page.waitForSelector($table.selector()); }; describe('plugin', () => { describe('navigation', () => { it('does login', async () => await auth.login(page)); it('navigates to plugins', async () => { await page.click('#navigate-plugins'); await waitForExists(page, selector.heading(), 'Plugins'); }); }); if (!pluginSupported) { return; } describe('functionality test', () => { describe('initial status', () => { it('has echo plugin', async () => { await waitForCount(page, $table.rows(), 1); expect(await innerText(page, $table.cell(1, Col.Name))).toEqual('test plugin'); expect(await innerText(page, $table.cell(1, Col.Token))).toBe(hiddenToken); expect(parseInt(await innerText(page, $table.cell(1, Col.ID)), 10)).toBeGreaterThan( 0 ); }); it('is disabled by default', async () => { expect(await enabledState(1)).toBe(false); }); }); describe('enable and disable plugin', () => { it('enable', async () => { await toggleEnabled(1); expect(await enabledState(1)).toBe(true); }); it('disable', async () => { await toggleEnabled(1); expect(await enabledState(1)).toBe(false); }); }); describe('details page', () => { it('has plugin info', async () => { await inDetailPage(1, async () => { expect(await pluginInfo('module-path')).toBe( 'github.com/gotify/server/v2/plugin/example/echo' ); }); }); it('has displayer', async () => { await inDetailPage(1, async () => { expect(await getDisplayer()).toBeTruthy(); }); }); it('has configurer', async () => { await inDetailPage(1, async () => { expect(await page.$('.configurer')).toBeTruthy(); }); }); it('updates configurer', async () => { await inDetailPage(1, async () => { expect( await ( await (await page.$('.config-save'))!.getProperty('disabled') ).jsonValue() ).toBe(true); await page.waitForSelector('.cm-editor .cm-content'); await page.waitForFunction( 'document.querySelector(".cm-editor .cm-content").innerText.toLowerCase().indexOf("loading")<0' ); await page.click('.cm-editor .cm-content > div'); await page.keyboard.press('x'); await page.waitForFunction( 'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled' ); await page.click('.config-save'); await page.waitForFunction('document.querySelector(".config-save").disabled'); }); }); it('configurer updated', async () => { await inDetailPage(1, async () => { expect( await ( await (await page.$('.config-save'))!.getProperty('disabled') ).jsonValue() ).toBe(true); await page.waitForSelector('.cm-editor .cm-content > div'); await page.waitForFunction( 'document.querySelector(".cm-editor .cm-content > div").innerText.toLowerCase().indexOf("loading")<0' ); expect(await innerText(page, '.cm-editor .cm-content > div')).toMatch(/x$/); }); }); it('sends messages', async () => { if (!(await enabledState(1))) { await toggleEnabled(1); } await inDetailPage(1, async () => { await page.waitForSelector('.displayer a'); const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href')); if (!hook) { throw 'href not found'; } await axios.get(hook); }); }); it('has received message', async () => { await hasReceivedMessage( /^.+received$/, /^echo server received a hello message \d+ times$/ ); }); }); }); }); ================================================ FILE: ui/src/tests/selector.ts ================================================ export const heading = () => `main h4`; export const table = (tableSelector: string) => ({ selector: () => tableSelector, rows: () => `${tableSelector} tbody tr`, row: (index: number) => `${tableSelector} tbody tr:nth-child(${index})`, cell: (index: number, col: number, suffix = '') => `${tableSelector} tbody tr:nth-child(${index}) td:nth-child(${col}) ${suffix}`, }); export const form = (dialogSelector: string) => ({ selector: () => dialogSelector, input: (selector: string) => `${dialogSelector} ${selector} input`, textarea: (selector: string) => `${dialogSelector} ${selector} textarea`, button: (selector: string) => `${dialogSelector} button${selector}`, }); export const $confirmDialog = form('.confirm-dialog'); ================================================ FILE: ui/src/tests/setup.ts ================================================ import getPort from 'get-port'; import {spawn, exec, ChildProcess} from 'child_process'; import {rimrafSync} from 'rimraf'; import path from 'path'; import puppeteer, {Browser, Page} from 'puppeteer'; import fs from 'fs'; // @ts-expect-error no types import wait from 'wait-on'; import kill from 'tree-kill'; export interface GotifyTest { url: string; close: () => Promise; browser: Browser; page: Page; } const windowsPrefix = process.platform === 'win32' ? '.exe' : ''; const appDotGo = path.join(__dirname, '..', '..', '..', 'app.go'); const testBuildPath = path.join(__dirname, 'build'); export const newPluginDir = async (plugins: string[]): Promise => { const {dir, generator} = testPluginDir(); for (const pluginName of plugins) { await buildGoPlugin(generator(), pluginName); } return dir; }; export const newTest = async (pluginsDir = ''): Promise => { const port = await getPort(); const gotifyFile = testFilePath(); await buildGoExecutable(gotifyFile); const gotifyInstance = startGotify(gotifyFile, port, pluginsDir); const gotifyURL = 'http://localhost:' + port; await waitForGotify('http-get://localhost:' + port); const browser = await puppeteer.launch({ headless: process.env.CI === 'true', args: [`--window-size=1920,1080`, '--no-sandbox'], }); const page = await browser.newPage(); await page.setViewport({width: 1920, height: 1080}); await page.goto(gotifyURL); return { close: async () => { await Promise.all([ browser.close(), new Promise((resolve) => kill(gotifyInstance.pid!, 'SIGKILL', () => resolve(undefined)) ), ]); rimrafSync(gotifyFile, {maxRetries: 8}); }, url: gotifyURL, browser, page, }; }; const testPluginDir = (): {dir: string; generator: () => string} => { const random = Math.random().toString(36).substring(2, 15); const dirName = 'gotifyplugin_' + random; const dir = path.join(testBuildPath, dirName); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, {recursive: true, mode: 0o755}); } return { dir, generator: () => { const randomFn = Math.random().toString(36).substring(2, 15); return path.join(dir, randomFn + '.so'); }, }; }; const testFilePath = (): string => { const random = Math.random().toString(36).substring(2, 15); const filename = 'gotifytest_' + random + windowsPrefix; return path.join(testBuildPath, filename); }; const waitForGotify = (url: string): Promise => new Promise((resolve, err) => { wait({resources: [url], timeout: 40000}, (error: string) => { if (error) { console.log(error); err(error); } else { resolve(); } }); }); const buildGoPlugin = (filename: string, pluginPath: string): Promise => { process.stdout.write(`### Building Plugin ${pluginPath}\n`); return new Promise((resolve) => exec(`go build -o ${filename} -buildmode=plugin ${pluginPath}`, () => resolve()) ); }; const buildGoExecutable = (filename: string): Promise => { const envGotify = process.env.GOTIFY_EXE; if (envGotify) { if (!fs.existsSync(testBuildPath)) { fs.mkdirSync(testBuildPath, {recursive: true}); } fs.copyFileSync(envGotify, filename); process.stdout.write(`### Copying ${envGotify} to ${filename}\n`); return Promise.resolve(); } else { process.stdout.write(`### Building Gotify ${filename}\n`); return new Promise((resolve) => exec(`go build -ldflags="-X main.Mode=prod" -o ${filename} ${appDotGo}`, () => resolve() ) ); } }; const startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => { const gotify = spawn(filename, [], { env: { GOTIFY_SERVER_PORT: '' + port, GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared', GOTIFY_PLUGINSDIR: pluginDir, NODE_ENV: process.env.NODE_ENV, PUBLIC_URL: process.env.PUBLIC_URL, }, }); gotify.stdout.pipe(process.stdout); gotify.stderr.pipe(process.stderr); return gotify; }; ================================================ FILE: ui/src/tests/user.test.ts ================================================ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; let page: Page; let gotify: GotifyTest; beforeAll(async () => { gotify = await newTest(); page = gotify.page; }); afterAll(async () => await gotify.close()); enum Col { Name = 1, Admin = 2, EditDelete = 3, } const $table = selector.table('#user-table'); const $dialog = selector.form('#add-edit-user-dialog'); describe('User', () => { it('does login', async () => await auth.login(page)); it('navigates to users through window location', async () => { await page.goto(gotify.url + '/#/users'); await waitForExists(page, selector.heading(), 'Users'); }); it('has changed url', async () => { expect(page.url()).toContain('/users'); }); it('has only admin user (the current one)', async () => { expect(await count(page, $table.rows())).toBe(1); }); describe('create users', () => { const createUser = (name: string, password: string, isAdmin: boolean): (() => Promise) => async () => { await page.click('#create-user'); await page.waitForSelector($dialog.selector()); await page.type($dialog.input('.name'), name); await page.type($dialog.input('.password'), password); if (isAdmin) { await page.click($dialog.input('.admin-rights')); } await page.click($dialog.button('.save-create')); await waitToDisappear(page, $dialog.selector()); }; it('nicories', createUser('nicories', '123', false)); it('jmattheis', createUser('jmattheis', 'noice', true)); it('dude', createUser('dude', '1', false)); }); const hasUser = (name: string, isAdmin: boolean, row: number): (() => Promise) => async () => { expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name); expect(await innerText(page, $table.cell(row, Col.Admin))).toBe(isAdmin ? 'Yes' : 'No'); }; describe('has created users', () => { it('has four users', async () => { await page.waitForSelector($table.row(4)); expect(await count(page, $table.rows())).toBe(4); }); it('has admin user', hasUser('admin', true, 1)); it('has nicories user', hasUser('nicories', false, 2)); it('has jmattheis user', hasUser('jmattheis', true, 3)); it('has dude user', hasUser('dude', false, 4)); }); describe('edit users', () => { it('changes password of jmattheis', async () => { await page.click($table.cell(3, Col.EditDelete, '.edit')); await page.waitForSelector($dialog.selector()); await page.type($dialog.input('.password'), 'unicorn'); await page.click($dialog.button('.save-create')); await waitToDisappear(page, $dialog.selector()); }); it('changed jmattheis', hasUser('jmattheis', true, 3)); it('changes name of nicories', async () => { await page.click($table.cell(2, 3, '.edit')); await page.waitForSelector($dialog.selector()); await clearField(page, $dialog.input('.name')); await page.type($dialog.input('.name'), 'nicolas'); await page.click($dialog.button('.save-create')); await waitToDisappear(page, $dialog.selector()); await waitForExists(page, $table.cell(2, Col.Name), 'nicolas'); }); it('changed nicories to nicolas', hasUser('nicolas', false, 2)); it('makes dude admin', async () => { await page.click($table.cell(4, Col.EditDelete, '.edit')); await page.waitForSelector($dialog.selector()); await page.click($dialog.input('.admin-rights')); await page.click($dialog.button('.save-create')); await waitToDisappear(page, $dialog.selector()); await waitForExists(page, $table.cell(4, Col.Admin), 'Yes'); }); it('made dude admin', hasUser('dude', true, 4)); }); it('deletes dude', async () => { await page.click($table.cell(4, Col.EditDelete, '.delete')); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); }); it('has deleted dude', async () => { await waitToDisappear(page, $table.row(4)); expect(await count(page, $table.rows())).toBe(3); }); it('changes password of current user', async () => { const $changepw = selector.form('#changepw-dialog'); await page.click('#changepw'); await page.waitForSelector($changepw.selector()); await page.type($changepw.input('.newpass'), 'changed'); await page.click($changepw.button('.change')); }); it('does logout', async () => await auth.logout(page)); it('can login with new password (admin)', async () => await auth.login(page, 'admin', 'changed')); it('does logout admin', async () => await auth.logout(page)); it('can login with nicolas', async () => await auth.login(page, 'nicolas', '123')); it('does logout nicolas', async () => await auth.logout(page)); it('can login with jmattheis', async () => await auth.login(page, 'jmattheis', 'unicorn')); it('does logout jmattheis', async () => await auth.logout(page)); }); ================================================ FILE: ui/src/tests/utils.ts ================================================ import {ElementHandle, JSHandle, Page} from 'puppeteer'; export const innerText = async (page: ElementHandle | Page, selector: string): Promise => { const element = await page.$(selector); const handle = await element!.getProperty('innerText'); const value = await handle.jsonValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (value as any).toString().trim(); }; export const clickByText = async (page: Page, selector: string, text: string): Promise => { await waitForExists(page, selector, text); text = text.toLowerCase(); await page.evaluate( (_selector, _text) => { ( Array.from(document.querySelectorAll(_selector)).filter( (element) => element.textContent?.toLowerCase().trim() === _text )[0] as HTMLButtonElement ).click(); }, selector, text ); }; export const count = async (page: Page, selector: string): Promise => page.$$(selector).then((elements) => elements.length); export const waitToDisappear = async (page: Page, selector: string): Promise => page.waitForFunction((_selector: string) => !document.querySelector(_selector), {}, selector); export const waitForCount = async ( page: Page, selector: string, amount: number ): Promise => page.waitForFunction( (_selector: string, _amount: number) => document.querySelectorAll(_selector).length === _amount, {}, selector, amount ); export const waitForExists = async (page: Page, selector: string, text: string): Promise => { text = text.toLowerCase(); await page.waitForFunction( (_selector: string, _text: string) => Array.from(document.querySelectorAll(_selector)).filter( (element) => element.textContent!.toLowerCase().trim() === _text ).length > 0, {}, selector, text ); }; export const clearField = async (element: ElementHandle | Page, selector: string) => { const elementHandle = await element.$(selector); if (!elementHandle) { throw 'element handle not set'; } await elementHandle.click(); await elementHandle.focus(); // click three times to select all await elementHandle.click({clickCount: 3}); await elementHandle.press('Backspace'); }; ================================================ FILE: ui/src/typedef/notifyjs.d.ts ================================================ // eslint-disable-next-line import Notify = require('notifyjs'); export as namespace notifyjs; export = Notify; ================================================ FILE: ui/src/typedef/react-timeago.d.ts ================================================ declare module 'react-timeago' { import React from 'react'; export type FormatterOptions = { style?: 'long' | 'short' | 'narrow'; locale?: string; }; export type Formatter = (options: FormatterOptions) => React.ReactNode; export interface ITimeAgoProps { date: string; formatter?: Formatter; } export default class TimeAgo extends React.Component {} } declare module 'react-timeago/defaultFormatter' { declare function makeIntlFormatter(options: FormatterOptions): Formatter; } ================================================ FILE: ui/src/types.ts ================================================ export interface IApplication { id: number; token: string; name: string; sortKey: string; description: string; image: string; internal: boolean; defaultPriority: number; lastUsed: string | null; } export interface IClient { id: number; token: string; name: string; lastUsed: string | null; } export interface IPlugin { id: number; token: string; name: string; modulePath: string; enabled: boolean; author?: string; website?: string; license?: string; capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>; } export interface IMessage { id: number; appid: number; message: string; title: string; priority: number; date: string; image?: string; extras?: IMessageExtras; } export interface IMessageExtras { [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } export interface IPagedMessages { paging: IPaging; messages: IMessage[]; } export interface IPaging { next?: string; since?: number; size: number; limit: number; } export interface IUser { id: number; name: string; admin: boolean; } export interface IVersion { version: string; commit: string; buildDate: string; } ================================================ FILE: ui/src/user/AddEditUserDialog.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import FormControlLabel from '@mui/material/FormControlLabel'; import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import React from 'react'; interface IProps { name?: string; admin?: boolean; fClose: VoidFunction; fOnSubmit: (name: string, pass: string, admin: boolean) => Promise; isEdit?: boolean; } const AddEditUserDialog = ({ fClose, fOnSubmit, isEdit, name: initialName = '', admin: initialAdmin = false, }: IProps) => { const [name, setName] = React.useState(initialName); const [pass, setPass] = React.useState(''); const [admin, setAdmin] = React.useState(initialAdmin); const namePresent = name.length !== 0; const passPresent = pass.length !== 0 || isEdit; const submitAndClose = async () => { await fOnSubmit(name, pass, admin); fClose(); }; return ( {isEdit ? 'Edit ' + name : 'Add a user'} setName(e.target.value)} fullWidth /> setPass(e.target.value)} /> setAdmin(e.target.checked)} value="admin" /> } label="has administrator rights" />
); }; export default AddEditUserDialog; ================================================ FILE: ui/src/user/Login.tsx ================================================ import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; import TextField from '@mui/material/TextField'; import React from 'react'; import Container from '../common/Container'; import DefaultPage from '../common/DefaultPage'; import * as config from '../config'; import RegistrationDialog from './Register'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; import {useNavigate} from 'react-router'; const Login = observer(() => { const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); const [registerDialog, setRegisterDialog] = React.useState(false); const {currentUser} = useStores(); const navigate = useNavigate(); React.useEffect(() => { if (currentUser.loggedIn) { navigate('/'); } }, [currentUser.loggedIn]); const registerButton = () => { if (config.get('register')) return ( ); else return null; }; const login = (e: React.MouseEvent) => { e.preventDefault(); currentUser.login(username, password); }; return (
e.preventDefault()} id="login-form"> setUsername(e.target.value)} /> setPassword(e.target.value)} />
{registerDialog && ( setRegisterDialog(false)} fOnSubmit={currentUser.register} /> )}
); }); export default Login; ================================================ FILE: ui/src/user/Register.tsx ================================================ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import React from 'react'; interface IProps { name?: string; fClose: VoidFunction; fOnSubmit: (name: string, pass: string) => Promise; } const RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => { const [name, setName] = React.useState(initialName); const [pass, setPass] = React.useState(''); const namePresent = name.length !== 0; const passPresent = pass.length !== 0; const handleNameChange = (e: React.ChangeEvent) => { setName(e.target.value); }; const handlePassChange = (e: React.ChangeEvent) => { setPass(e.target.value); }; const submitAndClose = (): void => { fOnSubmit(name, pass).then((success) => { if (success) { fClose(); } }); }; return ( Registration
); }; export default RegistrationDialog; ================================================ FILE: ui/src/user/UserStore.ts ================================================ import {BaseStore} from '../common/BaseStore'; import axios from 'axios'; import * as config from '../config'; import {action} from 'mobx'; import {SnackReporter} from '../snack/SnackManager'; import {IUser} from '../types'; export class UserStore extends BaseStore { constructor(private readonly snack: SnackReporter) { super(); } protected requestItems = (): Promise => axios.get(`${config.get('url')}user`).then((response) => response.data); protected requestDelete(id: number): Promise { return axios .delete(`${config.get('url')}user/${id}`) .then(() => this.snack('User deleted')); } @action public create = async (name: string, pass: string, admin: boolean) => { await axios.post(`${config.get('url')}user`, {name, pass, admin}); await this.refresh(); this.snack('User created'); }; @action public update = async (id: number, name: string, pass: string | null, admin: boolean) => { await axios.post(config.get('url') + 'user/' + id, {name, pass, admin}); await this.refresh(); this.snack('User updated'); }; } ================================================ FILE: ui/src/user/Users.tsx ================================================ import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import React from 'react'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import Button from '@mui/material/Button'; import AddEditDialog from './AddEditUserDialog'; import {IUser} from '../types'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; interface IRowProps { name: string; admin: boolean; fDelete: VoidFunction; fEdit: VoidFunction; } const UserRow: React.FC = ({name, admin, fDelete, fEdit}) => ( {name} {admin ? 'Yes' : 'No'} ); const Users = observer(() => { const [deleteUser, setDeleteUser] = React.useState(); const [editUser, setEditUser] = React.useState(); const [createDialog, setCreateDialog] = React.useState(false); const {userStore} = useStores(); React.useEffect(() => void userStore.refresh(), []); const users = userStore.getItems(); return ( setCreateDialog(true)}> Create User }> Username Admin {users.map((user: IUser) => ( setDeleteUser(user)} fEdit={() => setEditUser(user)} /> ))}
{createDialog && ( setCreateDialog(false)} fOnSubmit={userStore.create} /> )} {editUser && ( setEditUser(undefined)} fOnSubmit={userStore.update.bind(this, editUser.id)} name={editUser.name} admin={editUser.admin} isEdit={true} /> )} {deleteUser && ( setDeleteUser(undefined)} fOnSubmit={() => userStore.remove(deleteUser.id)} /> )}
); }); export default Users; ================================================ FILE: ui/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], "allowImportingTsExtensions": true, "sourceMap": true, "allowJs": true, "jsx": "react", "moduleResolution": "node", "rootDir": "src", "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, "esModuleInterop": true, "strict": true, "isolatedModules": true, "noEmit": true, "module": "esnext", "resolveJsonModule": true, "noFallthroughCasesInSwitch": true }, "exclude": [ "node_modules", "build", "scripts", "acceptance-tests", "webpack", "jest", "src/setupTests.ts" ], "include": [ "src" ] } ================================================ FILE: ui/tsconfig.prod.json ================================================ { "extends": "./tsconfig.json" } ================================================ FILE: ui/tsconfig.test.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs" } } ================================================ FILE: ui/vite-env.d.ts ================================================ // Example: vite-env.d.ts /// ================================================ FILE: ui/vite.config.ts ================================================ import {defineConfig} from 'vite'; import react from '@vitejs/plugin-react'; const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80'; export default defineConfig({ base: './', build: { outDir: 'build', emptyOutDir: true, sourcemap: false, assetsDir: 'static', }, plugins: [react()], define: { // Some libraries use the global object, even though it doesn't exist in the browser. // Alternatively, we could add `` to index.html. // https://github.com/vitejs/vite/discussions/5912 global: {}, }, server: { host: '0.0.0.0', proxy: { '^/(application|message|client|current|user|plugin|version|image)': { target: `http://localhost:${GOTIFY_SERVER_PORT}/`, changeOrigin: true, secure: false, }, '/stream': { target: `ws://localhost:${GOTIFY_SERVER_PORT}/`, ws: true, rewriteWsOrigin: true, }, }, cors: false, }, }); ================================================ FILE: ui/vitest.config.js ================================================ import {defineConfig} from 'vitest/config'; const timeout = process.env.CI === 'true' ? 60000 : 30000; export default defineConfig({ test: { testTimeout: timeout, hookTimeout: timeout, }, });