[
  {
    "path": ".dockerignore",
    "content": "vendor/\n.idea/\nbuild/\nlicenses/\ncoverage.txt\ndata/\nimages/\n.git/\n*/node_modules/\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_size = 4\ntrim_trailing_whitespace = true\n\n[*.go]\nindent_style = tab\n\n[*.{js,ts,tsx}]\nindent_style = space\nquote_type = single\n\n[*.json]\nindent_style = space\n\n[*.html]\nindent_style = space\n\n[*.md]\nindent_style = space\ntrim_trailing_whitespace = false\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: jmattheis\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: https://jmattheis.de/donate\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Found a bug? Tell us and help us improve\ntitle: ''\nlabels: a:bug\nassignees: ''\n\n---\n\n**Can the issue be reproduced with the latest available release? (y/n)**\n\n**Which one is the environment gotify server is running in?**\n- [ ] Docker\n- [ ] Linux machine\n- [ ] Windows machine\n<details><summary>Docker startup command or config file here (please mask sensitive information)</summary><br>\n\n```\n\n```\n</details>\n\n**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)**\n- [ ] None\n- [ ] Nginx\n- [ ] Apache\n- [ ] Caddy\n<details><summary>Reverse proxy configuration (please mask sensitive information)</summary><br>\n\n```\n\n```\n</details>\n\n**On which client do you experience problems? (Select as many as you can see)**\n- [ ] WebUI\n- [ ] gotify-cli\n- [ ] Android Client <!-- (Please open the issue in gotify/android instead if it is only related to the android client) -->\n- [ ] 3rd-party API call (Please include your code)\n\n\n**What did you do?**\n\n**What did you expect to see?**\n\n**What did you see instead? (Include screenshots, android logcat/request dumps if possible)**\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: a:feature\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/questions.md",
    "content": "---\nname: Questions\nabout: Having difficulties with gotify? Feel free to ask here\ntitle: ''\nlabels: question\nassignees: ''\n\n---\n\n<!-- \nAlternative ways to get help:\nOfficial documentation - https://gotify.net/\nCommunity chat - https://matrix.to/#/#gotify:matrix.org\n-->\n\n**Have you read the documentation?**\n- [ ] Yes, but it does not include related information regarding my question.\n- [ ] Yes, but the steps described in the documentation do not work on my machine.\n- [ ] Yes, but I am having difficulty understanding it and want clarification.\n\n**You are setting up gotify in**\n- [ ] Docker\n- [ ] Linux native platform\n- [ ] Windows native platform\n\n\n**Describe your problem**\n<!-- EXAMPLE\nI'm having difficulties setting up my apache reverse proxy\n....\nmy config is ...\n-->\n\n\n\n**Any errors, logs, or other information that might help us identify your problem**\n\nEx: `docker-compose.yml`, `nginx.conf`, android logcat, browser requests, etc.\n\n<details><summary>Name of the information here</summary><br><pre>\n\ncontents here\n\n</pre></details>\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\non: [push, pull_request]\n\njobs:\n  gotify:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/setup-go@v6\n        with:\n          go-version: 1.26.x\n      - uses: actions/setup-node@v6\n        with:\n          node-version: '24'\n      - uses: actions/checkout@v6\n      - run: (cd ui && yarn)\n      - run: make build-js\n      - uses: golangci/golangci-lint-action@v9\n        with:\n          version: v2.11.3\n          args: --timeout=5m\n          skip-cache: true\n      - run: go mod download\n      - run: make download-tools\n      - run: make test\n      - run: make check-ci\n      - uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n      - if: startsWith(github.ref, 'refs/tags/v')\n        run: echo \"VERSION=${GITHUB_REF/refs\\/tags\\/v/}\" >> $GITHUB_ENV\n      - if: startsWith(github.ref, 'refs/tags/v')\n        run: |\n          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\"\n          echo \"LD_FLAGS=$LD_FLAGS\" >> $GITHUB_ENV\n\n          make build\n          sudo chown -R $UID build\n          make package-zip\n          ls -lath build\n      - if: startsWith(github.ref, 'refs/tags/v')\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      - if: startsWith(github.ref, 'refs/tags/v')\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n      - if: startsWith(github.ref, 'refs/tags/v')\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_USER }}\n          password: ${{ secrets.DOCKER_PASS }}\n      - if: startsWith(github.ref, 'refs/tags/v')\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ secrets.DOCKER_GHCR_USER }}\n          password: ${{ secrets.DOCKER_GHCR_PASS }}\n      - if: startsWith(github.ref, 'refs/tags/v')\n        run: |\n          make DOCKER_BUILD_PUSH=true build-docker\n      - if: startsWith(github.ref, 'refs/tags/v')\n        uses: svenstaro/upload-release-action@v2\n        with:\n          repo_token: ${{ secrets.GITHUB_TOKEN }}\n          file: build/*.zip\n          tag: ${{ github.ref }}\n          overwrite: true\n          file_glob: true\n"
  },
  {
    "path": ".gitignore",
    "content": "vendor/\n.idea/\nbuild/\ncerts/\nbuild/\nlicenses/\ncoverage.txt\n*/node_modules/\n**/*-packr.go\nconfig.yml\ndata/\nimages/"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nlinters:\n  enable:\n    - asciicheck\n    - copyloopvar\n    - godot\n    - gomodguard\n    - goprintffuncname\n    - misspell\n    - nakedret\n    - nolintlint\n    - sqlclosecheck\n    - staticcheck\n    - unconvert\n    - whitespace\n  disable:\n    - err113\n    - errcheck\n    - funlen\n    - gochecknoglobals\n    - gocognit\n    - goconst\n    - gocyclo\n    - godox\n    - lll\n    - nestif\n    - nlreturn\n    - noctx\n    - testpackage\n    - wsl\n  settings:\n    misspell:\n      locale: US\n  exclusions:\n    generated: lax\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    paths:\n      - plugin/example\n      - plugin/testing\n      - third_party$\n      - builtin$\n      - examples$\nformatters:\n  enable:\n    - gofmt\n    - gofumpt\n    - goimports\n  settings:\n    gofumpt:\n      extra-rules: true\n  exclusions:\n    generated: lax\n    paths:\n      - plugin/example\n      - plugin/testing\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "* @gotify/committers"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn 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.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment include:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or advances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Our Responsibilities\n\nProject 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.\n\nProject 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.\n\n## Scope\n\nThis 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.\n\n## Enforcement\n\nInstances 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.\n\nProject 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.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThanks for your interest in Gotify!\n\nFirst 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. \n\nIf you have any questions you can join the chat on [#gotify:matrix.org](https://matrix.to/#/#gotify:matrix.org).\n\n## Where to Contribute\n\n|                                                         Repository|                                   Description|               Technology|\n|                                                                ---|                                           ---|                      ---|\n|[gotify/server](https://github.com/gotify/server)                  |server implementation and WebUI code          |`Go` `Typescript` `React`|\n|[gotify/android](https://github.com/gotify/android)                |android client implementation                 |`Java` `Android`         |\n|[gotify/plugin-template](https://github.com/gotify/plugin-template)|official gotify plugin template               |`Go`                     |\n|[gotify/cli](https://github.com/gotify/cli)                        |official CLI client                           |`Go`                     |\n|[gotify/website](https://github.com/gotify/website)                |documentaion [gotify.net](https://gotify.net/)|`Markdown` `Docusaurus`  |\n|[gotify/contrib](https://github.com/gotify/contrib)                |community-contributed projects                |`misc`                   |    \n\n## Ways to Contribute\n\n### Document Refinements\n\n_Keywords: **Documentation**, **Writing**_\n\nDocuments 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.\n\n### Feature Request and implementation\n\n_Keywords: **Features**, **Coding**_\n\nWhen proposing features to gotify/\\*, please first discuss the change you wish to make via issue, chat or any other method with the maintainers.\n\nAfter 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.\n\n### Bug Reports and Fixes\n\n_Keywords: **Bug Hunt**, **Coding**_\n\nIf 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.\n\nAfter 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.\n\nIf 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.\n\n### Community Contribution Projects\n\n_Keywords:_ **Features**, **Coding**, **Writing**\n\nMake gotify more powerful and easy-to-use than ever by:\n - writing a [plugin](https://gotify.net/docs/plugin)\n - writing a client (smartphones, Windows, Linux, Browser Add-on, etc.)\n - writing about how you have used gotify for your applications\n \nAlso, 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.\n"
  },
  {
    "path": "GO_VERSION",
    "content": "1.26.0\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018 jmattheis\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n---\n\nThe Gotify logo is licensed under the Creative Commons Attribution 4.0 International Public License.\nhttp://creativecommons.org/licenses/by/4.0/"
  },
  {
    "path": "Makefile",
    "content": "LICENSE_DIR=./licenses/\nBUILD_DIR=./build\nDOCKER_DIR=./docker/\nSHELL := /bin/bash\nGO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//')\nDOCKER_BUILD_IMAGE=docker.io/gotify/build\nDOCKER_WORKDIR=/proj\nDOCKER_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}\nDOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags \"$$LD_FLAGS\"\nDOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build\n\ntest: test-coverage test-js\ncheck: check-go check-swagger check-js\ncheck-ci: check-swagger check-js\n\nrequire-version:\n\tif [ -n ${VERSION} ] && [[ $$VERSION == \"v\"* ]]; then echo \"The version may not start with v\" && exit 1; fi\n\tif [ -z ${VERSION} ]; then echo \"Need to set VERSION\" && exit 1; fi;\n\ntest-coverage:\n\tgo test --race -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./...\n\nformat:\n\tgoimports -w $(shell find . -type f -name '*.go' -not -path \"./vendor/*\")\n\ntest-js:\n\tgo build -ldflags=\"-s -w -X main.Mode=prod\" -o removeme/gotify app.go\n\t(cd ui && CI=true GOTIFY_EXE=../removeme/gotify yarn test)\n\trm -rf removeme\n\ncheck-go:\n\tgolangci-lint run\n\ncheck-js:\n\t(cd ui && yarn lint)\n\t(cd ui && yarn testformat)\n\ndownload-tools:\n\tgo install github.com/go-swagger/go-swagger/cmd/swagger@717e3cb29becaaf00e56953556c6d80f8a01b286\n\nupdate-swagger:\n\tswagger generate spec --scan-models -o docs/spec.json\n\tsed -i 's/\"uint64\"/\"int64\"/g' docs/spec.json\n\ncheck-swagger: update-swagger\n## add the docs to git, this changes line endings in git, otherwise this does not work on windows\n\tgit add docs\n\tif [ -n \"$(shell git status --porcelain | grep docs)\" ]; then \\\n        echo Swagger Spec is not up-to-date; \\\n        exit 1; \\\n    fi\n\nextract-licenses:\n\tmkdir ${LICENSE_DIR} || true\n\tfor LICENSE in $(shell find vendor/* -name LICENSE); do \\\n\t\tDIR=`echo $$LICENSE | tr \"/\" _ | sed -e 's/vendor_//; s/_LICENSE//'` ; \\\n        cp $$LICENSE ${LICENSE_DIR}$$DIR ; \\\n    done\n\npackage-zip: extract-licenses\n\tfor BUILD in $(shell find ${BUILD_DIR}/*); do \\\n       zip -j $$BUILD.zip $$BUILD ./LICENSE; \\\n       zip -ur $$BUILD.zip ${LICENSE_DIR}; \\\n    done\n\nbuild-docker-multiarch: require-version\n\tdocker buildx build --sbom=true --provenance=true \\\n\t\t$(if $(DOCKER_BUILD_PUSH),--push) \\\n\t\t--label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \\\n\t\t--label org.opencontainers.image.version=$(VERSION) \\\n\t\t--label org.opencontainers.image.created=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \\\n\t\t-t gotify/server:latest \\\n\t\t-t gotify/server:${VERSION} \\\n\t\t-t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t    -t ghcr.io/gotify/server:latest \\\n\t\t-t ghcr.io/gotify/server:${VERSION} \\\n\t\t-t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t gotify/server-arm64:latest \\\n\t\t-t gotify/server-arm64:${VERSION} \\\n\t\t-t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t ghcr.io/gotify/server-arm64:latest \\\n\t\t-t ghcr.io/gotify/server-arm64:${VERSION} \\\n\t\t-t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t gotify/server-arm7:latest \\\n\t\t-t gotify/server-arm7:${VERSION} \\\n\t\t-t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t ghcr.io/gotify/server-arm7:latest \\\n\t\t-t ghcr.io/gotify/server-arm7:${VERSION} \\\n\t\t-t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t gotify/server-riscv64:latest \\\n\t\t-t gotify/server-riscv64:${VERSION} \\\n\t\t-t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t-t ghcr.io/gotify/server-riscv64:latest \\\n\t\t-t ghcr.io/gotify/server-riscv64:${VERSION} \\\n\t\t-t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \\\n\t\t-t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \\\n\t\t--build-arg RUN_TESTS=$(DOCKER_TEST_LEVEL) \\\n\t\t--build-arg GO_VERSION=$(GO_VERSION) \\\n\t\t--build-arg LD_FLAGS=\"$$LD_FLAGS\" \\\n\t\t--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/riscv64 \\\n\t\t-f docker/Dockerfile .\n\nbuild-docker: build-docker-multiarch\n\n_build_within_docker: OUTPUT = gotify-app\n_build_within_docker:\n\t${DOCKER_GO_BUILD} -o ${OUTPUT}\n\nbuild-js:\n\t(cd ui && yarn build)\n\nbuild-linux-amd64:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64\n\nbuild-linux-386:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-386\n\nbuild-linux-arm-7:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm-7 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm-7\n\nbuild-linux-arm64:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm64\n\nbuild-linux-riscv64:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-riscv64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-riscv64\n\nbuild-windows-amd64:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-amd64.exe\n\nbuild-windows-386:\n\t${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-386.exe\n\nbuild: build-linux-arm-7 build-linux-amd64 build-linux-386 build-linux-arm64 build-linux-riscv64 build-windows-amd64 build-windows-386\n\n.PHONY: test-coverage test check-go check-js verify-swagger check download-tools update-swagger package-zip build-docker build-js build\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <a href=\"https://github.com/gotify/logo\">\n        <img height=\"275px\" src=\"https://raw.githubusercontent.com/gotify/logo/master/gotify-logo.png\" />\n    </a>\n</p>\n\n<h1 align=\"center\">gotify/server</h1>\n\n<p align=\"center\">\n    <a href=\"https://github.com/gotify/server/actions/workflows/build.yml\">\n        <img alt=\"Build Status\" src=\"https://github.com/gotify/server/actions/workflows/build.yml/badge.svg\">\n    </a>\n    <a href=\"https://codecov.io/gh/gotify/server\">\n        <img alt=\"codecov\" src=\"https://codecov.io/gh/gotify/server/branch/master/graph/badge.svg\">\n    </a>\n    <a href=\"https://goreportcard.com/report/github.com/gotify/server\">\n        <img alt=\"Go Report Card\" src=\"https://goreportcard.com/badge/github.com/gotify/server\">\n    </a>\n    <a href=\"https://matrix.to/#/#gotify:matrix.org\">\n        <img alt=\"Matrix\" src=\"https://img.shields.io/matrix/gotify:matrix.org.svg\">\n    </a>\n    <a href=\"https://hub.docker.com/r/gotify/server\">\n        <img alt=\"Docker Pulls\" src=\"https://img.shields.io/docker/pulls/gotify/server.svg\">\n    </a>\n    <a href=\"https://github.com/gotify/server/releases/latest\">\n        <img alt=\"latest release\" src=\"https://img.shields.io/github/release/gotify/server.svg\">\n    </a>\n</p>\n\n## Intro\nWe 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.\n\n## Features\n\n<img alt=\"Gotify UI screenshot\" src=\"ui.png\" align=\"right\" width=\"500px\"/>\n\n* send messages via REST-API\n* receive messages via WebSocket\n* manage users, clients and applications\n* [Plugins](https://gotify.net/docs/plugin)\n* Web-UI -> [./ui](ui)\n* CLI for sending messages -> [gotify/cli](https://github.com/gotify/cli)\n* Android-App -> [gotify/android](https://github.com/gotify/android)\n\n[<img src=\"https://play.google.com/intl/en_gb/badges/images/generic/en_badge_web_generic.png\" alt=\"Get it on Google Play\" width=\"150\" />][playstore]\n[<img src=\"https://f-droid.org/badge/get-it-on.png\" alt=\"Get it on F-Droid\" width=\"150\"/>][fdroid]\n\n<sub>(Google Play and the Google Play logo are trademarks of Google LLC.)</sub>\n\n---\n\n**[Documentation](https://gotify.net/docs)**\n\n[Install](https://gotify.net/docs/install) ᛫\n[Configuration](https://gotify.net/docs/config) ᛫\n[REST-API](https://gotify.net/api-docs) ᛫\n[Setup Dev Environment](https://gotify.net/docs/dev-setup)\n\n## Contributing\n\nWe welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## Versioning\nWe use [SemVer](http://semver.org/) for versioning. For the versions available, see the\n[tags on this repository](https://github.com/gotify/server/tags).\n\n## License\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details\n\n [playstore]: https://play.google.com/store/apps/details?id=com.github.gotify\n [fdroid]: https://f-droid.org/de/packages/com.github.gotify/\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nOnly the latest version.\n\n## Reporting a Vulnerability\n\nPlease report (suspected) security vulnerabilities to\n**[gotify@protonmail.com](mailto:gotify@protonmail.com)**. You will receive a\nresponse from us within a few days. If the issue is confirmed, we will release a\npatch as soon as possible.\n"
  },
  {
    "path": "api/application.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/h2non/filetype\"\n\t\"gorm.io/gorm\"\n)\n\n// The ApplicationDatabase interface for encapsulating database access.\ntype ApplicationDatabase interface {\n\tCreateApplication(application *model.Application) error\n\tGetApplicationByToken(token string) (*model.Application, error)\n\tGetApplicationByID(id uint) (*model.Application, error)\n\tGetApplicationsByUser(userID uint) ([]*model.Application, error)\n\tDeleteApplicationByID(id uint) error\n\tUpdateApplication(application *model.Application) error\n}\n\n// The ApplicationAPI provides handlers for managing applications.\ntype ApplicationAPI struct {\n\tDB       ApplicationDatabase\n\tImageDir string\n}\n\n// Application Params Model\n//\n// Params allowed to create or update Applications.\n//\n// swagger:model ApplicationParams\ntype ApplicationParams struct {\n\t// The application name. This is how the application should be displayed to the user.\n\t//\n\t// required: true\n\t// example: Backup Server\n\tName string `form:\"name\" query:\"name\" json:\"name\" binding:\"required\"`\n\t// The description of the application.\n\t//\n\t// example: Backup server for the interwebs\n\tDescription string `form:\"description\" query:\"description\" json:\"description\"`\n\t// The default priority of messages sent by this application. Defaults to 0.\n\t//\n\t// example: 5\n\tDefaultPriority int `form:\"defaultPriority\" query:\"defaultPriority\" json:\"defaultPriority\"`\n\t// The sortKey for the application. Uses fractional indexing.\n\t//\n\t// example: a1\n\tSortKey string `form:\"sortKey\" query:\"sortKey\" json:\"sortKey\"`\n}\n\n// CreateApplication creates an application and returns the access token.\n// swagger:operation POST /application application createApp\n//\n// Create an application.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the application to add\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/ApplicationParams\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Application\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {\n\tapplicationParams := ApplicationParams{}\n\tif err := ctx.Bind(&applicationParams); err == nil {\n\t\tapp := model.Application{\n\t\t\tName:            applicationParams.Name,\n\t\t\tDescription:     applicationParams.Description,\n\t\t\tDefaultPriority: applicationParams.DefaultPriority,\n\t\t\tSortKey:         applicationParams.SortKey,\n\t\t\tToken:           auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists),\n\t\t\tUserID:          auth.GetUserID(ctx),\n\t\t\tInternal:        false,\n\t\t}\n\n\t\tif err := a.DB.CreateApplication(&app); err != nil {\n\t\t\thandleApplicationError(ctx, err)\n\t\t\treturn\n\t\t}\n\t\tctx.JSON(200, withResolvedImage(&app))\n\t}\n}\n\n// GetApplications returns all applications a user has.\n// swagger:operation GET /application application getApps\n//\n// Return all applications.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      type: array\n//\t      items:\n//\t        $ref: \"#/definitions/Application\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) GetApplications(ctx *gin.Context) {\n\tuserID := auth.GetUserID(ctx)\n\tapps, err := a.DB.GetApplicationsByUser(userID)\n\tif success := successOrAbort(ctx, 500, err); !success {\n\t\treturn\n\t}\n\tfor _, app := range apps {\n\t\twithResolvedImage(app)\n\t}\n\tctx.JSON(200, apps)\n}\n\n// DeleteApplication deletes an application by its id.\n// swagger:operation DELETE /application/{id} application deleteApp\n//\n// Delete an application.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tapp, err := a.DB.GetApplicationByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\tif app.Internal {\n\t\t\t\tctx.AbortWithError(400, errors.New(\"cannot delete internal application\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif success := successOrAbort(ctx, 500, a.DB.DeleteApplicationByID(id)); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif app.Image != \"\" {\n\t\t\t\tos.Remove(a.ImageDir + app.Image)\n\t\t\t}\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"app with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\n// UpdateApplication updates an application info by its id.\n// swagger:operation PUT /application/{id} application updateApplication\n//\n// Update an application.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the application to update\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/ApplicationParams\"\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Application\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tapp, err := a.DB.GetApplicationByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\tapplicationParams := ApplicationParams{}\n\t\t\tif err := ctx.Bind(&applicationParams); err == nil {\n\t\t\t\tapp.Description = applicationParams.Description\n\t\t\t\tapp.Name = applicationParams.Name\n\t\t\t\tapp.DefaultPriority = applicationParams.DefaultPriority\n\t\t\t\tif applicationParams.SortKey != \"\" {\n\t\t\t\t\tapp.SortKey = applicationParams.SortKey\n\t\t\t\t}\n\n\t\t\t\tif err := a.DB.UpdateApplication(app); err != nil {\n\t\t\t\t\thandleApplicationError(ctx, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tctx.JSON(200, withResolvedImage(app))\n\t\t\t}\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"app with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\n// UploadApplicationImage uploads an image for an application.\n// swagger:operation POST /application/{id}/image application uploadAppImage\n//\n// Upload an image for an application.\n//\n//\t---\n//\tconsumes:\n//\t- multipart/form-data\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: file\n//\t  in: formData\n//\t  description: the application image\n//\t  required: true\n//\t  type: file\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Application\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tapp, err := a.DB.GetApplicationByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\tfile, err := ctx.FormFile(\"file\")\n\t\t\tif err == http.ErrMissingFile {\n\t\t\t\tctx.AbortWithError(400, errors.New(\"file with key 'file' must be present\"))\n\t\t\t\treturn\n\t\t\t} else if err != nil {\n\t\t\t\tctx.AbortWithError(500, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\thead := make([]byte, 261)\n\t\t\topen, _ := file.Open()\n\t\t\topen.Read(head)\n\t\t\tif !filetype.IsImage(head) {\n\t\t\t\tctx.AbortWithError(400, errors.New(\"file must be an image\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\text := filepath.Ext(file.Filename)\n\t\t\tif !ValidApplicationImageExt(ext) {\n\t\t\t\tctx.AbortWithError(400, errors.New(\"invalid file extension\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tname := generateNonExistingImageName(a.ImageDir, func() string {\n\t\t\t\treturn generateImageName() + ext\n\t\t\t})\n\n\t\t\terr = ctx.SaveUploadedFile(file, a.ImageDir+name)\n\t\t\tif err != nil {\n\t\t\t\tctx.AbortWithError(500, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif app.Image != \"\" {\n\t\t\t\tos.Remove(a.ImageDir + app.Image)\n\t\t\t}\n\n\t\t\tapp.Image = name\n\t\t\tif success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tctx.JSON(200, withResolvedImage(app))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"app with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\n// RemoveApplicationImage deletes an image of an application.\n// swagger:operation DELETE /application/{id}/image application removeAppImage\n//\n// Deletes an image of an application.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tapp, err := a.DB.GetApplicationByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\tif app.Image == \"\" {\n\t\t\t\tctx.AbortWithError(400, fmt.Errorf(\"app with id %d does not have a customized image\", id))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\timage := app.Image\n\t\t\tapp.Image = \"\"\n\t\t\tif success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tos.Remove(a.ImageDir + image)\n\t\t\tctx.JSON(200, withResolvedImage(app))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"app with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\nfunc withResolvedImage(app *model.Application) *model.Application {\n\tif app.Image == \"\" {\n\t\t// This must stay in sync with the isDefaultImage check in ui/src/application/Applications.tsx.\n\t\tapp.Image = \"static/defaultapp.png\"\n\t} else {\n\t\tapp.Image = \"image/\" + app.Image\n\t}\n\treturn app\n}\n\nfunc (a *ApplicationAPI) applicationExists(token string) bool {\n\tapp, _ := a.DB.GetApplicationByToken(token)\n\treturn app != nil\n}\n\nfunc exist(path string) bool {\n\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc generateNonExistingImageName(imgDir string, gen func() string) string {\n\tfor {\n\t\tname := gen()\n\t\tif !exist(imgDir + name) {\n\t\t\treturn name\n\t\t}\n\t}\n}\n\nfunc ValidApplicationImageExt(ext string) bool {\n\tswitch strings.ToLower(ext) {\n\tcase \".gif\", \".png\", \".jpg\", \".jpeg\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc handleApplicationError(ctx *gin.Context, err error) {\n\tif errors.Is(err, gorm.ErrDuplicatedKey) {\n\t\tctx.AbortWithError(400, errors.New(\"sort key is not unique\"))\n\t} else {\n\t\tctx.AbortWithError(500, err)\n\t}\n}\n"
  },
  {
    "path": "api/application_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nvar (\n\tfirstApplicationToken  = \"Aaaaaaaaaaaaaaa\"\n\tsecondApplicationToken = \"Abbbbbbbbbbbbbb\"\n\tthirdApplicationToken  = \"Acccccccccccccc\"\n)\n\nfunc TestApplicationSuite(t *testing.T) {\n\tsuite.Run(t, new(ApplicationSuite))\n}\n\ntype ApplicationSuite struct {\n\tsuite.Suite\n\tdb       *testdb.Database\n\ta        *ApplicationAPI\n\tctx      *gin.Context\n\trecorder *httptest.ResponseRecorder\n}\n\nvar (\n\toriginalGenerateApplicationToken func() string\n\toriginalGenerateImageName        func() string\n)\n\nfunc (s *ApplicationSuite) BeforeTest(suiteName, testName string) {\n\toriginalGenerateApplicationToken = generateApplicationToken\n\toriginalGenerateImageName = generateImageName\n\tgenerateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken)\n\tgenerateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:])\n\tmode.Set(mode.TestDev)\n\ts.recorder = httptest.NewRecorder()\n\ts.db = testdb.NewDB(s.T())\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n\twithURL(s.ctx, \"http\", \"example.com\")\n\ts.a = &ApplicationAPI{DB: s.db}\n}\n\nfunc (s *ApplicationSuite) AfterTest(suiteName, testName string) {\n\tgenerateApplicationToken = originalGenerateApplicationToken\n\tgenerateImageName = originalGenerateImageName\n\ts.db.Close()\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name&description=description_text&sortKey=a5\")\n\ts.a.CreateApplication(s.ctx)\n\n\texpected := &model.Application{\n\t\tID:          1,\n\t\tToken:       firstApplicationToken,\n\t\tUserID:      5,\n\t\tName:        \"custom_name\",\n\t\tDescription: \"description_text\",\n\t\tSortKey:     \"a5\",\n\t}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), expected, app)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() {\n\tactual := &model.Application{\n\t\tID:          1,\n\t\tUserID:      2,\n\t\tToken:       \"Aasdasfgeeg\",\n\t\tName:        \"myapp\",\n\t\tDescription: \"mydesc\",\n\t\tImage:       \"asd\",\n\t\tInternal:    true,\n\t\tLastUsed:    nil,\n\t\tSortKey:     \"a1\",\n\t}\n\ttest.JSONEquals(s.T(), actual, `{\"id\":1,\"token\":\"Aasdasfgeeg\",\"name\":\"myapp\",\"description\":\"mydesc\", \"image\": \"asd\", \"internal\":true, \"defaultPriority\":0, \"lastUsed\":null, \"sortKey\":\"a1\"}`)\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=&description=description_text\")\n\ts.a.CreateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tif app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), app)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInParams() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withJSON(&model.Application{\n\t\tName:        \"name\",\n\t\tDescription: \"description\",\n\t\tID:          333,\n\t\tInternal:    true,\n\t\tToken:       \"token\",\n\t\tImage:       \"adfdf\",\n\t\tSortKey:     \"a5\",\n\t})\n\n\ts.a.CreateApplication(s.ctx)\n\n\texpectedJSONValue, _ := json.Marshal(&model.Application{\n\t\tID:          1,\n\t\tToken:       firstApplicationToken,\n\t\tUserID:      5,\n\t\tName:        \"name\",\n\t\tDescription: \"description\",\n\t\tInternal:    false,\n\t\tImage:       \"static/defaultapp.png\",\n\t\tSortKey:     \"a5\",\n\t})\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), string(expectedJSONValue), s.recorder.Body.String())\n}\n\nfunc (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() {\n\ts.db.User(2)\n\ts.db.User(5).App(5)\n\n\ttest.WithUser(s.ctx, 2)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/5\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"5\"}}\n\n\ts.a.DeleteApplication(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n\ts.db.AssertAppExist(5)\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name\")\n\ts.a.CreateApplication(s.ctx)\n\n\texpected := &model.Application{ID: 1, Token: firstApplicationToken, Name: \"custom_name\", UserID: 5, SortKey: \"a0\"}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Contains(s.T(), app, expected)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name\")\n\n\ts.a.CreateApplication(s.ctx)\n\n\texpected := &model.Application{\n\t\tID:      1,\n\t\tToken:   firstApplicationToken,\n\t\tName:    \"custom_name\",\n\t\tImage:   \"static/defaultapp.png\",\n\t\tUserID:  5,\n\t\tSortKey: \"a0\",\n\t}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {\n\ts.db.User(5)\n\ts.db.User(6).AppWithToken(1, firstApplicationToken)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name\")\n\n\ts.a.CreateApplication(s.ctx)\n\n\texpected := &model.Application{ID: 2, Token: secondApplicationToken, Name: \"custom_name\", UserID: 5, SortKey: \"a0\"}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Contains(s.T(), app, expected)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_Sorting() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=one\")\n\ts.a.CreateApplication(s.ctx)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=two\")\n\ts.a.CreateApplication(s.ctx)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=three\")\n\ts.a.CreateApplication(s.ctx)\n\n\tapps, err := s.db.GetApplicationsByUser(5)\n\trequire.NoError(s.T(), err)\n\trequire.Len(s.T(), apps, 3)\n\tassert.Equal(s.T(), apps[0].Name, \"one\")\n\tassert.Equal(s.T(), apps[0].SortKey, \"a0\")\n\tassert.Equal(s.T(), apps[1].Name, \"two\")\n\tassert.Equal(s.T(), apps[1].SortKey, \"a1\")\n\tassert.Equal(s.T(), apps[2].Name, \"three\")\n\tassert.Equal(s.T(), apps[2].SortKey, \"a2\")\n\n\ts.withFormData(\"name=one&description=&sortKey=a1V\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(apps[0].ID)}}\n\ts.a.UpdateApplication(s.ctx)\n\n\tapps, err = s.db.GetApplicationsByUser(5)\n\trequire.NoError(s.T(), err)\n\trequire.Len(s.T(), apps, 3)\n\tassert.Equal(s.T(), apps[0].Name, \"two\")\n\tassert.Equal(s.T(), apps[0].SortKey, \"a1\")\n\tassert.Equal(s.T(), apps[1].Name, \"one\")\n\tassert.Equal(s.T(), apps[1].SortKey, \"a1V\")\n\tassert.Equal(s.T(), apps[2].Name, \"three\")\n\tassert.Equal(s.T(), apps[2].SortKey, \"a2\")\n}\n\nfunc (s *ApplicationSuite) Test_GetApplications() {\n\tuserBuilder := s.db.User(5)\n\tfirst := userBuilder.NewAppWithToken(1, \"perfper\")\n\tsecond := userBuilder.NewAppWithToken(2, \"asdasd\")\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/tokens\", nil)\n\n\ts.a.GetApplications(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tfirst.Image = \"static/defaultapp.png\"\n\tsecond.Image = \"static/defaultapp.png\"\n\ttest.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)\n}\n\nfunc (s *ApplicationSuite) Test_GetApplications_WithImage() {\n\tuserBuilder := s.db.User(5)\n\tfirst := userBuilder.NewAppWithToken(1, \"perfper\")\n\tsecond := userBuilder.NewAppWithToken(2, \"asdasd\")\n\tfirst.Image = \"abcd.jpg\"\n\ts.db.UpdateApplication(first)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/tokens\", nil)\n\n\ts.a.GetApplications(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tfirst.Image = \"image/abcd.jpg\"\n\tsecond.Image = \"static/defaultapp.png\"\n\ttest.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)\n}\n\nfunc (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() {\n\ts.db.User(5).InternalApp(10)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/\"+firstApplicationToken, nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"10\"}}\n\n\ts.a.DeleteApplication(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/\"+firstApplicationToken, nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"4\"}}\n\n\ts.a.DeleteApplication(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_DeleteApplication() {\n\ts.db.User(5).App(1)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/\"+firstApplicationToken, nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.DeleteApplication(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertAppNotExist(1)\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() {\n\ts.db.User(5).App(1)\n\tvar b bytes.Buffer\n\twriter := multipart.NewWriter(&b)\n\twriter.Close()\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &b)\n\ts.ctx.Request.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tassert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New(\"file with key 'file' must be present\"))\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() {\n\ts.db.User(5).App(1)\n\tvar b bytes.Buffer\n\twriter := multipart.NewWriter(&b)\n\tdefer writer.Close()\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &b)\n\ts.ctx.Request.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 500, s.recorder.Code)\n\tassert.Error(s.T(), s.ctx.Errors[0].Err, \"multipart: NextPart: EOF\")\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() {\n\ts.db.User(5).App(1)\n\n\tcType, buffer, err := upload(map[string]*os.File{\"file\": mustOpen(\"../test/assets/image.png\")})\n\tassert.Nil(s.T(), err)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &buffer)\n\ts.ctx.Request.Header.Set(\"Content-Type\", cType)\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tif app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {\n\t\timgName := app.Image\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\t\t_, err = os.Stat(imgName)\n\t\tassert.Nil(s.T(), err)\n\n\t\ts.a.DeleteApplication(s.ctx)\n\n\t\t_, err = os.Stat(imgName)\n\t\tassert.True(s.T(), os.IsNotExist(err))\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() {\n\texistingImageName := \"2lHMAel6BDHLL-HrwphcviX-l.png\"\n\tfirstGeneratedImageName := firstApplicationToken[1:] + \".png\"\n\tsecondGeneratedImageName := secondApplicationToken[1:] + \".png\"\n\ts.db.User(5)\n\ts.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})\n\n\tcType, buffer, err := upload(map[string]*os.File{\"file\": mustOpen(\"../test/assets/image.png\")})\n\tassert.Nil(s.T(), err)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &buffer)\n\ts.ctx.Request.Header.Set(\"Content-Type\", cType)\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\tfakeImage(s.T(), existingImageName)\n\tfakeImage(s.T(), firstGeneratedImageName)\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\n\t_, err = os.Stat(existingImageName)\n\tassert.True(s.T(), os.IsNotExist(err))\n\n\t_, err = os.Stat(secondGeneratedImageName)\n\tassert.Nil(s.T(), err)\n\tassert.Nil(s.T(), os.Remove(secondGeneratedImageName))\n\tassert.Nil(s.T(), os.Remove(firstGeneratedImageName))\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() {\n\ts.db.User(5)\n\ts.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: \"existing.png\"})\n\n\tfakeImage(s.T(), \"existing.png\")\n\tcType, buffer, err := upload(map[string]*os.File{\"file\": mustOpen(\"../test/assets/image.png\")})\n\tassert.Nil(s.T(), err)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &buffer)\n\ts.ctx.Request.Header.Set(\"Content-Type\", cType)\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\n\t_, err = os.Stat(\"existing.png\")\n\tassert.True(s.T(), os.IsNotExist(err))\n\n\tos.Remove(firstApplicationToken[1:] + \".png\")\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() {\n\ts.db.User(5).App(1)\n\n\tcType, buffer, err := upload(map[string]*os.File{\"file\": mustOpen(\"../test/assets/text.txt\")})\n\tassert.Nil(s.T(), err)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &buffer)\n\ts.ctx.Request.Header.Set(\"Content-Type\", cType)\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tassert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New(\"file must be an image\"))\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_WithHtmlFileHavingImageHeader() {\n\ts.db.User(5).App(1)\n\n\tcType, buffer, err := upload(map[string]*os.File{\"file\": mustOpen(\"../test/assets/image-header-with.html\")})\n\tassert.Nil(s.T(), err)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", &buffer)\n\ts.ctx.Request.Header.Set(\"Content-Type\", cType)\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tassert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New(\"invalid file extension\"))\n}\n\nfunc (s *ApplicationSuite) Test_UploadAppImage_expectNotFound() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/irrelevant\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"4\"}}\n\n\ts.a.UploadApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_RemoveAppImage_expectNotFound() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/irrelevant\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"4\"}}\n\n\ts.a.RemoveApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_RemoveAppImage_noCustomizedImage() {\n\ts.db.User(5).App(1)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/irrelevant\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\ts.a.RemoveApplicationImage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() {\n\ts.db.User(5)\n\n\timageFile := \"existing.png\"\n\ts.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile})\n\tfakeImage(s.T(), imageFile)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/irrelevant\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\ts.a.RemoveApplicationImage(s.ctx)\n\n\t_, err := os.Stat(imageFile)\n\tassert.True(s.T(), os.IsNotExist(err))\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSuccess() {\n\ts.db.User(5).NewAppWithToken(2, \"app-2\")\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=new_name&description=new_description_text\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.UpdateApplication(s.ctx)\n\n\texpected := &model.Application{\n\t\tID:          2,\n\t\tToken:       \"app-2\",\n\t\tUserID:      5,\n\t\tName:        \"new_name\",\n\t\tDescription: \"new_description_text\",\n\t\tSortKey:     \"a0\",\n\t}\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), expected, app)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {\n\ts.db.User(5).NewAppWithToken(2, \"app-2\")\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=new_name\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.UpdateApplication(s.ctx)\n\n\texpected := &model.Application{\n\t\tID:          2,\n\t\tToken:       \"app-2\",\n\t\tUserID:      5,\n\t\tName:        \"new_name\",\n\t\tDescription: \"\",\n\t\tSortKey:     \"a0\",\n\t}\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), expected, app)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() {\n\ts.db.User(5).NewAppWithToken(2, \"app-2\")\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=name&description=&defaultPriority=4\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.UpdateApplication(s.ctx)\n\n\texpected := &model.Application{\n\t\tID:              2,\n\t\tToken:           \"app-2\",\n\t\tUserID:          5,\n\t\tName:            \"name\",\n\t\tDescription:     \"\",\n\t\tDefaultPriority: 4,\n\t\tSortKey:         \"a0\",\n\t}\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), expected, app)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() {\n\tapp := s.db.User(5).NewAppWithToken(2, \"app-2\")\n\tapp.Image = \"existing.png\"\n\tapp.SortKey = \"a5\"\n\tassert.Nil(s.T(), s.db.UpdateApplication(app))\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=new_name\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), \"existing.png\", app.Image)\n\t\tassert.Equal(s.T(), \"a5\", app.SortKey)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() {\n\tapp := s.db.User(5).NewAppWithToken(2, \"app-2\")\n\tapp.Description = \"my desc\"\n\tassert.Nil(s.T(), s.db.UpdateApplication(app))\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=new_name&desc=\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), \"\", app.Description)\n\t}\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() {\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttributes_expectBadRequest() {\n\ttest.WithUser(s.ctx, 5)\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFound() {\n\ts.db.User(5).NewAppWithToken(2, \"app-2\")\n\n\ttest.WithUser(s.ctx, 4)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() {\n\tuser := s.db.User(5)\n\tuser.App(1) // sortKey=a0\n\tuser.App(2) // sortKey=a1\n\n\ts.withFormData(\"name=new_name&sortKey=a0\")\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.UpdateApplication(s.ctx)\n\n\tassert.EqualError(s.T(), s.ctx.Errors[0].Err, \"sort key is not unique\")\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *ApplicationSuite) withFormData(formData string) {\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/token\", strings.NewReader(formData))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n}\n\nfunc (s *ApplicationSuite) withJSON(value interface{}) {\n\tjsonVal, _ := json.Marshal(value)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/application\", bytes.NewBuffer(jsonVal))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n}\n\n// A modified version of https://stackoverflow.com/a/20397167/4244993 from Attila O.\nfunc upload(values map[string]*os.File) (contentType string, buffer bytes.Buffer, err error) {\n\tw := multipart.NewWriter(&buffer)\n\tfor key, r := range values {\n\t\tvar fw io.Writer\n\t\tif fw, err = w.CreateFormFile(key, r.Name()); err != nil {\n\t\t\treturn contentType, buffer, err\n\t\t}\n\n\t\tif _, err = io.Copy(fw, r); err != nil {\n\t\t\treturn contentType, buffer, err\n\t\t}\n\t}\n\tcontentType = w.FormDataContentType()\n\tw.Close()\n\treturn contentType, buffer, err\n}\n\nfunc mustOpen(f string) *os.File {\n\tr, err := os.Open(f)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn r\n}\n\nfunc fakeImage(t *testing.T, path string) {\n\tdata, err := os.ReadFile(\"../test/assets/image.png\")\n\tassert.Nil(t, err)\n\t// Write data to dst\n\terr = os.WriteFile(path, data, 0o644)\n\tassert.Nil(t, err)\n}\n"
  },
  {
    "path": "api/client.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The ClientDatabase interface for encapsulating database access.\ntype ClientDatabase interface {\n\tCreateClient(client *model.Client) error\n\tGetClientByToken(token string) (*model.Client, error)\n\tGetClientByID(id uint) (*model.Client, error)\n\tGetClientsByUser(userID uint) ([]*model.Client, error)\n\tDeleteClientByID(id uint) error\n\tUpdateClient(client *model.Client) error\n}\n\n// The ClientAPI provides handlers for managing clients and applications.\ntype ClientAPI struct {\n\tDB            ClientDatabase\n\tImageDir      string\n\tNotifyDeleted func(uint, string)\n}\n\n// Client Params Model\n//\n// Params allowed to create or update Clients.\n//\n// swagger:model ClientParams\ntype ClientParams struct {\n\t// The client name\n\t//\n\t// required: true\n\t// example: My Client\n\tName string `form:\"name\" query:\"name\" json:\"name\" binding:\"required\"`\n}\n\n// UpdateClient updates a client by its id.\n// swagger:operation PUT /client/{id} client updateClient\n//\n// Update a client.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the client to update\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/ClientParams\"\n//\t- name: id\n//\t  in: path\n//\t  description: the client id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Client\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ClientAPI) UpdateClient(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tclient, err := a.DB.GetClientByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif client != nil && client.UserID == auth.GetUserID(ctx) {\n\t\t\tnewValues := ClientParams{}\n\t\t\tif err := ctx.Bind(&newValues); err == nil {\n\t\t\t\tclient.Name = newValues.Name\n\n\t\t\t\tif success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tctx.JSON(200, client)\n\t\t\t}\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"client with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\n// CreateClient creates a client and returns the access token.\n// swagger:operation POST /client client createClient\n//\n// Create a client.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the client to add\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/ClientParams\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Client\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ClientAPI) CreateClient(ctx *gin.Context) {\n\tclientParams := ClientParams{}\n\tif err := ctx.Bind(&clientParams); err == nil {\n\t\tclient := model.Client{\n\t\t\tName:   clientParams.Name,\n\t\t\tToken:  auth.GenerateNotExistingToken(generateClientToken, a.clientExists),\n\t\t\tUserID: auth.GetUserID(ctx),\n\t\t}\n\n\t\tif success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success {\n\t\t\treturn\n\t\t}\n\t\tctx.JSON(200, client)\n\t}\n}\n\n// GetClients returns all clients a user has.\n// swagger:operation GET /client client getClients\n//\n// Return all clients.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      type: array\n//\t      items:\n//\t        $ref: \"#/definitions/Client\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ClientAPI) GetClients(ctx *gin.Context) {\n\tuserID := auth.GetUserID(ctx)\n\tclients, err := a.DB.GetClientsByUser(userID)\n\tif success := successOrAbort(ctx, 500, err); !success {\n\t\treturn\n\t}\n\tctx.JSON(200, clients)\n}\n\n// DeleteClient deletes a client by its id.\n// swagger:operation DELETE /client/{id} client deleteClient\n//\n// Delete a client.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the client id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *ClientAPI) DeleteClient(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tclient, err := a.DB.GetClientByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif client != nil && client.UserID == auth.GetUserID(ctx) {\n\t\t\ta.NotifyDeleted(client.UserID, client.Token)\n\t\t\tsuccessOrAbort(ctx, 500, a.DB.DeleteClientByID(id))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, fmt.Errorf(\"client with id %d doesn't exists\", id))\n\t\t}\n\t})\n}\n\nfunc (a *ClientAPI) clientExists(token string) bool {\n\tclient, _ := a.DB.GetClientByToken(token)\n\treturn client != nil\n}\n"
  },
  {
    "path": "api/client_test.go",
    "content": "package api\n\nimport (\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nvar (\n\tfirstClientToken  = \"Caaaaaaaaaaaaaa\"\n\tsecondClientToken = \"Cbbbbbbbbbbbbbb\"\n)\n\nfunc TestClientSuite(t *testing.T) {\n\tsuite.Run(t, new(ClientSuite))\n}\n\ntype ClientSuite struct {\n\tsuite.Suite\n\tdb       *testdb.Database\n\ta        *ClientAPI\n\tctx      *gin.Context\n\trecorder *httptest.ResponseRecorder\n\tnotified bool\n}\n\nvar originalGenerateClientToken func() string\n\nfunc (s *ClientSuite) BeforeTest(suiteName, testName string) {\n\toriginalGenerateClientToken = generateClientToken\n\tgenerateClientToken = test.Tokens(firstClientToken, secondClientToken)\n\tmode.Set(mode.TestDev)\n\ts.recorder = httptest.NewRecorder()\n\ts.db = testdb.NewDB(s.T())\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n\twithURL(s.ctx, \"http\", \"example.com\")\n\ts.notified = false\n\ts.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify}\n}\n\nfunc (s *ClientSuite) notify(uint, string) {\n\ts.notified = true\n}\n\nfunc (s *ClientSuite) AfterTest(suiteName, testName string) {\n\tgenerateClientToken = originalGenerateClientToken\n\ts.db.Close()\n}\n\nfunc (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {\n\tactual := &model.Client{ID: 1, UserID: 2, Token: \"Casdasfgeeg\", Name: \"myclient\"}\n\ttest.JSONEquals(s.T(), actual, `{\"id\":1,\"token\":\"Casdasfgeeg\",\"name\":\"myclient\",\"lastUsed\":null}`)\n}\n\nfunc (s *ClientSuite) Test_CreateClient_mapAllParameters() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name&description=description_text\")\n\n\ts.a.CreateClient(s.ctx)\n\n\texpected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: \"custom_name\"}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Contains(s.T(), clients, expected)\n\t}\n}\n\nfunc (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() {\n\ts.db.User(5)\n\ttest.WithUser(s.ctx, 5)\n\n\ts.withFormData(\"name=myclient&ID=45&Token=12341234&UserID=333\")\n\n\ts.a.CreateClient(s.ctx)\n\texpected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: \"myclient\"}\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Contains(s.T(), clients, expected)\n\t}\n}\n\nfunc (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=&description=description_text\")\n\n\ts.a.CreateClient(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tif clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), clients)\n\t}\n}\n\nfunc (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() {\n\ts.db.User(5).Client(7)\n\ts.db.User(2)\n\n\ttest.WithUser(s.ctx, 2)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/7\", nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"7\"}}\n\n\ts.a.DeleteClient(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n\ts.db.AssertClientExist(7)\n}\n\nfunc (s *ClientSuite) Test_CreateClient_returnsClientWithID() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name\")\n\n\ts.a.CreateClient(s.ctx)\n\n\texpected := &model.Client{ID: 1, Token: firstClientToken, Name: \"custom_name\", UserID: 5}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *ClientSuite) Test_CreateClient_withExistingToken() {\n\ts.db.User(5).ClientWithToken(1, firstClientToken)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=custom_name\")\n\n\ts.a.CreateClient(s.ctx)\n\n\texpected := &model.Client{ID: 2, Token: secondClientToken, Name: \"custom_name\", UserID: 5}\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *ClientSuite) Test_GetClients() {\n\tuserBuilder := s.db.User(5)\n\tfirst := userBuilder.NewClientWithToken(1, \"perfper\")\n\tsecond := userBuilder.NewClientWithToken(2, \"asdasd\")\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/tokens\", nil)\n\n\ts.a.GetClients(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder)\n}\n\nfunc (s *ClientSuite) Test_DeleteClient_expectNotFound() {\n\ts.db.User(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/\"+firstClientToken, nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"8\"}}\n\n\ts.a.DeleteClient(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ClientSuite) Test_DeleteClient() {\n\ts.db.User(5).Client(8)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Request = httptest.NewRequest(\"DELETE\", \"/token/\"+firstClientToken, nil)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"8\"}}\n\n\tassert.False(s.T(), s.notified)\n\n\ts.a.DeleteClient(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertClientNotExist(8)\n\tassert.True(s.T(), s.notified)\n}\n\nfunc (s *ClientSuite) Test_UpdateClient_expectSuccess() {\n\ts.db.User(5).NewClientWithToken(1, firstClientToken)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.withFormData(\"name=firefox\")\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\ts.a.UpdateClient(s.ctx)\n\n\texpected := &model.Client{\n\t\tID:     1,\n\t\tToken:  firstClientToken,\n\t\tUserID: 5,\n\t\tName:   \"firefox\",\n\t}\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), expected, client)\n\t}\n}\n\nfunc (s *ClientSuite) Test_UpdateClient_expectNotFound() {\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.UpdateClient(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() {\n\ttest.WithUser(s.ctx, 5)\n\ts.a.UpdateClient(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *ClientSuite) withFormData(formData string) {\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/token\", strings.NewReader(formData))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n}\n\nfunc withURL(ctx *gin.Context, scheme, host string) {\n\tctx.Set(\"location\", &url.URL{Scheme: scheme, Host: host})\n}\n"
  },
  {
    "path": "api/errorHandling.go",
    "content": "package api\n\nimport \"github.com/gin-gonic/gin\"\n\nfunc successOrAbort(ctx *gin.Context, code int, err error) (success bool) {\n\tif err != nil {\n\t\tctx.AbortWithError(code, err)\n\t}\n\treturn err == nil\n}\n"
  },
  {
    "path": "api/errorHandling_test.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestErrorHandling(t *testing.T) {\n\trec := httptest.NewRecorder()\n\n\tctx, _ := gin.CreateTestContext(rec)\n\tsuccessOrAbort(ctx, 500, errors.New(\"err\"))\n\n\tif rec.Code != 500 {\n\t\tt.Fail()\n\t}\n}\n"
  },
  {
    "path": "api/health.go",
    "content": "package api\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The HealthDatabase interface for encapsulating database access.\ntype HealthDatabase interface {\n\tPing() error\n}\n\n// The HealthAPI provides handlers for the health information.\ntype HealthAPI struct {\n\tDB HealthDatabase\n}\n\n// Health returns health information.\n// swagger:operation GET /health health getHealth\n//\n// Get health information.\n//\n//\t---\n//\tproduces: [application/json]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Health\"\n//\t  500:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Health\"\nfunc (a *HealthAPI) Health(ctx *gin.Context) {\n\tif err := a.DB.Ping(); err != nil {\n\t\tctx.JSON(500, model.Health{\n\t\t\tHealth:   model.StatusOrange,\n\t\t\tDatabase: model.StatusRed,\n\t\t})\n\t\treturn\n\t}\n\tctx.JSON(200, model.Health{\n\t\tHealth:   model.StatusGreen,\n\t\tDatabase: model.StatusGreen,\n\t})\n}\n"
  },
  {
    "path": "api/health_test.go",
    "content": "package api\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc TestHealthSuite(t *testing.T) {\n\tsuite.Run(t, new(HealthSuite))\n}\n\ntype HealthSuite struct {\n\tsuite.Suite\n\tdb       *testdb.Database\n\ta        *HealthAPI\n\tctx      *gin.Context\n\trecorder *httptest.ResponseRecorder\n}\n\nfunc (s *HealthSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n\ts.recorder = httptest.NewRecorder()\n\ts.db = testdb.NewDB(s.T())\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n\twithURL(s.ctx, \"http\", \"example.com\")\n\ts.a = &HealthAPI{DB: s.db}\n}\n\nfunc (s *HealthSuite) AfterTest(suiteName, testName string) {\n\ts.db.Close()\n}\n\nfunc (s *HealthSuite) TestHealthSuccess() {\n\ts.a.Health(s.ctx)\n\ttest.BodyEquals(s.T(), model.Health{Health: model.StatusGreen, Database: model.StatusGreen}, s.recorder)\n}\n\nfunc (s *HealthSuite) TestDatabaseFailure() {\n\ts.db.Close()\n\ts.a.Health(s.ctx)\n\ttest.BodyEquals(s.T(), model.Health{Health: model.StatusOrange, Database: model.StatusRed}, s.recorder)\n}\n"
  },
  {
    "path": "api/internalutil.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"math/bits\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc withID(ctx *gin.Context, name string, f func(id uint)) {\n\tif id, err := strconv.ParseUint(ctx.Param(name), 10, bits.UintSize); err == nil {\n\t\tf(uint(id))\n\t} else {\n\t\tctx.AbortWithError(400, errors.New(\"invalid id\"))\n\t}\n}\n"
  },
  {
    "path": "api/message.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gin-gonic/gin/binding\"\n\t\"github.com/gotify/location\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The MessageDatabase interface for encapsulating database access.\ntype MessageDatabase interface {\n\tGetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error)\n\tGetApplicationByID(id uint) (*model.Application, error)\n\tGetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error)\n\tDeleteMessageByID(id uint) error\n\tGetMessageByID(id uint) (*model.Message, error)\n\tDeleteMessagesByUser(userID uint) error\n\tDeleteMessagesByApplication(applicationID uint) error\n\tCreateMessage(message *model.Message) error\n\tGetApplicationByToken(token string) (*model.Application, error)\n}\n\nvar timeNow = time.Now\n\n// Notifier notifies when a new message was created.\ntype Notifier interface {\n\tNotify(userID uint, message *model.MessageExternal)\n}\n\n// The MessageAPI provides handlers for managing messages.\ntype MessageAPI struct {\n\tDB       MessageDatabase\n\tNotifier Notifier\n}\n\ntype pagingParams struct {\n\tLimit int  `form:\"limit\" binding:\"min=1,max=200\"`\n\tSince uint `form:\"since\" binding:\"min=0\"`\n}\n\n// GetMessages returns all messages from a user.\n// swagger:operation GET /message message getMessages\n//\n// Return all messages.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: limit\n//\t  in: query\n//\t  description: the maximal amount of messages to return\n//\t  required: false\n//\t  maximum: 200\n//\t  minimum: 1\n//\t  default: 100\n//\t  type: integer\n//\t- name: since\n//\t  in: query\n//\t  description: return all messages with an ID less than this value\n//\t  minimum: 0\n//\t  required: false\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/PagedMessages\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) GetMessages(ctx *gin.Context) {\n\tuserID := auth.GetUserID(ctx)\n\twithPaging(ctx, func(params *pagingParams) {\n\t\t// the +1 is used to check if there are more messages and will be removed on buildWithPaging\n\t\tmessages, err := a.DB.GetMessagesByUserSince(userID, params.Limit+1, params.Since)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tctx.JSON(200, buildWithPaging(ctx, params, messages))\n\t})\n}\n\nfunc buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages {\n\tnext := \"\"\n\tsince := uint(0)\n\tuseMessages := messages\n\tif len(messages) > paging.Limit {\n\t\tuseMessages = messages[:len(messages)-1]\n\t\tsince = useMessages[len(useMessages)-1].ID\n\t\turl := location.Get(ctx)\n\t\turl.Path = ctx.Request.URL.Path\n\t\tquery := url.Query()\n\t\tquery.Add(\"limit\", strconv.Itoa(paging.Limit))\n\t\tquery.Add(\"since\", strconv.FormatUint(uint64(since), 10))\n\t\turl.RawQuery = query.Encode()\n\t\tnext = url.String()\n\t}\n\treturn &model.PagedMessages{\n\t\tPaging:   model.Paging{Size: len(useMessages), Limit: paging.Limit, Next: next, Since: since},\n\t\tMessages: toExternalMessages(useMessages),\n\t}\n}\n\nfunc withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) {\n\tparams := &pagingParams{Limit: 100}\n\tif err := ctx.MustBindWith(params, binding.Query); err == nil {\n\t\tf(params)\n\t}\n}\n\n// GetMessagesWithApplication returns all messages from a specific application.\n// swagger:operation GET /application/{id}/message message getAppMessages\n//\n// Return all messages from a specific application.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\t- name: limit\n//\t  in: query\n//\t  description: the maximal amount of messages to return\n//\t  required: false\n//\t  maximum: 200\n//\t  minimum: 1\n//\t  default: 100\n//\t  type: integer\n//\t- name: since\n//\t  in: query\n//\t  description: return all messages with an ID less than this value\n//\t  minimum: 0\n//\t  required: false\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/PagedMessages\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\twithPaging(ctx, func(params *pagingParams) {\n\t\t\tapp, err := a.DB.GetApplicationByID(id)\n\t\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\t\t// the +1 is used to check if there are more messages and will be removed on buildWithPaging\n\t\t\t\tmessages, err := a.DB.GetMessagesByApplicationSince(id, params.Limit+1, params.Since)\n\t\t\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tctx.JSON(200, buildWithPaging(ctx, params, messages))\n\t\t\t} else {\n\t\t\t\tctx.AbortWithError(404, errors.New(\"application does not exist\"))\n\t\t\t}\n\t\t})\n\t})\n}\n\n// DeleteMessages delete all messages from a user.\n// swagger:operation DELETE /message message deleteMessages\n//\n// Delete all messages.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) DeleteMessages(ctx *gin.Context) {\n\tuserID := auth.GetUserID(ctx)\n\tsuccessOrAbort(ctx, 500, a.DB.DeleteMessagesByUser(userID))\n}\n\n// DeleteMessageWithApplication deletes all messages from a specific application.\n// swagger:operation DELETE /application/{id}/message message deleteAppMessages\n//\n// Delete all messages from a specific application.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the application id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) DeleteMessageWithApplication(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tapplication, err := a.DB.GetApplicationByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif application != nil && application.UserID == auth.GetUserID(ctx) {\n\t\t\tsuccessOrAbort(ctx, 500, a.DB.DeleteMessagesByApplication(id))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, errors.New(\"application does not exists\"))\n\t\t}\n\t})\n}\n\n// DeleteMessage deletes a message with an id.\n// swagger:operation DELETE /message/{id} message deleteMessage\n//\n// Deletes a message with an id.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the message id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) DeleteMessage(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tmsg, err := a.DB.GetMessageByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif msg == nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"message does not exist\"))\n\t\t\treturn\n\t\t}\n\t\tapp, err := a.DB.GetApplicationByID(msg.ApplicationID)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif app != nil && app.UserID == auth.GetUserID(ctx) {\n\t\t\tsuccessOrAbort(ctx, 500, a.DB.DeleteMessageByID(id))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, errors.New(\"message does not exist\"))\n\t\t}\n\t})\n}\n\n// CreateMessage creates a message, authentication via application-token is required.\n// swagger:operation POST /message message createMessage\n//\n// Create a message.\n//\n// __NOTE__: This API ONLY accepts an application token as authentication.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the message to add\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/Message\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      $ref: \"#/definitions/Message\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *MessageAPI) CreateMessage(ctx *gin.Context) {\n\tmessage := model.MessageExternal{}\n\tif err := ctx.Bind(&message); err == nil {\n\t\tapplication, err := a.DB.GetApplicationByToken(auth.GetTokenID(ctx))\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tmessage.ApplicationID = application.ID\n\t\tif strings.TrimSpace(message.Title) == \"\" {\n\t\t\tmessage.Title = application.Name\n\t\t}\n\n\t\tif message.Priority == nil {\n\t\t\tmessage.Priority = &application.DefaultPriority\n\t\t}\n\n\t\tmessage.Date = timeNow()\n\t\tmessage.ID = 0\n\t\tmsgInternal := toInternalMessage(&message)\n\t\tif success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success {\n\t\t\treturn\n\t\t}\n\t\ta.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal))\n\t\tctx.JSON(200, toExternalMessage(msgInternal))\n\t}\n}\n\nfunc toInternalMessage(msg *model.MessageExternal) *model.Message {\n\tres := &model.Message{\n\t\tID:            msg.ID,\n\t\tApplicationID: msg.ApplicationID,\n\t\tMessage:       msg.Message,\n\t\tTitle:         msg.Title,\n\t\tDate:          msg.Date,\n\t}\n\tif msg.Priority != nil {\n\t\tres.Priority = *msg.Priority\n\t}\n\n\tif msg.Extras != nil {\n\t\tres.Extras, _ = json.Marshal(msg.Extras)\n\t}\n\treturn res\n}\n\nfunc toExternalMessage(msg *model.Message) *model.MessageExternal {\n\tres := &model.MessageExternal{\n\t\tID:            msg.ID,\n\t\tApplicationID: msg.ApplicationID,\n\t\tMessage:       msg.Message,\n\t\tTitle:         msg.Title,\n\t\tPriority:      &msg.Priority,\n\t\tDate:          msg.Date,\n\t}\n\tif len(msg.Extras) != 0 {\n\t\tres.Extras = make(map[string]interface{})\n\t\tjson.Unmarshal(msg.Extras, &res.Extras)\n\t}\n\treturn res\n}\n\nfunc toExternalMessages(msg []*model.Message) []*model.MessageExternal {\n\tres := make([]*model.MessageExternal, len(msg))\n\tfor i := range msg {\n\t\tres[i] = toExternalMessage(msg[i])\n\t}\n\treturn res\n}\n"
  },
  {
    "path": "api/message_test.go",
    "content": "package api\n\nimport (\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc TestMessageSuite(t *testing.T) {\n\tsuite.Run(t, new(MessageSuite))\n}\n\ntype MessageSuite struct {\n\tsuite.Suite\n\tdb              *testdb.Database\n\ta               *MessageAPI\n\tctx             *gin.Context\n\trecorder        *httptest.ResponseRecorder\n\tnotifiedMessage *model.MessageExternal\n}\n\nfunc (s *MessageSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n\ts.recorder = httptest.NewRecorder()\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/irrelevant\", nil)\n\ts.db = testdb.NewDB(s.T())\n\ts.notifiedMessage = nil\n\ts.a = &MessageAPI{DB: s.db, Notifier: s}\n}\n\nfunc (s *MessageSuite) AfterTest(string, string) {\n\ts.db.Close()\n}\n\nfunc (s *MessageSuite) Notify(userID uint, msg *model.MessageExternal) {\n\ts.notifiedMessage = msg\n}\n\nfunc (s *MessageSuite) Test_ensureCorrectJsonRepresentation() {\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\n\tactual := &model.PagedMessages{\n\t\tPaging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: \"http://example.com/message?limit=5&since=122\"},\n\t\tMessages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: \"hi\", Title: \"hi\", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{\n\t\t\t\"test::string\": \"string\",\n\t\t\t\"test::array\":  []interface{}{1, 2, 3},\n\t\t\t\"test::int\":    1,\n\t\t\t\"test::float\":  0.5,\n\t\t}}},\n\t}\n\ttest.JSONEquals(s.T(), actual, `{\"paging\": {\"limit\":5, \"since\": 122, \"size\": 5, \"next\": \"http://example.com/message?limit=5&since=122\"},\n                                              \"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}}]}`)\n}\n\nfunc (s *MessageSuite) Test_GetMessages() {\n\tuser := s.db.User(5)\n\tfirst := user.App(1).NewMessage(1)\n\tsecond := user.App(2).NewMessage(2)\n\tfirstExternal := toExternalMessage(&first)\n\tsecondExternal := toExternalMessage(&second)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.a.GetMessages(s.ctx)\n\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 100, Size: 2, Next: \"\"},\n\t\tMessages: []*model.MessageExternal{secondExternal, firstExternal},\n\t}\n\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() {\n\tuser := s.db.User(5)\n\tapp1 := user.App(1)\n\tapp2 := user.App(2)\n\tvar messages []*model.Message\n\tfor i := 100; i >= 1; i -= 2 {\n\t\tone := app2.NewMessage(uint(i))\n\t\ttwo := app1.NewMessage(uint(i - 1))\n\t\tmessages = append(messages, &one, &two)\n\t}\n\n\ts.withURL(\"http\", \"example.com\", \"/messages\", \"limit=5\")\n\ttest.WithUser(s.ctx, 5)\n\ts.a.GetMessages(s.ctx)\n\n\t// Since: entries with ids from 100 - 96 will be returned (5 entries)\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 5, Size: 5, Since: 96, Next: \"http://example.com/messages?limit=5&since=96\"},\n\t\tMessages: toExternalMessages(messages[:5]),\n\t}\n\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() {\n\tuser := s.db.User(5)\n\tapp1 := user.App(1)\n\tapp2 := user.App(2)\n\tvar messages []*model.Message\n\tfor i := 100; i >= 1; i -= 2 {\n\t\tone := app2.NewMessage(uint(i))\n\t\ttwo := app1.NewMessage(uint(i - 1))\n\t\tmessages = append(messages, &one, &two)\n\t}\n\n\ts.withURL(\"http\", \"example.com\", \"/messages\", \"limit=13&since=55\")\n\ttest.WithUser(s.ctx, 5)\n\ts.a.GetMessages(s.ctx)\n\n\t// Since: entries with ids from 54 - 42 will be returned (13 entries)\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 13, Size: 13, Since: 42, Next: \"http://example.com/messages?limit=13&since=42\"},\n\t\tMessages: toExternalMessages(messages[46 : 46+13]),\n\t}\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() {\n\ts.db.User(5)\n\ttest.WithUser(s.ctx, 5)\n\ts.withURL(\"http\", \"example.com\", \"/messages\", \"limit=555\")\n\ts.a.GetMessages(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() {\n\ts.db.User(5)\n\ttest.WithUser(s.ctx, 5)\n\ts.withURL(\"http\", \"example.com\", \"/messages\", \"limit=-5\")\n\ts.a.GetMessages(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() {\n\ts.db.User(4).App(2).NewMessage(1)\n\n\ttest.WithUser(s.ctx, 4)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.withURL(\"http\", \"example.com\", \"/messages\", \"limit=555\")\n\ts.a.GetMessagesWithApplication(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_GetMessagesWithToken() {\n\tmsg := s.db.User(4).App(2).NewMessage(1)\n\n\ttest.WithUser(s.ctx, 4)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.GetMessagesWithApplication(s.ctx)\n\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 100, Size: 1, Next: \"\"},\n\t\tMessages: toExternalMessages([]*model.Message{&msg}),\n\t}\n\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext() {\n\tuser := s.db.User(5)\n\tapp1 := user.App(2)\n\tvar messages []*model.Message\n\tfor i := 100; i >= 1; i-- {\n\t\tmsg := app1.NewMessage(uint(i))\n\t\tmessages = append(messages, &msg)\n\t}\n\n\ts.withURL(\"http\", \"example.com\", \"/app/2/message\", \"limit=9\")\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.GetMessagesWithApplication(s.ctx)\n\n\t// Since: entries with ids from 100 - 92 will be returned (9 entries)\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 9, Size: 9, Since: 92, Next: \"http://example.com/app/2/message?limit=9&since=92\"},\n\t\tMessages: toExternalMessages(messages[:9]),\n\t}\n\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext() {\n\tuser := s.db.User(5)\n\tapp1 := user.App(2)\n\tvar messages []*model.Message\n\tfor i := 100; i >= 1; i-- {\n\t\tmsg := app1.NewMessage(uint(i))\n\t\tmessages = append(messages, &msg)\n\t}\n\n\ts.withURL(\"http\", \"example.com\", \"/app/2/message\", \"limit=13&since=55\")\n\ttest.WithUser(s.ctx, 5)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.GetMessagesWithApplication(s.ctx)\n\n\t// Since: entries with ids from 54 - 42 will be returned (13 entries)\n\texpected := &model.PagedMessages{\n\t\tPaging:   model.Paging{Limit: 13, Size: 13, Since: 42, Next: \"http://example.com/app/2/message?limit=13&since=42\"},\n\t\tMessages: toExternalMessages(messages[46 : 46+13]),\n\t}\n\ttest.BodyEquals(s.T(), expected, s.recorder)\n}\n\nfunc (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectNotFound() {\n\ts.db.User(4)\n\ts.db.User(5).App(2).Message(66)\n\n\ttest.WithUser(s.ctx, 4)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\ts.a.GetMessagesWithApplication(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessage_invalidID() {\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"string\"}}\n\n\ts.a.DeleteMessage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessage_notExistingID() {\n\ts.db.User(1).App(5).Message(55)\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\ts.a.DeleteMessage(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessage_existingIDButNotOwner() {\n\ts.db.User(1).App(10).Message(100)\n\ts.db.User(2)\n\n\ttest.WithUser(s.ctx, 2)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"100\"}}\n\ts.a.DeleteMessage(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessage() {\n\ts.db.User(6).App(1).Message(50)\n\n\ttest.WithUser(s.ctx, 6)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"50\"}}\n\ts.a.DeleteMessage(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertMessageNotExist(50)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessageWithID() {\n\ts.db.User(2).AppWithToken(5, \"mytoken\").Message(55)\n\n\ttest.WithUser(s.ctx, 2)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"5\"}}\n\ts.a.DeleteMessageWithApplication(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertMessageNotExist(55)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessageWithToken_notExistingID() {\n\ts.db.User(2).AppWithToken(1, \"wrong\").Message(1)\n\n\ttest.WithUser(s.ctx, 2)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"55\"}}\n\ts.a.DeleteMessageWithApplication(s.ctx)\n\n\ts.db.AssertMessageExist(1)\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessageWithToken_notOwner() {\n\ts.db.User(4)\n\ts.db.User(2).App(55).Message(5)\n\n\ttest.WithUser(s.ctx, 4)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"55\"}}\n\ts.a.DeleteMessageWithApplication(s.ctx)\n\n\ts.db.AssertMessageExist(5)\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_DeleteMessages() {\n\tuserBuilder := s.db.User(4)\n\tuserBuilder.App(5).Message(5).Message(6)\n\tuserBuilder.App(2).Message(7).Message(8)\n\ts.db.User(5).App(7).Message(22)\n\n\ttest.WithUser(s.ctx, 4)\n\ts.a.DeleteMessages(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertMessageExist(22)\n\ts.db.AssertMessageNotExist(5, 6, 7, 8)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_onJson_allParams() {\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(7, \"app-token\")\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"title\": \"mytitle\", \"message\": \"mymessage\", \"priority\": 1}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(7)\n\tassert.NoError(s.T(), err)\n\texpected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: \"mytitle\", Message: \"mymessage\", Priority: intPtr(1), Date: t}\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), expected, s.notifiedMessage)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() {\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithTokenAndDefaultPriority(8, \"app-token\", 5)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"title\": \"mytitle\", \"message\": \"mymessage\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(8)\n\tassert.NoError(s.T(), err)\n\texpected := &model.MessageExternal{ID: 1, ApplicationID: 8, Title: \"mytitle\", Message: \"mymessage\", Priority: intPtr(5), Date: t}\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), expected, s.notifiedMessage)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_WithTitle() {\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(5, \"app-token\")\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"title\": \"mytitle\", \"message\": \"mymessage\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(5)\n\tassert.NoError(s.T(), err)\n\texpected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: \"mytitle\", Message: \"mymessage\", Date: t, Priority: intPtr(0)}\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), expected, s.notifiedMessage)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(1, \"app-token\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"title\": \"mytitle\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tif msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), msgs)\n\t}\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tassert.Nil(s.T(), s.notifiedMessage)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_WithoutTitle() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithTokenAndName(8, \"app-token\", \"Application name\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"message\": \"mymessage\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(8)\n\tassert.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), \"Application name\", msgs[0].Title)\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), \"mymessage\", s.notifiedMessage.Message)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_WithBlankTitle() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithTokenAndName(8, \"app-token\", \"Application name\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"message\": \"mymessage\", \"title\": \"  \"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(8)\n\tassert.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), \"Application name\", msgs[0].Title)\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), \"mymessage\", msgs[0].Message)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_IgnoreID() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithTokenAndName(8, \"app-token\", \"Application name\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"message\": \"mymessage\", \"id\": 1337}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(8)\n\tassert.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassert.NotEqual(s.T(), msgs[0].ID, uint(1337))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_WithExtras() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithTokenAndName(8, \"app-token\", \"Application name\")\n\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\ts.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]}}}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tmsgs, err := s.db.GetMessagesByApplication(8)\n\tassert.NoError(s.T(), err)\n\texpected := &model.MessageExternal{\n\t\tID:            1,\n\t\tApplicationID: 8,\n\t\tMessage:       \"mymessage\",\n\t\tTitle:         \"msg with extras\",\n\t\tDate:          t,\n\t\tPriority:      intPtr(0),\n\t\tExtras: map[string]interface{}{\n\t\t\t\"gotify::test\": map[string]interface{}{\n\t\t\t\t\"string\": \"test\",\n\t\t\t\t\"array\":  []interface{}{float64(1), float64(2), float64(3)},\n\t\t\t\t\"int\":    float64(1),\n\t\t\t\t\"float\":  float64(0.5),\n\t\t\t},\n\t\t},\n\t}\n\tassert.Len(s.T(), msgs, 1)\n\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), uint(1), s.notifiedMessage.ID)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(8, \"app-token\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`{\"title\": \"mytitle\", \"message\": \"mymessage\", \"priority\": \"asd\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tassert.Nil(s.T(), s.notifiedMessage)\n\tif msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), msgs)\n\t}\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_onQueryData() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(2, \"app-token\")\n\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message?title=mytitle&message=mymessage&priority=1\", nil)\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\texpected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: \"mytitle\", Message: \"mymessage\", Priority: intPtr(1), Date: t}\n\n\tmsgs, err := s.db.GetMessagesByApplication(2)\n\tassert.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), uint(1), s.notifiedMessage.ID)\n}\n\nfunc (s *MessageSuite) Test_CreateMessage_onFormData() {\n\tauth.RegisterAuthentication(s.ctx, nil, 4, \"app-token\")\n\ts.db.User(4).AppWithToken(99, \"app-token\")\n\n\tt, _ := time.Parse(\"2006/01/02\", \"2017/01/02\")\n\ttimeNow = func() time.Time { return t }\n\tdefer func() { timeNow = time.Now }()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/message\", strings.NewReader(`title=mytitle&message=mymessage&priority=1`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\ts.a.CreateMessage(s.ctx)\n\n\texpected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: \"mytitle\", Message: \"mymessage\", Priority: intPtr(1), Date: t}\n\tmsgs, err := s.db.GetMessagesByApplication(99)\n\tassert.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassert.Equal(s.T(), expected, toExternalMessage(msgs[0]))\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tassert.Equal(s.T(), uint(1), s.notifiedMessage.ID)\n}\n\nfunc (s *MessageSuite) withURL(scheme, host, path, query string) {\n\ts.ctx.Request.URL = &url.URL{Path: path, RawQuery: query}\n\ts.ctx.Set(\"location\", &url.URL{Scheme: scheme, Host: host})\n}\n\nfunc intPtr(x int) *int {\n\treturn &x\n}\n"
  },
  {
    "path": "api/plugin.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/location\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin\"\n\t\"github.com/gotify/server/v2/plugin/compat\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// The PluginDatabase interface for encapsulating database access.\ntype PluginDatabase interface {\n\tGetPluginConfByUser(userid uint) ([]*model.PluginConf, error)\n\tUpdatePluginConf(p *model.PluginConf) error\n\tGetPluginConfByID(id uint) (*model.PluginConf, error)\n}\n\n// The PluginAPI provides handlers for managing plugins.\ntype PluginAPI struct {\n\tNotifier Notifier\n\tManager  *plugin.Manager\n\tDB       PluginDatabase\n}\n\n// GetPlugins returns all plugins a user has.\n// swagger:operation GET /plugin plugin getPlugins\n//\n// Return all plugins.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      type: array\n//\t      items:\n//\t        $ref: \"#/definitions/PluginConf\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) GetPlugins(ctx *gin.Context) {\n\tuserID := auth.GetUserID(ctx)\n\tplugins, err := c.DB.GetPluginConfByUser(userID)\n\tif success := successOrAbort(ctx, 500, err); !success {\n\t\treturn\n\t}\n\tresult := make([]model.PluginConfExternal, 0)\n\tfor _, conf := range plugins {\n\t\tif inst, err := c.Manager.Instance(conf.ID); err == nil {\n\t\t\tinfo := c.Manager.PluginInfo(conf.ModulePath)\n\t\t\tresult = append(result, model.PluginConfExternal{\n\t\t\t\tID:           conf.ID,\n\t\t\t\tName:         info.String(),\n\t\t\t\tToken:        conf.Token,\n\t\t\t\tModulePath:   conf.ModulePath,\n\t\t\t\tAuthor:       info.Author,\n\t\t\t\tWebsite:      info.Website,\n\t\t\t\tLicense:      info.License,\n\t\t\t\tEnabled:      conf.Enabled,\n\t\t\t\tCapabilities: inst.Supports().Strings(),\n\t\t\t})\n\t\t}\n\t}\n\tctx.JSON(200, result)\n}\n\n// EnablePlugin enables a plugin.\n// swagger:operation POST /plugin/{id}/enable plugin enablePlugin\n//\n// Enable a plugin.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the plugin id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) EnablePlugin(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tconf, err := c.DB.GetPluginConfByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !isPluginOwner(ctx, conf) {\n\t\t\tctx.AbortWithError(404, errors.New(\"unknown plugin\"))\n\t\t\treturn\n\t\t}\n\t\t_, err = c.Manager.Instance(id)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"plugin instance not found\"))\n\t\t\treturn\n\t\t}\n\t\tif err := c.Manager.SetPluginEnabled(id, true); err == plugin.ErrAlreadyEnabledOrDisabled {\n\t\t\tctx.AbortWithError(400, err)\n\t\t} else if err != nil {\n\t\t\tctx.AbortWithError(500, err)\n\t\t}\n\t})\n}\n\n// DisablePlugin disables a plugin.\n// swagger:operation POST /plugin/{id}/disable plugin disablePlugin\n//\n// Disable a plugin.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the plugin id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) DisablePlugin(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tconf, err := c.DB.GetPluginConfByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !isPluginOwner(ctx, conf) {\n\t\t\tctx.AbortWithError(404, errors.New(\"unknown plugin\"))\n\t\t\treturn\n\t\t}\n\t\t_, err = c.Manager.Instance(id)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"plugin instance not found\"))\n\t\t\treturn\n\t\t}\n\t\tif err := c.Manager.SetPluginEnabled(id, false); err == plugin.ErrAlreadyEnabledOrDisabled {\n\t\t\tctx.AbortWithError(400, err)\n\t\t} else if err != nil {\n\t\t\tctx.AbortWithError(500, err)\n\t\t}\n\t})\n}\n\n// GetDisplay get display info for Displayer plugin.\n// swagger:operation GET /plugin/{id}/display plugin getPluginDisplay\n//\n// Get display info for a Displayer plugin.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the plugin id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      type: string\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) GetDisplay(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tconf, err := c.DB.GetPluginConfByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !isPluginOwner(ctx, conf) {\n\t\t\tctx.AbortWithError(404, errors.New(\"unknown plugin\"))\n\t\t\treturn\n\t\t}\n\t\tinstance, err := c.Manager.Instance(id)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"plugin instance not found\"))\n\t\t\treturn\n\t\t}\n\t\tctx.JSON(200, instance.GetDisplay(location.Get(ctx)))\n\t})\n}\n\n// GetConfig returns Configurer plugin configuration in YAML format.\n// swagger:operation GET /plugin/{id}/config plugin getPluginConfig\n//\n// Get YAML configuration for Configurer plugin.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/x-yaml]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the plugin id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        type: object\n//\t        description: plugin configuration\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) GetConfig(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tconf, err := c.DB.GetPluginConfByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !isPluginOwner(ctx, conf) {\n\t\t\tctx.AbortWithError(404, errors.New(\"unknown plugin\"))\n\t\t\treturn\n\t\t}\n\t\tinstance, err := c.Manager.Instance(id)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"plugin instance not found\"))\n\t\t\treturn\n\t\t}\n\n\t\tif aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {\n\t\t\treturn\n\t\t}\n\n\t\tctx.Header(\"content-type\", \"application/x-yaml\")\n\t\tctx.Writer.Write(conf.Config)\n\t})\n}\n\n// UpdateConfig updates Configurer plugin configuration in YAML format.\n// swagger:operation POST /plugin/{id}/config plugin updatePluginConfig\n//\n// Update YAML configuration for Configurer plugin.\n//\n//\t---\n//\tconsumes: [application/x-yaml]\n//\tproduces: [application/json]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the plugin id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Internal Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (c *PluginAPI) UpdateConfig(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tconf, err := c.DB.GetPluginConfByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !isPluginOwner(ctx, conf) {\n\t\t\tctx.AbortWithError(404, errors.New(\"unknown plugin\"))\n\t\t\treturn\n\t\t}\n\t\tinstance, err := c.Manager.Instance(id)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(404, errors.New(\"plugin instance not found\"))\n\t\t\treturn\n\t\t}\n\n\t\tif aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {\n\t\t\treturn\n\t\t}\n\n\t\tnewConf := instance.DefaultConfig()\n\t\tnewconfBytes, err := io.ReadAll(ctx.Request.Body)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(500, err)\n\t\t\treturn\n\t\t}\n\t\tif err := yaml.Unmarshal(newconfBytes, newConf); err != nil {\n\t\t\tctx.AbortWithError(400, err)\n\t\t\treturn\n\t\t}\n\t\tif err := instance.ValidateAndSetConfig(newConf); err != nil {\n\t\t\tctx.AbortWithError(400, err)\n\t\t\treturn\n\t\t}\n\t\tconf.Config = newconfBytes\n\t\tsuccessOrAbort(ctx, 500, c.DB.UpdatePluginConf(conf))\n\t})\n}\n\nfunc isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool {\n\treturn conf.UserID == auth.GetUserID(ctx)\n}\n\nfunc supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, module compat.Capability) (aborted bool) {\n\tif compat.HasSupport(instance, module) {\n\t\treturn false\n\t}\n\tctx.AbortWithError(400, fmt.Errorf(\"plugin does not support %s\", module))\n\treturn true\n}\n"
  },
  {
    "path": "api/plugin_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin\"\n\t\"github.com/gotify/server/v2/plugin/compat\"\n\t\"github.com/gotify/server/v2/plugin/testing/mock\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestPluginSuite(t *testing.T) {\n\tsuite.Run(t, new(PluginSuite))\n}\n\ntype PluginSuite struct {\n\tsuite.Suite\n\tdb       *testdb.Database\n\ta        *PluginAPI\n\tctx      *gin.Context\n\trecorder *httptest.ResponseRecorder\n\tmanager  *plugin.Manager\n\tnotified bool\n}\n\nfunc (s *PluginSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n\ts.db = testdb.NewDB(s.T())\n\ts.resetRecorder()\n\tmanager, err := plugin.NewManager(s.db, \"\", nil, s)\n\tassert.Nil(s.T(), err)\n\ts.manager = manager\n\twithURL(s.ctx, \"http\", \"example.com\")\n\ts.a = &PluginAPI{DB: s.db, Manager: manager, Notifier: s}\n\n\tmockPluginCompat := new(mock.Plugin)\n\tassert.Nil(s.T(), s.manager.LoadPlugin(mockPluginCompat))\n\n\ts.db.User(1)\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(1))\n\ts.db.User(2)\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(2))\n\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tUserID:     1,\n\t\tModulePath: \"github.com/gotify/server/v2/plugin/example/removed\",\n\t\tToken:      \"P1234\",\n\t\tEnabled:    false,\n\t})\n}\n\nfunc (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf {\n\tconf, err := s.db.GetPluginConfByUserAndPath(uid, \"github.com/gotify/server/v2/plugin/example/removed\")\n\tassert.NoError(s.T(), err)\n\treturn conf\n}\n\nfunc (s *PluginSuite) resetRecorder() {\n\ts.recorder = httptest.NewRecorder()\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n}\n\nfunc (s *PluginSuite) AfterTest(suiteName, testName string) {\n\ts.db.Close()\n}\n\nfunc (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) {\n\ts.notified = true\n}\n\nfunc (s *PluginSuite) Test_GetPlugins() {\n\ttest.WithUser(s.ctx, 1)\n\n\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/plugin\", nil)\n\ts.a.GetPlugins(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\n\tpluginConfs := make([]model.PluginConfExternal, 0)\n\tassert.Nil(s.T(), json.Unmarshal(s.recorder.Body.Bytes(), &pluginConfs))\n\n\tassert.Equal(s.T(), mock.Name, pluginConfs[0].Name)\n\tassert.Equal(s.T(), mock.ModulePath, pluginConfs[0].ModulePath)\n\n\tassert.False(s.T(), pluginConfs[0].Enabled, \"Plugins should be disabled by default\")\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin() {\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/enable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.True(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/enable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.True(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/disable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/disable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expect500() {\n\ts.db.User(16)\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(16))\n\tmock.ReturnErrorOnEnableForUser(16, errors.New(\"test error\"))\n\tconf, err := s.db.GetPluginConfByUserAndPath(16, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 16)\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/enable\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 500, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_expect500() {\n\ts.db.User(17)\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(17))\n\tmock.ReturnErrorOnDisableForUser(17, errors.New(\"test error\"))\n\tconf, err := s.db.GetPluginConfByUserAndPath(17, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\ts.manager.SetPluginEnabled(conf.ID, true)\n\n\t{\n\t\ttest.WithUser(s.ctx, 17)\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/disable\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 500, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNotFound() {\n\t{\n\t\ttest.WithUser(s.ctx, 2)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/enable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 2)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/1/disable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"1\"}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\n\t\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {\n\t\t\tassert.False(s.T(), pluginConf.Enabled)\n\t\t}\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNotFound() {\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/99/enable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"99\"}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/99/disable\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"99\"}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotFound() {\n\tconf := s.getDanglingConf(1)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/enable\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.EnablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t\ts.resetRecorder()\n\t}\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/disable\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.DisablePlugin(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t\ts.resetRecorder()\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetDisplay() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tmockInst.DisplayString = \"test string\"\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/display\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetDisplay(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\t\ttest.JSONEquals(s.T(), mockInst.DisplayString, s.recorder.Body.String())\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tmockInst.SetCapability(compat.Displayer, false)\n\tdefer mockInst.SetCapability(compat.Displayer, true)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/display\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetDisplay(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\t\ttest.JSONEquals(s.T(), \"\", s.recorder.Body.String())\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tmockInst.DisplayString = \"test string\"\n\n\t{\n\t\ttest.WithUser(s.ctx, 2)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/display\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetDisplay(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() {\n\tconf := s.getDanglingConf(1)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/display\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetDisplay(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() {\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/plugin/99/display\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"99\"}}\n\t\ts.a.GetDisplay(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetConfig() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tassert.Equal(s.T(), mockInst.DefaultConfig(), mockInst.Config, \"Initial config should be default config\")\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\t\treturnedConfig := new(mock.PluginConfig)\n\t\tassert.Nil(s.T(), yaml.Unmarshal(s.recorder.Body.Bytes(), returnedConfig))\n\t\tassert.Equal(s.T(), mockInst.Config, returnedConfig)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tmockInst.SetCapability(compat.Configurer, false)\n\tdefer mockInst.SetCapability(compat.Configurer, true)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 2)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() {\n\tconf := s.getDanglingConf(1)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.GetConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() {\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"GET\", \"/plugin/99/config\", nil)\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"99\"}}\n\t\ts.a.GetConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey: \"test__new__config\",\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 200, s.recorder.Code)\n\t\tassert.Equal(s.T(), newConfig, mockInst.Config, \"config should be received by plugin\")\n\n\t\tvar pluginFromDBBytes []byte\n\t\tif pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {\n\t\t\tpluginFromDBBytes = pluginConf.Config\n\t\t}\n\t\tpluginFromDB := new(mock.PluginConfig)\n\t\terr := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)\n\t\tassert.Nil(s.T(), err)\n\t\tassert.Equal(s.T(), newConfig, pluginFromDB, \"config should be updated in database\")\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\torigConfig := mockInst.Config\n\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey:    \"test__new__config__invalid\",\n\t\tIsNotValid: true,\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\t\tassert.Equal(s.T(), origConfig, mockInst.Config, \"config should not be received by plugin\")\n\n\t\tvar pluginFromDBBytes []byte\n\t\tif pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {\n\t\t\tpluginFromDBBytes = pluginConf.Config\n\t\t}\n\t\tpluginFromDB := new(mock.PluginConfig)\n\t\terr := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)\n\t\tassert.Nil(s.T(), err)\n\t\tassert.Equal(s.T(), origConfig, pluginFromDB, \"config should not be updated in database\")\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\torigConfig := mockInst.Config\n\n\tnewConfigYAML := []byte(`--- \"rg e\"\"`)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\t\tassert.Equal(s.T(), origConfig, mockInst.Config, \"config should not be received by plugin\")\n\n\t\tvar pluginFromDBBytes []byte\n\t\tif pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {\n\t\t\tpluginFromDBBytes = pluginConf.Config\n\t\t}\n\t\tpluginFromDB := new(mock.PluginConfig)\n\t\terr := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)\n\t\tassert.Nil(s.T(), err)\n\t\tassert.Equal(s.T(), origConfig, pluginFromDB, \"config should not be updated in database\")\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_ioError_expect500() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\torigConfig := mockInst.Config\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), test.UnreadableReader())\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 500, s.recorder.Code)\n\t\tassert.Equal(s.T(), origConfig, mockInst.Config, \"config should not be received by plugin\")\n\n\t\tvar pluginFromDBBytes []byte\n\t\tif pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {\n\t\t\tpluginFromDBBytes = pluginConf.Config\n\t\t}\n\t\tpluginFromDB := new(mock.PluginConfig)\n\t\terr := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)\n\t\tassert.Nil(s.T(), err)\n\t\tassert.Equal(s.T(), origConfig, pluginFromDB, \"config should not be updated in database\")\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey: \"test__new__config\",\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\tmockInst.SetCapability(compat.Configurer, false)\n\tdefer mockInst.SetCapability(compat.Configurer, true)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 400, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() {\n\tconf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)\n\tassert.NoError(s.T(), err)\n\tinst, err := s.manager.Instance(conf.ID)\n\tassert.Nil(s.T(), err)\n\tmockInst := inst.(*mock.PluginInstance)\n\torigConfig := mockInst.Config\n\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey: \"test__new__config\",\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 2)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t\tassert.Equal(s.T(), origConfig, mockInst.Config, \"config should not be received by plugin\")\n\n\t\tvar pluginFromDBBytes []byte\n\t\tif pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {\n\t\t\tpluginFromDBBytes = pluginConf.Config\n\t\t}\n\t\tpluginFromDB := new(mock.PluginConfig)\n\t\terr := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)\n\t\tassert.Nil(s.T(), err)\n\t\tassert.Equal(s.T(), origConfig, pluginFromDB, \"config should not be updated in database\")\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() {\n\tconf := s.getDanglingConf(1)\n\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey: \"test__new__config\",\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", fmt.Sprintf(\"/plugin/%d/config\", conf.ID), bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: fmt.Sprint(conf.ID)}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n\nfunc (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() {\n\tnewConfig := &mock.PluginConfig{\n\t\tTestKey: \"test__new__config\",\n\t}\n\tnewConfigYAML, err := yaml.Marshal(newConfig)\n\tassert.Nil(s.T(), err)\n\n\t{\n\t\ttest.WithUser(s.ctx, 1)\n\n\t\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/plugin/99/config\", bytes.NewReader(newConfigYAML))\n\t\ts.ctx.Header(\"Content-Type\", \"application/x-yaml\")\n\t\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"99\"}}\n\t\ts.a.UpdateConfig(s.ctx)\n\n\t\tassert.Equal(s.T(), 404, s.recorder.Code)\n\t}\n}\n"
  },
  {
    "path": "api/stream/client.go",
    "content": "package stream\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\nconst (\n\twriteWait = 2 * time.Second\n)\n\nvar ping = func(conn *websocket.Conn) error {\n\treturn conn.WriteMessage(websocket.PingMessage, nil)\n}\n\nvar writeJSON = func(conn *websocket.Conn, v interface{}) error {\n\treturn conn.WriteJSON(v)\n}\n\ntype client struct {\n\tconn    *websocket.Conn\n\tonClose func(*client)\n\twrite   chan *model.MessageExternal\n\tuserID  uint\n\ttoken   string\n\tonce    once\n}\n\nfunc newClient(conn *websocket.Conn, userID uint, token string, onClose func(*client)) *client {\n\treturn &client{\n\t\tconn:    conn,\n\t\twrite:   make(chan *model.MessageExternal, 1),\n\t\tuserID:  userID,\n\t\ttoken:   token,\n\t\tonClose: onClose,\n\t}\n}\n\n// Close closes the connection.\nfunc (c *client) Close() {\n\tc.once.Do(func() {\n\t\tc.conn.Close()\n\t\tclose(c.write)\n\t})\n}\n\n// NotifyClose closes the connection and notifies that the connection was closed.\nfunc (c *client) NotifyClose() {\n\tc.once.Do(func() {\n\t\tc.conn.Close()\n\t\tclose(c.write)\n\t\tc.onClose(c)\n\t})\n}\n\n// startWriteHandler starts listening on the client connection. As we do not need anything from the client,\n// we ignore incoming messages. Leaves the loop on errors.\nfunc (c *client) startReading(pongWait time.Duration) {\n\tdefer c.NotifyClose()\n\tc.conn.SetReadLimit(64)\n\tc.conn.SetReadDeadline(time.Now().Add(pongWait))\n\tc.conn.SetPongHandler(func(appData string) error {\n\t\tc.conn.SetReadDeadline(time.Now().Add(pongWait))\n\t\treturn nil\n\t})\n\tfor {\n\t\tif _, _, err := c.conn.NextReader(); err != nil {\n\t\t\tprintWebSocketError(\"ReadError\", err)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// startWriteHandler starts the write loop. The method has the following tasks:\n// * ping the client in the interval provided as parameter\n// * write messages send by the channel to the client\n// * on errors exit the loop.\nfunc (c *client) startWriteHandler(pingPeriod time.Duration) {\n\tpingTicker := time.NewTicker(pingPeriod)\n\tdefer func() {\n\t\tc.NotifyClose()\n\t\tpingTicker.Stop()\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase message, ok := <-c.write:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tc.conn.SetWriteDeadline(time.Now().Add(writeWait))\n\t\t\tif err := writeJSON(c.conn, message); err != nil {\n\t\t\t\tprintWebSocketError(\"WriteError\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-pingTicker.C:\n\t\t\tc.conn.SetWriteDeadline(time.Now().Add(writeWait))\n\t\t\tif err := ping(c.conn); err != nil {\n\t\t\t\tprintWebSocketError(\"PingError\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc printWebSocketError(prefix string, err error) {\n\tcloseError, ok := err.(*websocket.CloseError)\n\n\tif ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) {\n\t\t// normal closure\n\t\treturn\n\t}\n\n\tfmt.Println(\"WebSocket:\", prefix, err)\n}\n"
  },
  {
    "path": "api/stream/once.go",
    "content": "// Copyright 2009 The Go Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\npackage stream\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go)\n// This version unlocks the mutex early and therefore doesn't hold the lock while executing func f().\ntype once struct {\n\tm    sync.Mutex\n\tdone uint32\n}\n\nfunc (o *once) Do(f func()) {\n\tif atomic.LoadUint32(&o.done) == 1 {\n\t\treturn\n\t}\n\tif o.mayExecute() {\n\t\tf()\n\t}\n}\n\nfunc (o *once) mayExecute() bool {\n\to.m.Lock()\n\tdefer o.m.Unlock()\n\tif o.done == 0 {\n\t\tatomic.StoreUint32(&o.done, 1)\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "api/stream/once_test.go",
    "content": "package stream\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_Execute(t *testing.T) {\n\texecuteOnce := once{}\n\texecution := make(chan struct{})\n\tfExecute := func() {\n\t\texecution <- struct{}{}\n\t}\n\tgo executeOnce.Do(fExecute)\n\tgo executeOnce.Do(fExecute)\n\n\tselect {\n\tcase <-execution:\n\t\t// expected\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"fExecute should be executed once\")\n\t}\n\n\tselect {\n\tcase <-execution:\n\t\tt.Fatal(\"should only execute once\")\n\tcase <-time.After(100 * time.Millisecond):\n\t\t// expected\n\t}\n\n\tassert.False(t, executeOnce.mayExecute())\n\n\tgo executeOnce.Do(fExecute)\n\n\tselect {\n\tcase <-execution:\n\t\tt.Fatal(\"should only execute once\")\n\tcase <-time.After(100 * time.Millisecond):\n\t\t// expected\n\t}\n}\n"
  },
  {
    "path": "api/stream/stream.go",
    "content": "package stream\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The API provides a handler for a WebSocket stream API.\ntype API struct {\n\tclients     map[uint][]*client\n\tlock        sync.RWMutex\n\tpingPeriod  time.Duration\n\tpongTimeout time.Duration\n\tupgrader    *websocket.Upgrader\n}\n\n// New creates a new instance of API.\n// pingPeriod: is the interval, in which is server sends the a ping to the client.\n// pongTimeout: is the duration after the connection will be terminated, when the client does not respond with the\n// pong command.\nfunc New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string) *API {\n\treturn &API{\n\t\tclients:     make(map[uint][]*client),\n\t\tpingPeriod:  pingPeriod,\n\t\tpongTimeout: pingPeriod + pongTimeout,\n\t\tupgrader:    newUpgrader(allowedWebSocketOrigins),\n\t}\n}\n\n// CollectConnectedClientTokens returns all tokens of the connected clients.\nfunc (a *API) CollectConnectedClientTokens() []string {\n\ta.lock.RLock()\n\tdefer a.lock.RUnlock()\n\tvar clients []string\n\tfor _, cs := range a.clients {\n\t\tfor _, c := range cs {\n\t\t\tclients = append(clients, c.token)\n\t\t}\n\t}\n\treturn uniq(clients)\n}\n\n// NotifyDeletedUser closes existing connections for the given user.\nfunc (a *API) NotifyDeletedUser(userID uint) error {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\tif clients, ok := a.clients[userID]; ok {\n\t\tfor _, client := range clients {\n\t\t\tclient.Close()\n\t\t}\n\t\tdelete(a.clients, userID)\n\t}\n\treturn nil\n}\n\n// NotifyDeletedClient closes existing connections with the given token.\nfunc (a *API) NotifyDeletedClient(userID uint, token string) {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\tif clients, ok := a.clients[userID]; ok {\n\t\tfor i := len(clients) - 1; i >= 0; i-- {\n\t\t\tclient := clients[i]\n\t\t\tif client.token == token {\n\t\t\t\tclient.Close()\n\t\t\t\tclients = append(clients[:i], clients[i+1:]...)\n\t\t\t}\n\t\t}\n\t\ta.clients[userID] = clients\n\t}\n}\n\n// Notify notifies the clients with the given userID that a new messages was created.\nfunc (a *API) Notify(userID uint, msg *model.MessageExternal) {\n\ta.lock.RLock()\n\tdefer a.lock.RUnlock()\n\tif clients, ok := a.clients[userID]; ok {\n\t\tfor _, c := range clients {\n\t\t\tc.write <- msg\n\t\t}\n\t}\n}\n\nfunc (a *API) remove(remove *client) {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\tif userIDClients, ok := a.clients[remove.userID]; ok {\n\t\tfor i, client := range userIDClients {\n\t\t\tif client == remove {\n\t\t\t\ta.clients[remove.userID] = append(userIDClients[:i], userIDClients[i+1:]...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (a *API) register(client *client) {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\ta.clients[client.userID] = append(a.clients[client.userID], client)\n}\n\n// Handle handles incoming requests. First it upgrades the protocol to the WebSocket protocol and then starts listening\n// for read and writes.\n// swagger:operation GET /stream message streamMessages\n//\n// Websocket, return newly created messages.\n//\n//\t---\n//\tschema: ws, wss\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/Message\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  500:\n//\t    description: Server Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *API) Handle(ctx *gin.Context) {\n\tconn, err := a.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)\n\tif err != nil {\n\t\tctx.Error(err)\n\t\treturn\n\t}\n\n\tclient := newClient(conn, auth.GetUserID(ctx), auth.GetTokenID(ctx), a.remove)\n\ta.register(client)\n\tgo client.startReading(a.pongTimeout)\n\tgo client.startWriteHandler(a.pingPeriod)\n}\n\n// Close closes all client connections and stops answering new connections.\nfunc (a *API) Close() {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\n\tfor _, clients := range a.clients {\n\t\tfor _, client := range clients {\n\t\t\tclient.Close()\n\t\t}\n\t}\n\tfor k := range a.clients {\n\t\tdelete(a.clients, k)\n\t}\n}\n\nfunc uniq[T comparable](s []T) []T {\n\tm := make(map[T]struct{}, len(s))\n\tr := make([]T, 0, len(s))\n\tfor _, v := range s {\n\t\tif _, ok := m[v]; !ok {\n\t\t\tm[v] = struct{}{}\n\t\t\tr = append(r, v)\n\t\t}\n\t}\n\treturn r\n}\n\nfunc isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {\n\torigin := r.Header.Get(\"origin\")\n\tif origin == \"\" {\n\t\treturn true\n\t}\n\n\tu, err := url.Parse(origin)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tif strings.EqualFold(u.Host, r.Host) {\n\t\treturn true\n\t}\n\n\tfor _, allowedOrigin := range allowedOrigins {\n\t\tif allowedOrigin.MatchString(strings.ToLower(u.Hostname())) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {\n\tcompiledAllowedOrigins := compileAllowedWebSocketOrigins(allowedWebSocketOrigins)\n\treturn &websocket.Upgrader{\n\t\tReadBufferSize:  1024,\n\t\tWriteBufferSize: 1024,\n\t\tCheckOrigin: func(r *http.Request) bool {\n\t\t\tif mode.IsDev() {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn isAllowedOrigin(r, compiledAllowedOrigins)\n\t\t},\n\t}\n}\n\nfunc compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.Regexp {\n\tvar compiledAllowedOrigins []*regexp.Regexp\n\tfor _, origin := range allowedOrigins {\n\t\tcompiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin))\n\t}\n\n\treturn compiledAllowedOrigins\n}\n"
  },
  {
    "path": "api/stream/stream_test.go",
    "content": "package stream\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/fortytw2/leaktest\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFailureOnNormalHttpRequest(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tdefer leaktest.Check(t)()\n\n\tserver, api := bootTestServer(staticUserID())\n\tdefer server.Close()\n\tdefer api.Close()\n\n\tresp, err := http.Get(server.URL)\n\tassert.Nil(t, err)\n\tassert.Equal(t, 400, resp.StatusCode)\n\tresp.Body.Close()\n}\n\nfunc TestWriteMessageFails(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\toldWrite := writeJSON\n\t// try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know.\n\twriteJSON = func(conn *websocket.Conn, v interface{}) error {\n\t\treturn errors.New(\"asd\")\n\t}\n\tdefer func() {\n\t\twriteJSON = oldWrite\n\t}()\n\tdefer leaktest.Check(t)()\n\n\tserver, api := bootTestServer(func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, 1, \"\")\n\t})\n\tdefer server.Close()\n\tdefer api.Close()\n\n\twsURL := wsURL(server.URL)\n\tuser := testClient(t, wsURL)\n\n\twaitForConnectedClients(api, 1)\n\n\tclients := clients(api, 1)\n\tassert.NotEmpty(t, clients)\n\n\tapi.Notify(1, &model.MessageExternal{Message: \"HI\"})\n\tuser.expectNoMessage()\n}\n\nfunc TestWritePingFails(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\toldPing := ping\n\t// try emulate an write error, mostly this should kill the ReadMessage gorouting first but you'll never know.\n\tping = func(conn *websocket.Conn) error {\n\t\treturn errors.New(\"asd\")\n\t}\n\tdefer func() {\n\t\tping = oldPing\n\t}()\n\n\tdefer leaktest.CheckTimeout(t, 10*time.Second)()\n\n\tserver, api := bootTestServer(staticUserID())\n\tdefer api.Close()\n\tdefer server.Close()\n\n\twsURL := wsURL(server.URL)\n\tuser := testClient(t, wsURL)\n\tdefer user.conn.Close()\n\n\twaitForConnectedClients(api, 1)\n\n\tclients := clients(api, 1)\n\n\tassert.NotEmpty(t, clients)\n\n\ttime.Sleep(api.pingPeriod + (50 * time.Millisecond)) // waiting for ping\n\n\tapi.Notify(1, &model.MessageExternal{Message: \"HI\"})\n\tuser.expectNoMessage()\n}\n\nfunc TestPing(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tserver, api := bootTestServer(staticUserID())\n\tdefer server.Close()\n\tdefer api.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuser := createClient(t, wsURL)\n\tdefer user.conn.Close()\n\n\tping := make(chan bool)\n\toldPingHandler := user.conn.PingHandler()\n\tuser.conn.SetPingHandler(func(appData string) error {\n\t\terr := oldPingHandler(appData)\n\t\tping <- true\n\t\treturn err\n\t})\n\n\tstartReading(user)\n\n\texpectNoMessage(user)\n\n\tselect {\n\tcase <-time.After(2 * time.Second):\n\t\tassert.Fail(t, \"Expected ping but there was one :(\")\n\tcase <-ping:\n\t\t// expected\n\t}\n\n\texpectNoMessage(user)\n\tapi.Notify(1, &model.MessageExternal{Message: \"HI\"})\n\tuser.expectMessage(&model.MessageExternal{Message: \"HI\"})\n}\n\nfunc TestCloseClientOnNotReading(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tserver, api := bootTestServer(staticUserID())\n\tdefer server.Close()\n\tdefer api.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)\n\tassert.Nil(t, err)\n\tdefer ws.Close()\n\n\twaitForConnectedClients(api, 1)\n\n\tassert.NotEmpty(t, clients(api, 1))\n\n\ttime.Sleep(api.pingPeriod + api.pongTimeout)\n\n\tassert.Empty(t, clients(api, 1))\n}\n\nfunc TestMessageDirectlyAfterConnect(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdefer leaktest.Check(t)()\n\tserver, api := bootTestServer(staticUserID())\n\tdefer server.Close()\n\tdefer api.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuser := testClient(t, wsURL)\n\tdefer user.conn.Close()\n\n\twaitForConnectedClients(api, 1)\n\n\tapi.Notify(1, &model.MessageExternal{Message: \"msg\"})\n\tuser.expectMessage(&model.MessageExternal{Message: \"msg\"})\n}\n\nfunc TestDeleteClientShouldCloseConnection(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdefer leaktest.Check(t)()\n\tserver, api := bootTestServer(staticUserID())\n\tdefer server.Close()\n\tdefer api.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuser := testClient(t, wsURL)\n\tdefer user.conn.Close()\n\n\twaitForConnectedClients(api, 1)\n\n\tapi.Notify(1, &model.MessageExternal{Message: \"msg\"})\n\tuser.expectMessage(&model.MessageExternal{Message: \"msg\"})\n\n\tapi.NotifyDeletedClient(1, \"customtoken\")\n\n\tapi.Notify(1, &model.MessageExternal{Message: \"msg\"})\n\tuser.expectNoMessage()\n}\n\nfunc TestDeleteMultipleClients(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tdefer leaktest.Check(t)()\n\tuserIDs := []uint{1, 1, 1, 1, 2, 2, 3}\n\ttokens := []string{\"1-1\", \"1-2\", \"1-2\", \"1-3\", \"2-1\", \"2-2\", \"3\"}\n\ti := 0\n\tserver, api := bootTestServer(func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])\n\t\ti++\n\t})\n\tdefer server.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuserOneIPhone := testClient(t, wsURL)\n\tdefer userOneIPhone.conn.Close()\n\tuserOneAndroid := testClient(t, wsURL)\n\tdefer userOneAndroid.conn.Close()\n\tuserOneBrowser := testClient(t, wsURL)\n\tdefer userOneBrowser.conn.Close()\n\tuserOneOther := testClient(t, wsURL)\n\tdefer userOneOther.conn.Close()\n\tuserOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}\n\n\tuserTwoBrowser := testClient(t, wsURL)\n\tdefer userTwoBrowser.conn.Close()\n\tuserTwoAndroid := testClient(t, wsURL)\n\tdefer userTwoAndroid.conn.Close()\n\tuserTwo := []*testingClient{userTwoAndroid, userTwoBrowser}\n\n\tuserThreeAndroid := testClient(t, wsURL)\n\tdefer userThreeAndroid.conn.Close()\n\tuserThree := []*testingClient{userThreeAndroid}\n\n\twaitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))\n\n\tapi.Notify(1, &model.MessageExternal{ID: 4, Message: \"there\"})\n\texpectMessage(&model.MessageExternal{ID: 4, Message: \"there\"}, userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.NotifyDeletedClient(1, \"1-2\")\n\n\tapi.Notify(1, &model.MessageExternal{ID: 2, Message: \"there\"})\n\texpectMessage(&model.MessageExternal{ID: 2, Message: \"there\"}, userOneIPhone, userOneOther)\n\texpectNoMessage(userOneBrowser, userOneAndroid)\n\texpectNoMessage(userThree...)\n\texpectNoMessage(userTwo...)\n\n\tapi.Notify(2, &model.MessageExternal{ID: 2, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectMessage(&model.MessageExternal{ID: 2, Message: \"there\"}, userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(3, &model.MessageExternal{ID: 5, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userTwo...)\n\texpectMessage(&model.MessageExternal{ID: 5, Message: \"there\"}, userThree...)\n\n\tapi.Close()\n}\n\nfunc TestDeleteUser(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tdefer leaktest.Check(t)()\n\tuserIDs := []uint{1, 1, 1, 1, 2, 2, 3}\n\ttokens := []string{\"1-1\", \"1-2\", \"1-2\", \"1-3\", \"2-1\", \"2-2\", \"3\"}\n\ti := 0\n\tserver, api := bootTestServer(func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])\n\t\ti++\n\t})\n\tdefer server.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuserOneIPhone := testClient(t, wsURL)\n\tdefer userOneIPhone.conn.Close()\n\tuserOneAndroid := testClient(t, wsURL)\n\tdefer userOneAndroid.conn.Close()\n\tuserOneBrowser := testClient(t, wsURL)\n\tdefer userOneBrowser.conn.Close()\n\tuserOneOther := testClient(t, wsURL)\n\tdefer userOneOther.conn.Close()\n\tuserOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}\n\n\tuserTwoBrowser := testClient(t, wsURL)\n\tdefer userTwoBrowser.conn.Close()\n\tuserTwoAndroid := testClient(t, wsURL)\n\tdefer userTwoAndroid.conn.Close()\n\tuserTwo := []*testingClient{userTwoAndroid, userTwoBrowser}\n\n\tuserThreeAndroid := testClient(t, wsURL)\n\tdefer userThreeAndroid.conn.Close()\n\tuserThree := []*testingClient{userThreeAndroid}\n\n\twaitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))\n\n\tapi.Notify(1, &model.MessageExternal{ID: 4, Message: \"there\"})\n\texpectMessage(&model.MessageExternal{ID: 4, Message: \"there\"}, userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.NotifyDeletedUser(1)\n\n\tapi.Notify(1, &model.MessageExternal{ID: 2, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userThree...)\n\texpectNoMessage(userTwo...)\n\n\tapi.Notify(2, &model.MessageExternal{ID: 2, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectMessage(&model.MessageExternal{ID: 2, Message: \"there\"}, userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(3, &model.MessageExternal{ID: 5, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userTwo...)\n\texpectMessage(&model.MessageExternal{ID: 5, Message: \"there\"}, userThree...)\n\n\tapi.Close()\n}\n\nfunc TestCollectConnectedClientTokens(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tdefer leaktest.Check(t)()\n\tuserIDs := []uint{1, 1, 1, 2, 2}\n\ttokens := []string{\"1-1\", \"1-2\", \"1-2\", \"2-1\", \"2-2\"}\n\ti := 0\n\tserver, api := bootTestServer(func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])\n\t\ti++\n\t})\n\tdefer server.Close()\n\n\twsURL := wsURL(server.URL)\n\tuserOneConnOne := testClient(t, wsURL)\n\tdefer userOneConnOne.conn.Close()\n\tuserOneConnTwo := testClient(t, wsURL)\n\tdefer userOneConnTwo.conn.Close()\n\tuserOneConnThree := testClient(t, wsURL)\n\tdefer userOneConnThree.conn.Close()\n\twaitForConnectedClients(api, 3)\n\n\tret := api.CollectConnectedClientTokens()\n\tsort.Strings(ret)\n\tassert.Equal(t, []string{\"1-1\", \"1-2\"}, ret)\n\n\tuserTwoConnOne := testClient(t, wsURL)\n\tdefer userTwoConnOne.conn.Close()\n\tuserTwoConnTwo := testClient(t, wsURL)\n\tdefer userTwoConnTwo.conn.Close()\n\twaitForConnectedClients(api, 5)\n\n\tret = api.CollectConnectedClientTokens()\n\tsort.Strings(ret)\n\tassert.Equal(t, []string{\"1-1\", \"1-2\", \"2-1\", \"2-2\"}, ret)\n}\n\nfunc TestMultipleClients(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tdefer leaktest.Check(t)()\n\tuserIDs := []uint{1, 1, 1, 2, 2, 3}\n\ti := 0\n\tserver, api := bootTestServer(func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, userIDs[i], \"t\"+fmt.Sprint(userIDs[i]))\n\t\ti++\n\t})\n\tdefer server.Close()\n\n\twsURL := wsURL(server.URL)\n\n\tuserOneIPhone := testClient(t, wsURL)\n\tdefer userOneIPhone.conn.Close()\n\tuserOneAndroid := testClient(t, wsURL)\n\tdefer userOneAndroid.conn.Close()\n\tuserOneBrowser := testClient(t, wsURL)\n\tdefer userOneBrowser.conn.Close()\n\tuserOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone}\n\n\tuserTwoBrowser := testClient(t, wsURL)\n\tdefer userTwoBrowser.conn.Close()\n\tuserTwoAndroid := testClient(t, wsURL)\n\tdefer userTwoAndroid.conn.Close()\n\tuserTwo := []*testingClient{userTwoAndroid, userTwoBrowser}\n\n\tuserThreeAndroid := testClient(t, wsURL)\n\tdefer userThreeAndroid.conn.Close()\n\tuserThree := []*testingClient{userThreeAndroid}\n\n\twaitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))\n\n\t// there should not be messages at the beginning\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(1, &model.MessageExternal{ID: 1, Message: \"hello\"})\n\ttime.Sleep(500 * time.Millisecond)\n\texpectMessage(&model.MessageExternal{ID: 1, Message: \"hello\"}, userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(2, &model.MessageExternal{ID: 2, Message: \"there\"})\n\texpectNoMessage(userOne...)\n\texpectMessage(&model.MessageExternal{ID: 2, Message: \"there\"}, userTwo...)\n\texpectNoMessage(userThree...)\n\n\tuserOneIPhone.conn.Close()\n\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(1, &model.MessageExternal{ID: 3, Message: \"how\"})\n\texpectMessage(&model.MessageExternal{ID: 3, Message: \"how\"}, userOneAndroid, userOneBrowser)\n\texpectNoMessage(userOneIPhone)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Notify(2, &model.MessageExternal{ID: 4, Message: \"are\"})\n\n\texpectNoMessage(userOne...)\n\texpectMessage(&model.MessageExternal{ID: 4, Message: \"are\"}, userTwo...)\n\texpectNoMessage(userThree...)\n\n\tapi.Close()\n\n\tapi.Notify(2, &model.MessageExternal{ID: 5, Message: \"you\"})\n\n\texpectNoMessage(userOne...)\n\texpectNoMessage(userTwo...)\n\texpectNoMessage(userThree...)\n}\n\nfunc Test_sameOrigin_returnsTrue(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http://example.com\")\n\tactual := isAllowedOrigin(req, nil)\n\tassert.True(t, actual)\n}\n\nfunc Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com:8080/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http://example.com:8080\")\n\tactual := isAllowedOrigin(req, nil)\n\tassert.True(t, actual)\n}\n\nfunc Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http://gorify.example.com\")\n\tactual := isAllowedOrigin(req, nil)\n\tassert.False(t, actual)\n}\n\nfunc Test_isAllowedOriginMatching(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tcompiledAllowedOrigins := compileAllowedWebSocketOrigins([]string{\"go.{4}\\\\.example\\\\.com\", \"go\\\\.example\\\\.com\"})\n\n\treq := httptest.NewRequest(\"GET\", \"http://example.me/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http://gorify.example.com\")\n\tassert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))\n\n\treq.Header.Set(\"Origin\", \"http://go.example.com\")\n\tassert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))\n\n\treq.Header.Set(\"Origin\", \"http://hello.example.com\")\n\tassert.False(t, isAllowedOrigin(req, compiledAllowedOrigins))\n}\n\nfunc Test_emptyOrigin_returnsTrue(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/stream\", nil)\n\tactual := isAllowedOrigin(req, nil)\n\tassert.True(t, actual)\n}\n\nfunc Test_otherOrigin_returnsFalse(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http://otherexample.de\")\n\tactual := isAllowedOrigin(req, nil)\n\tassert.False(t, actual)\n}\n\nfunc Test_invalidOrigin_returnsFalse(t *testing.T) {\n\tmode.Set(mode.Prod)\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/stream\", nil)\n\treq.Header.Set(\"Origin\", \"http\\\\://otherexample.de\")\n\tactual := isAllowedOrigin(req, nil)\n\tassert.False(t, actual)\n}\n\nfunc Test_compileAllowedWebSocketOrigins(t *testing.T) {\n\tassert.Equal(t, 0, len(compileAllowedWebSocketOrigins([]string{})))\n\tassert.Equal(t, 3, len(compileAllowedWebSocketOrigins([]string{\"^.*$\", \"\", \"abc\"})))\n}\n\nfunc clients(api *API, user uint) []*client {\n\tapi.lock.RLock()\n\tdefer api.lock.RUnlock()\n\n\treturn api.clients[user]\n}\n\nfunc countClients(a *API) int {\n\ta.lock.RLock()\n\tdefer a.lock.RUnlock()\n\n\tvar i int\n\tfor _, clients := range a.clients {\n\t\ti += len(clients)\n\t}\n\treturn i\n}\n\nfunc testClient(t *testing.T, url string) *testingClient {\n\tclient := createClient(t, url)\n\tstartReading(client)\n\treturn client\n}\n\nfunc startReading(client *testingClient) {\n\tgo func() {\n\t\tfor {\n\t\t\t_, payload, err := client.conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tactual := &model.MessageExternal{}\n\t\t\tjson.NewDecoder(bytes.NewBuffer(payload)).Decode(actual)\n\t\t\tclient.readMessage <- *actual\n\t\t}\n\t}()\n}\n\nfunc createClient(t *testing.T, url string) *testingClient {\n\tws, _, err := websocket.DefaultDialer.Dial(url, nil)\n\tassert.Nil(t, err)\n\n\treadMessages := make(chan model.MessageExternal)\n\n\treturn &testingClient{conn: ws, readMessage: readMessages, t: t}\n}\n\ntype testingClient struct {\n\tconn        *websocket.Conn\n\treadMessage chan model.MessageExternal\n\tt           *testing.T\n}\n\nfunc (c *testingClient) expectMessage(expected *model.MessageExternal) {\n\tselect {\n\tcase <-time.After(50 * time.Millisecond):\n\t\tassert.Fail(c.t, \"Expected message but none was send :(\")\n\tcase actual := <-c.readMessage:\n\t\tassert.Equal(c.t, *expected, actual)\n\t}\n}\n\nfunc expectMessage(expected *model.MessageExternal, clients ...*testingClient) {\n\tfor _, client := range clients {\n\t\tclient.expectMessage(expected)\n\t}\n}\n\nfunc expectNoMessage(clients ...*testingClient) {\n\tfor _, client := range clients {\n\t\tclient.expectNoMessage()\n\t}\n}\n\nfunc (c *testingClient) expectNoMessage() {\n\tselect {\n\tcase <-time.After(50 * time.Millisecond):\n\t\t// no message == as expected\n\tcase msg := <-c.readMessage:\n\t\tassert.Fail(c.t, \"Expected NO message but there was one :(\", fmt.Sprint(msg))\n\t}\n}\n\nfunc bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) {\n\tr := gin.New()\n\tr.Use(handlerFunc)\n\t// ping every 500 ms, and the client has 500 ms to respond\n\tapi := New(500*time.Millisecond, 500*time.Millisecond, []string{})\n\n\tr.GET(\"/\", api.Handle)\n\tserver := httptest.NewServer(r)\n\treturn server, api\n}\n\nfunc wsURL(httpURL string) string {\n\treturn \"ws\" + strings.TrimPrefix(httpURL, \"http\")\n}\n\nfunc staticUserID() gin.HandlerFunc {\n\treturn func(context *gin.Context) {\n\t\tauth.RegisterAuthentication(context, nil, 1, \"customtoken\")\n\t}\n}\n\nfunc waitForConnectedClients(api *API, count int) {\n\tfor i := 0; i < 10; i++ {\n\t\tif countClients(api) == count {\n\t\t\t// ok\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n"
  },
  {
    "path": "api/tokens.go",
    "content": "package api\n\nimport (\n\t\"github.com/gotify/server/v2/auth\"\n)\n\nvar generateApplicationToken = auth.GenerateApplicationToken\n\nvar generateClientToken = auth.GenerateClientToken\n\nvar generateImageName = auth.GenerateImageName\n"
  },
  {
    "path": "api/tokens_test.go",
    "content": "package api\n\nimport (\n\t\"regexp\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTokenGeneration(t *testing.T) {\n\tassert.Regexp(t, regexp.MustCompile(\"^C(.+)$\"), generateClientToken())\n\tassert.Regexp(t, regexp.MustCompile(\"^A(.+)$\"), generateApplicationToken())\n\tassert.Regexp(t, regexp.MustCompile(\"^(.+)$\"), generateImageName())\n}\n"
  },
  {
    "path": "api/user.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/auth/password\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The UserDatabase interface for encapsulating database access.\ntype UserDatabase interface {\n\tGetUsers() ([]*model.User, error)\n\tGetUserByID(id uint) (*model.User, error)\n\tGetUserByName(name string) (*model.User, error)\n\tDeleteUserByID(id uint) error\n\tUpdateUser(user *model.User) error\n\tCreateUser(user *model.User) error\n\tCountUser(condition ...interface{}) (int64, error)\n}\n\n// UserChangeNotifier notifies listeners for user changes.\ntype UserChangeNotifier struct {\n\tuserDeletedCallbacks []func(uid uint) error\n\tuserAddedCallbacks   []func(uid uint) error\n}\n\n// OnUserDeleted is called on user deletion.\nfunc (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) {\n\tc.userDeletedCallbacks = append(c.userDeletedCallbacks, cb)\n}\n\n// OnUserAdded is called on user creation.\nfunc (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) {\n\tc.userAddedCallbacks = append(c.userAddedCallbacks, cb)\n}\n\nfunc (c *UserChangeNotifier) fireUserDeleted(uid uint) error {\n\tfor _, cb := range c.userDeletedCallbacks {\n\t\tif err := cb(uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *UserChangeNotifier) fireUserAdded(uid uint) error {\n\tfor _, cb := range c.userAddedCallbacks {\n\t\tif err := cb(uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// The UserAPI provides handlers for managing users.\ntype UserAPI struct {\n\tDB                 UserDatabase\n\tPasswordStrength   int\n\tUserChangeNotifier *UserChangeNotifier\n\tRegistration       bool\n}\n\n// GetUsers returns all the users\n// swagger:operation GET /user user getUsers\n//\n// Return all users.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t      type: array\n//\t      items:\n//\t        $ref: \"#/definitions/User\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) GetUsers(ctx *gin.Context) {\n\tusers, err := a.DB.GetUsers()\n\tif success := successOrAbort(ctx, 500, err); !success {\n\t\treturn\n\t}\n\tvar resp []*model.UserExternal\n\tfor _, user := range users {\n\t\tresp = append(resp, toExternalUser(user))\n\t}\n\n\tctx.JSON(200, resp)\n}\n\n// GetCurrentUser returns the current user\n// swagger:operation GET /current/user user currentUser\n//\n// Return the current user.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/User\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) GetCurrentUser(ctx *gin.Context) {\n\tuser, err := a.DB.GetUserByID(auth.GetUserID(ctx))\n\tif success := successOrAbort(ctx, 500, err); !success {\n\t\treturn\n\t}\n\tctx.JSON(200, toExternalUser(user))\n}\n\n// CreateUser create a user.\n// swagger:operation POST /user user createUser\n//\n// Create a user.\n//\n// With enabled registration: non admin users can be created without authentication.\n// With disabled registrations: users can only be created by admin users.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the user to add\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/CreateUserExternal\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/User\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) CreateUser(ctx *gin.Context) {\n\tuser := model.CreateUserExternal{}\n\tif err := ctx.Bind(&user); err == nil {\n\t\tinternal := &model.User{\n\t\t\tName:  user.Name,\n\t\t\tAdmin: user.Admin,\n\t\t\tPass:  password.CreatePassword(user.Pass, a.PasswordStrength),\n\t\t}\n\t\texistingUser, err := a.DB.GetUserByName(internal.Name)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\n\t\tvar requestedBy *model.User\n\t\tuid := auth.TryGetUserID(ctx)\n\t\tif uid != nil {\n\t\t\trequestedBy, err = a.DB.GetUserByID(*uid)\n\t\t\tif err != nil {\n\t\t\t\tctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf(\"could not get user: %s\", err))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif requestedBy == nil || !requestedBy.Admin {\n\t\t\tstatus := http.StatusUnauthorized\n\t\t\tif requestedBy != nil {\n\t\t\t\tstatus = http.StatusForbidden\n\t\t\t}\n\t\t\tif !a.Registration {\n\t\t\t\tctx.AbortWithError(status, errors.New(\"you are not allowed to access this api\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif internal.Admin {\n\t\t\t\tctx.AbortWithError(status, errors.New(\"you are not allowed to create an admin user\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif existingUser == nil {\n\t\t\tif success := successOrAbort(ctx, 500, a.DB.CreateUser(internal)); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil {\n\t\t\t\tctx.AbortWithError(500, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tctx.JSON(200, toExternalUser(internal))\n\t\t} else {\n\t\t\tctx.AbortWithError(400, errors.New(\"username already exists\"))\n\t\t}\n\t}\n}\n\n// GetUserByID returns the user by id\n// swagger:operation GET /user/{id} user getUser\n//\n// Get a user.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the user id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/User\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) GetUserByID(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tuser, err := a.DB.GetUserByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif user != nil {\n\t\t\tctx.JSON(200, toExternalUser(user))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, errors.New(\"user does not exist\"))\n\t\t}\n\t})\n}\n\n// DeleteUserByID deletes the user by id\n// swagger:operation DELETE /user/{id} user deleteUser\n//\n// Deletes a user.\n//\n//\t---\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the user id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) DeleteUserByID(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tuser, err := a.DB.GetUserByID(id)\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tif user != nil {\n\t\t\tadminCount, err := a.DB.CountUser(&model.User{Admin: true})\n\t\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif user.Admin && adminCount == 1 {\n\t\t\t\tctx.AbortWithError(400, errors.New(\"cannot delete last admin\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := a.UserChangeNotifier.fireUserDeleted(id); err != nil {\n\t\t\t\tctx.AbortWithError(500, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsuccessOrAbort(ctx, 500, a.DB.DeleteUserByID(id))\n\t\t} else {\n\t\t\tctx.AbortWithError(404, errors.New(\"user does not exist\"))\n\t\t}\n\t})\n}\n\n// ChangePassword changes the password from the current user\n// swagger:operation POST /current/user/password user updateCurrentUser\n//\n// Update the password of the current user.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: body\n//\t  in: body\n//\t  description: the user\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/UserPass\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) ChangePassword(ctx *gin.Context) {\n\tpw := model.UserExternalPass{}\n\tif err := ctx.Bind(&pw); err == nil {\n\t\tuser, err := a.DB.GetUserByID(auth.GetUserID(ctx))\n\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\treturn\n\t\t}\n\t\tuser.Pass = password.CreatePassword(pw.Pass, a.PasswordStrength)\n\t\tsuccessOrAbort(ctx, 500, a.DB.UpdateUser(user))\n\t}\n}\n\n// UpdateUserByID updates and user by id\n// swagger:operation POST /user/{id} user updateUser\n//\n// Update a user.\n//\n//\t---\n//\tconsumes: [application/json]\n//\tproduces: [application/json]\n//\tsecurity: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]\n//\tparameters:\n//\t- name: id\n//\t  in: path\n//\t  description: the user id\n//\t  required: true\n//\t  type: integer\n//\t  format: int64\n//\t- name: body\n//\t  in: body\n//\t  description: the updated user\n//\t  required: true\n//\t  schema:\n//\t    $ref: \"#/definitions/UpdateUserExternal\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/User\"\n//\t  400:\n//\t    description: Bad Request\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  401:\n//\t    description: Unauthorized\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  403:\n//\t    description: Forbidden\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\n//\t  404:\n//\t    description: Not Found\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *UserAPI) UpdateUserByID(ctx *gin.Context) {\n\twithID(ctx, \"id\", func(id uint) {\n\t\tvar user *model.UpdateUserExternal\n\t\tif err := ctx.Bind(&user); err == nil {\n\t\t\toldUser, err := a.DB.GetUserByID(id)\n\t\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif oldUser != nil {\n\t\t\t\tadminCount, err := a.DB.CountUser(&model.User{Admin: true})\n\t\t\t\tif success := successOrAbort(ctx, 500, err); !success {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !user.Admin && oldUser.Admin && adminCount == 1 {\n\t\t\t\t\tctx.AbortWithError(400, errors.New(\"cannot delete last admin\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tinternal := &model.User{\n\t\t\t\t\tID:    oldUser.ID,\n\t\t\t\t\tName:  user.Name,\n\t\t\t\t\tAdmin: user.Admin,\n\t\t\t\t\tPass:  oldUser.Pass,\n\t\t\t\t}\n\t\t\t\tif user.Pass != \"\" {\n\t\t\t\t\tinternal.Pass = password.CreatePassword(user.Pass, a.PasswordStrength)\n\t\t\t\t}\n\t\t\t\tif success := successOrAbort(ctx, 500, a.DB.UpdateUser(internal)); !success {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tctx.JSON(200, toExternalUser(internal))\n\t\t\t} else {\n\t\t\t\tctx.AbortWithError(404, errors.New(\"user does not exist\"))\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc toExternalUser(internal *model.User) *model.UserExternal {\n\treturn &model.UserExternal{\n\t\tName:  internal.Name,\n\t\tAdmin: internal.Admin,\n\t\tID:    internal.ID,\n\t}\n}\n"
  },
  {
    "path": "api/user_test.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/auth/password\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc TestUserSuite(t *testing.T) {\n\tsuite.Run(t, new(UserSuite))\n}\n\ntype UserSuite struct {\n\tsuite.Suite\n\tdb             *testdb.Database\n\ta              *UserAPI\n\tctx            *gin.Context\n\trecorder       *httptest.ResponseRecorder\n\tnotifiedAdd    bool\n\tnotifiedDelete bool\n\tnotifier       *UserChangeNotifier\n}\n\nfunc (s *UserSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n\ts.recorder = httptest.NewRecorder()\n\ts.ctx, _ = gin.CreateTestContext(s.recorder)\n\n\ts.db = testdb.NewDB(s.T())\n\n\ts.notifier = new(UserChangeNotifier)\n\ts.notifier.OnUserDeleted(func(uint) error {\n\t\ts.notifiedDelete = true\n\t\treturn nil\n\t})\n\ts.notifier.OnUserAdded(func(uint) error {\n\t\ts.notifiedAdd = true\n\t\treturn nil\n\t})\n\ts.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier}\n}\n\nfunc (s *UserSuite) AfterTest(suiteName, testName string) {\n\ts.db.Close()\n}\n\nfunc (s *UserSuite) Test_GetUsers() {\n\tfirst := s.db.NewUser(2)\n\tsecond := s.db.NewUser(5)\n\n\ts.a.GetUsers(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), []*model.UserExternal{externalOf(first), externalOf(second)}, s.recorder)\n}\n\nfunc (s *UserSuite) Test_GetCurrentUser() {\n\tuser := s.db.NewUser(5)\n\n\ttest.WithUser(s.ctx, 5)\n\ts.a.GetCurrentUser(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), externalOf(user), s.recorder)\n}\n\nfunc (s *UserSuite) Test_GetUserByID() {\n\tuser := s.db.NewUser(2)\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.GetUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ttest.BodyEquals(s.T(), externalOf(user), s.recorder)\n}\n\nfunc (s *UserSuite) Test_GetUserByID_InvalidID() {\n\ts.db.User(2)\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"abc\"}}\n\n\ts.a.GetUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_GetUserByID_UnknownUser() {\n\ts.db.User(2)\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"3\"}}\n\n\ts.a.GetUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_DeleteUserByID_LastAdmin_Expect400() {\n\ts.db.CreateUser(&model.User{\n\t\tID:    7,\n\t\tName:  \"admin\",\n\t\tAdmin: true,\n\t})\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"7\"}}\n\n\ts.a.DeleteUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_DeleteUserByID_InvalidID() {\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"abc\"}}\n\n\ts.a.DeleteUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_DeleteUserByID_UnknownUser() {\n\ts.db.User(2)\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"3\"}}\n\n\ts.a.DeleteUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_DeleteUserByID() {\n\tassert.False(s.T(), s.notifiedDelete)\n\n\ts.db.User(2)\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.a.DeleteUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\ts.db.AssertUserNotExist(2)\n\tassert.True(s.T(), s.notifiedDelete)\n}\n\nfunc (s *UserSuite) Test_DeleteUserByID_NotifyFail() {\n\ts.db.User(5)\n\ts.notifier.OnUserDeleted(func(id uint) error {\n\t\tif id == 5 {\n\t\t\treturn errors.New(\"some error\")\n\t\t}\n\t\treturn nil\n\t})\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"5\"}}\n\n\ts.a.DeleteUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 500, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_CreateUser() {\n\ts.loginAdmin()\n\n\tassert.False(s.T(), s.notifiedAdd)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"mylittlepony\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tuser := &model.UserExternal{ID: 2, Name: \"tom\", Admin: true}\n\ttest.BodyEquals(s.T(), user, s.recorder)\n\n\tif created, err := s.db.GetUserByName(\"tom\"); assert.NoError(s.T(), err) {\n\t\tassert.NotNil(s.T(), created)\n\t\tassert.True(s.T(), password.ComparePassword(created.Pass, []byte(\"mylittlepony\")))\n\t}\n\tassert.True(s.T(), s.notifiedAdd)\n}\n\nfunc (s *UserSuite) Test_CreateUser_ByNonAdmin() {\n\ts.loginUser()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 403, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() {\n\ts.loginUser()\n\ts.a.Registration = true\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif created, err := s.db.GetUserByName(\"tom\"); assert.NoError(s.T(), err) {\n\t\tassert.NotNil(s.T(), created)\n\t}\n}\n\nfunc (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() {\n\ts.a.Registration = true\n\ts.loginUser()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 403, s.recorder.Code)\n\ts.db.AssertUsernameNotExist(\"tom\")\n}\n\nfunc (s *UserSuite) Test_CreateUser_Anonymous() {\n\ts.noLogin()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 401, s.recorder.Code)\n\ts.db.AssertUsernameNotExist(\"tom\")\n}\n\nfunc (s *UserSuite) Test_CreateUser_Register_Anonymous() {\n\ts.a.Registration = true\n\ts.noLogin()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tif created, err := s.db.GetUserByName(\"tom\"); assert.NoError(s.T(), err) {\n\t\tassert.NotNil(s.T(), created)\n\t}\n}\n\nfunc (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() {\n\ts.a.Registration = true\n\ts.noLogin()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"1\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 401, s.recorder.Code)\n\ts.db.AssertUsernameNotExist(\"tom\")\n}\n\nfunc (s *UserSuite) Test_CreateUser_NotifyFail() {\n\ts.loginAdmin()\n\n\ts.notifier.OnUserAdded(func(id uint) error {\n\t\tuser, err := s.db.GetUserByID(id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif user.Name == \"eva\" {\n\t\t\treturn errors.New(\"some error\")\n\t\t}\n\t\treturn nil\n\t})\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"eva\", \"pass\": \"mylittlepony\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 500, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_CreateUser_NoPassword() {\n\ts.loginAdmin()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_CreateUser_NoName() {\n\ts.loginAdmin()\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"\", \"pass\": \"asd\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_CreateUser_NameAlreadyExists() {\n\ts.loginAdmin()\n\ts.db.NewUserWithName(2, \"tom\")\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"mylittlepony\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.CreateUser(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_UpdateUserByID_InvalidID() {\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"abc\"}}\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/abc\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.UpdateUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_UpdateUserByID_LastAdmin_Expect400() {\n\ts.db.CreateUser(&model.User{\n\t\tID:    7,\n\t\tName:  \"admin\",\n\t\tAdmin: true,\n\t})\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"7\"}}\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/7\", strings.NewReader(`{\"name\": \"admin\", \"pass\": \"\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\ts.a.UpdateUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_UpdateUserByID_UnknownUser() {\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/2\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"\", \"admin\": false}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.UpdateUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 404, s.recorder.Code)\n}\n\nfunc (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() {\n\ts.db.CreateUser(&model.User{ID: 2, Name: \"nico\", Pass: password.CreatePassword(\"old\", 5)})\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/2\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.UpdateUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tuser, err := s.db.GetUserByID(2)\n\tassert.NoError(s.T(), err)\n\tassert.NotNil(s.T(), user)\n\tassert.True(s.T(), password.ComparePassword(user.Pass, []byte(\"old\")))\n}\n\nfunc (s *UserSuite) Test_UpdateUserByID_UpdatePassword() {\n\ts.db.CreateUser(&model.User{ID: 2, Name: \"tom\", Pass: password.CreatePassword(\"old\", 5)})\n\n\ts.ctx.Params = gin.Params{{Key: \"id\", Value: \"2\"}}\n\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/2\", strings.NewReader(`{\"name\": \"tom\", \"pass\": \"new\", \"admin\": true}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.UpdateUserByID(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tuser, err := s.db.GetUserByID(2)\n\tassert.NoError(s.T(), err)\n\tassert.NotNil(s.T(), user)\n\tassert.True(s.T(), password.ComparePassword(user.Pass, []byte(\"new\")))\n}\n\nfunc (s *UserSuite) Test_UpdatePassword() {\n\ts.db.CreateUser(&model.User{ID: 1, Name: \"jmattheis\", Pass: password.CreatePassword(\"old\", 5)})\n\n\ttest.WithUser(s.ctx, 1)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/current/password\", strings.NewReader(`{\"pass\": \"new\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.ChangePassword(s.ctx)\n\n\tassert.Equal(s.T(), 200, s.recorder.Code)\n\tuser, err := s.db.GetUserByID(1)\n\tassert.NoError(s.T(), err)\n\tassert.NotNil(s.T(), user)\n\tassert.True(s.T(), password.ComparePassword(user.Pass, []byte(\"new\")))\n}\n\nfunc (s *UserSuite) Test_UpdatePassword_EmptyPassword() {\n\ts.db.CreateUser(&model.User{ID: 1, Name: \"jmattheis\", Pass: password.CreatePassword(\"old\", 5)})\n\n\ttest.WithUser(s.ctx, 1)\n\ts.ctx.Request = httptest.NewRequest(\"POST\", \"/user/current/password\", strings.NewReader(`{\"pass\":\"\"}`))\n\ts.ctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\ts.a.ChangePassword(s.ctx)\n\n\tassert.Equal(s.T(), 400, s.recorder.Code)\n\tuser, err := s.db.GetUserByID(1)\n\tassert.NoError(s.T(), err)\n\tassert.NotNil(s.T(), user)\n\tassert.True(s.T(), password.ComparePassword(user.Pass, []byte(\"old\")))\n}\n\nfunc (s *UserSuite) loginAdmin() {\n\ts.db.CreateUser(&model.User{ID: 1, Name: \"admin\", Admin: true})\n\tauth.RegisterAuthentication(s.ctx, nil, 1, \"\")\n}\n\nfunc (s *UserSuite) loginUser() {\n\ts.db.CreateUser(&model.User{ID: 1, Name: \"user\", Admin: false})\n\tauth.RegisterAuthentication(s.ctx, nil, 1, \"\")\n}\n\nfunc (s *UserSuite) noLogin() {\n\tauth.RegisterAuthentication(s.ctx, nil, 0, \"\")\n}\n\nfunc externalOf(user *model.User) *model.UserExternal {\n\treturn &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID}\n}\n"
  },
  {
    "path": "app.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/gotify/server/v2/config\"\n\t\"github.com/gotify/server/v2/database\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/router\"\n\t\"github.com/gotify/server/v2/runner\"\n)\n\nvar (\n\t// Version the version of Gotify.\n\tVersion = \"unknown\"\n\t// Commit the git commit hash of this version.\n\tCommit = \"unknown\"\n\t// BuildDate the date on which this binary was build.\n\tBuildDate = \"unknown\"\n\t// Mode the build mode.\n\tMode = mode.Dev\n)\n\nfunc main() {\n\tvInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate}\n\tmode.Set(Mode)\n\n\tfmt.Println(\"Starting Gotify version\", vInfo.Version+\"@\"+BuildDate)\n\tconf := config.Get()\n\n\tif conf.PluginsDir != \"\" {\n\t\tif err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil {\n\t\tpanic(err)\n\t}\n\n\tdb, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer db.Close()\n\n\tengine, closeable := router.Create(db, vInfo, conf)\n\tdefer closeable()\n\n\tif err := runner.Run(engine, conf); err != nil {\n\t\tfmt.Println(\"Server error: \", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "auth/authentication.go",
    "content": "package auth\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth/password\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\nconst (\n\theaderName = \"X-Gotify-Key\"\n)\n\n// The Database interface for encapsulating database access.\ntype Database interface {\n\tGetApplicationByToken(token string) (*model.Application, error)\n\tGetClientByToken(token string) (*model.Client, error)\n\tGetPluginConfByToken(token string) (*model.PluginConf, error)\n\tGetUserByName(name string) (*model.User, error)\n\tGetUserByID(id uint) (*model.User, error)\n\tUpdateClientTokensLastUsed(tokens []string, t *time.Time) error\n\tUpdateApplicationTokenLastUsed(token string, t *time.Time) error\n}\n\n// Auth is the provider for authentication middleware.\ntype Auth struct {\n\tDB Database\n}\n\ntype authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error)\n\n// RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied\n// with the request. Also the authenticated user must be an administrator.\nfunc (a *Auth) RequireAdmin() gin.HandlerFunc {\n\treturn a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {\n\t\tif user != nil {\n\t\t\treturn true, user.Admin, user.ID, nil\n\t\t}\n\t\tif token, err := a.DB.GetClientByToken(tokenID); err != nil {\n\t\t\treturn false, false, 0, err\n\t\t} else if token != nil {\n\t\t\tuser, err := a.DB.GetUserByID(token.UserID)\n\t\t\tif err != nil {\n\t\t\t\treturn false, false, token.UserID, err\n\t\t\t}\n\t\t\treturn true, user.Admin, token.UserID, nil\n\t\t}\n\t\treturn false, false, 0, nil\n\t})\n}\n\n// RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied\n// with the request.\nfunc (a *Auth) RequireClient() gin.HandlerFunc {\n\treturn a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {\n\t\tif user != nil {\n\t\t\treturn true, true, user.ID, nil\n\t\t}\n\t\tif client, err := a.DB.GetClientByToken(tokenID); err != nil {\n\t\t\treturn false, false, 0, err\n\t\t} else if client != nil {\n\t\t\tnow := time.Now()\n\t\t\tif client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) {\n\t\t\t\tif err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil {\n\t\t\t\t\treturn false, false, 0, err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true, true, client.UserID, nil\n\t\t}\n\t\treturn false, false, 0, nil\n\t})\n}\n\n// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request.\nfunc (a *Auth) RequireApplicationToken() gin.HandlerFunc {\n\treturn a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {\n\t\tif user != nil {\n\t\t\treturn true, false, 0, nil\n\t\t}\n\t\tif app, err := a.DB.GetApplicationByToken(tokenID); err != nil {\n\t\t\treturn false, false, 0, err\n\t\t} else if app != nil {\n\t\t\tnow := time.Now()\n\t\t\tif app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) {\n\t\t\t\tif err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil {\n\t\t\t\t\treturn false, false, 0, err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true, true, app.UserID, nil\n\t\t}\n\t\treturn false, false, 0, nil\n\t})\n}\n\nfunc (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string {\n\tif token := a.tokenFromQuery(ctx); token != \"\" {\n\t\treturn token\n\t} else if token := a.tokenFromXGotifyHeader(ctx); token != \"\" {\n\t\treturn token\n\t} else if token := a.tokenFromAuthorizationHeader(ctx); token != \"\" {\n\t\treturn token\n\t}\n\treturn \"\"\n}\n\nfunc (a *Auth) tokenFromQuery(ctx *gin.Context) string {\n\treturn ctx.Request.URL.Query().Get(\"token\")\n}\n\nfunc (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string {\n\treturn ctx.Request.Header.Get(headerName)\n}\n\nfunc (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string {\n\tconst prefix = \"Bearer \"\n\n\tauthHeader := ctx.Request.Header.Get(\"Authorization\")\n\tif authHeader == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif len(authHeader) < len(prefix) || !strings.EqualFold(prefix, authHeader[:len(prefix)]) {\n\t\treturn \"\"\n\t}\n\n\treturn authHeader[len(prefix):]\n}\n\nfunc (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) {\n\tif name, pass, ok := ctx.Request.BasicAuth(); ok {\n\t\tif user, err := a.DB.GetUserByName(name); err != nil {\n\t\t\treturn nil, err\n\t\t} else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) {\n\t\t\treturn user, nil\n\t\t}\n\t}\n\treturn nil, nil\n}\n\nfunc (a *Auth) requireToken(auth authenticate) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\ttoken := a.tokenFromQueryOrHeader(ctx)\n\t\tuser, err := a.userFromBasicAuth(ctx)\n\t\tif err != nil {\n\t\t\tctx.AbortWithError(500, errors.New(\"an error occurred while authenticating user\"))\n\t\t\treturn\n\t\t}\n\n\t\tif user != nil || token != \"\" {\n\t\t\tauthenticated, ok, userID, err := auth(token, user)\n\t\t\tif err != nil {\n\t\t\t\tctx.AbortWithError(500, errors.New(\"an error occurred while authenticating user\"))\n\t\t\t\treturn\n\t\t\t} else if ok {\n\t\t\t\tRegisterAuthentication(ctx, user, userID, token)\n\t\t\t\tctx.Next()\n\t\t\t\treturn\n\t\t\t} else if authenticated {\n\t\t\t\tctx.AbortWithError(403, errors.New(\"you are not allowed to access this api\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tctx.AbortWithError(401, errors.New(\"you need to provide a valid access token or user credentials to access this api\"))\n\t}\n}\n\nfunc (a *Auth) Optional() gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\ttoken := a.tokenFromQueryOrHeader(ctx)\n\t\tuser, err := a.userFromBasicAuth(ctx)\n\t\tif err != nil {\n\t\t\tRegisterAuthentication(ctx, nil, 0, \"\")\n\t\t\tctx.Next()\n\t\t\treturn\n\t\t}\n\n\t\tif user != nil {\n\t\t\tRegisterAuthentication(ctx, user, user.ID, token)\n\t\t\tctx.Next()\n\t\t\treturn\n\t\t} else if token != \"\" {\n\t\t\tif tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil {\n\t\t\t\tRegisterAuthentication(ctx, user, tokenClient.UserID, token)\n\t\t\t\tctx.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tRegisterAuthentication(ctx, nil, 0, \"\")\n\t\tctx.Next()\n\t}\n}\n"
  },
  {
    "path": "auth/authentication_test.go",
    "content": "package auth\n\nimport (\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth/password\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc TestSuite(t *testing.T) {\n\tsuite.Run(t, new(AuthenticationSuite))\n}\n\ntype AuthenticationSuite struct {\n\tsuite.Suite\n\tauth *Auth\n\tDB   *testdb.Database\n}\n\nfunc (s *AuthenticationSuite) SetupSuite() {\n\tmode.Set(mode.TestDev)\n\ts.DB = testdb.NewDB(s.T())\n\ts.auth = &Auth{s.DB}\n\n\ts.DB.CreateUser(&model.User{\n\t\tName:         \"existing\",\n\t\tPass:         password.CreatePassword(\"pw\", 5),\n\t\tAdmin:        false,\n\t\tApplications: []model.Application{{Token: \"apptoken\", Name: \"backup server1\", Description: \"irrelevant\"}},\n\t\tClients:      []model.Client{{Token: \"clienttoken\", Name: \"android phone1\"}},\n\t})\n\n\ts.DB.CreateUser(&model.User{\n\t\tName:         \"admin\",\n\t\tPass:         password.CreatePassword(\"pw\", 5),\n\t\tAdmin:        true,\n\t\tApplications: []model.Application{{Token: \"apptoken_admin\", Name: \"backup server2\", Description: \"irrelevant\"}},\n\t\tClients:      []model.Client{{Token: \"clienttoken_admin\", Name: \"android phone2\"}},\n\t})\n}\n\nfunc (s *AuthenticationSuite) TearDownSuite() {\n\ts.DB.Close()\n}\n\nfunc (s *AuthenticationSuite) TestQueryToken() {\n\t// not existing token\n\ts.assertQueryRequest(\"token\", \"ergerogerg\", s.auth.RequireApplicationToken, 401)\n\ts.assertQueryRequest(\"token\", \"ergerogerg\", s.auth.RequireClient, 401)\n\ts.assertQueryRequest(\"token\", \"ergerogerg\", s.auth.RequireAdmin, 401)\n\n\t// not existing key\n\ts.assertQueryRequest(\"tokenx\", \"clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertQueryRequest(\"tokenx\", \"clienttoken\", s.auth.RequireClient, 401)\n\ts.assertQueryRequest(\"tokenx\", \"clienttoken\", s.auth.RequireAdmin, 401)\n\n\t// apptoken\n\ts.assertQueryRequest(\"token\", \"apptoken\", s.auth.RequireApplicationToken, 200)\n\ts.assertQueryRequest(\"token\", \"apptoken\", s.auth.RequireClient, 401)\n\ts.assertQueryRequest(\"token\", \"apptoken\", s.auth.RequireAdmin, 401)\n\ts.assertQueryRequest(\"token\", \"apptoken_admin\", s.auth.RequireApplicationToken, 200)\n\ts.assertQueryRequest(\"token\", \"apptoken_admin\", s.auth.RequireClient, 401)\n\ts.assertQueryRequest(\"token\", \"apptoken_admin\", s.auth.RequireAdmin, 401)\n\n\t// clienttoken\n\ts.assertQueryRequest(\"token\", \"clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertQueryRequest(\"token\", \"clienttoken\", s.auth.RequireClient, 200)\n\ts.assertQueryRequest(\"token\", \"clienttoken\", s.auth.RequireAdmin, 403)\n\ts.assertQueryRequest(\"token\", \"clienttoken_admin\", s.auth.RequireApplicationToken, 401)\n\ts.assertQueryRequest(\"token\", \"clienttoken_admin\", s.auth.RequireClient, 200)\n\ts.assertQueryRequest(\"token\", \"clienttoken_admin\", s.auth.RequireAdmin, 200)\n}\n\nfunc (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {\n\trecorder := httptest.NewRecorder()\n\tctx, _ = gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(\"GET\", fmt.Sprintf(\"/?%s=%s\", key, value), nil)\n\tf()(ctx)\n\tassert.Equal(s.T(), code, recorder.Code)\n\treturn ctx\n}\n\nfunc (s *AuthenticationSuite) TestNothingProvided() {\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(\"GET\", \"/\", nil)\n\ts.auth.RequireApplicationToken()(ctx)\n\tassert.Equal(s.T(), 401, recorder.Code)\n}\n\nfunc (s *AuthenticationSuite) TestHeaderApiKeyToken() {\n\t// not existing token\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"ergerogerg\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"ergerogerg\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"ergerogerg\", s.auth.RequireAdmin, 401)\n\n\t// not existing key\n\ts.assertHeaderRequest(\"X-Gotify-Keyx\", \"clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Keyx\", \"clienttoken\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Keyx\", \"clienttoken\", s.auth.RequireAdmin, 401)\n\n\t// apptoken\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken\", s.auth.RequireApplicationToken, 200)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken\", s.auth.RequireAdmin, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken_admin\", s.auth.RequireApplicationToken, 200)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken_admin\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"apptoken_admin\", s.auth.RequireAdmin, 401)\n\n\t// clienttoken\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken\", s.auth.RequireAdmin, 403)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken_admin\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken_admin\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"X-Gotify-Key\", \"clienttoken_admin\", s.auth.RequireAdmin, 200)\n}\n\nfunc (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() {\n\t// not existing token\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer ergerogerg\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer ergerogerg\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer ergerogerg\", s.auth.RequireAdmin, 401)\n\n\t// no authentication schema\n\ts.assertHeaderRequest(\"Authorization\", \"ergerogerg\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"ergerogerg\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"ergerogerg\", s.auth.RequireAdmin, 401)\n\n\t// wrong authentication schema\n\ts.assertHeaderRequest(\"Authorization\", \"ApiKeyx clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"ApiKeyx clienttoken\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"ApiKeyx clienttoken\", s.auth.RequireAdmin, 401)\n\n\t// Authorization Bearer apptoken\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken\", s.auth.RequireApplicationToken, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken\", s.auth.RequireAdmin, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken_admin\", s.auth.RequireApplicationToken, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken_admin\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer apptoken_admin\", s.auth.RequireAdmin, 401)\n\n\t// Authorization Bearer clienttoken\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer clienttoken\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer clienttoken\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"Bearer clienttoken\", s.auth.RequireAdmin, 403)\n\ts.assertHeaderRequest(\"Authorization\", \"bearer clienttoken_admin\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"bearer clienttoken_admin\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"bearer clienttoken_admin\", s.auth.RequireAdmin, 200)\n}\n\nfunc (s *AuthenticationSuite) TestBasicAuth() {\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ergerogerg\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ergerogerg\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ergerogerg\", s.auth.RequireAdmin, 401)\n\n\t// user existing:pw\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ZXhpc3Rpbmc6cHc=\", s.auth.RequireApplicationToken, 403)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ZXhpc3Rpbmc6cHc=\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic ZXhpc3Rpbmc6cHc=\", s.auth.RequireAdmin, 403)\n\n\t// user admin:pw\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHc=\", s.auth.RequireApplicationToken, 403)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHc=\", s.auth.RequireClient, 200)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHc=\", s.auth.RequireAdmin, 200)\n\n\t// user admin:pwx\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHd4\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHd4\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHd4\", s.auth.RequireAdmin, 401)\n\n\t// user notexisting:pw\n\ts.assertHeaderRequest(\"Authorization\", \"Basic bm90ZXhpc3Rpbmc6cHc=\", s.auth.RequireApplicationToken, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic bm90ZXhpc3Rpbmc6cHc=\", s.auth.RequireClient, 401)\n\ts.assertHeaderRequest(\"Authorization\", \"Basic bm90ZXhpc3Rpbmc6cHc=\", s.auth.RequireAdmin, 401)\n}\n\nfunc (s *AuthenticationSuite) TestOptionalAuth() {\n\t// various invalid users\n\tctx := s.assertQueryRequest(\"token\", \"ergerogerg\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\tctx = s.assertHeaderRequest(\"X-Gotify-Key\", \"ergerogerg\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\tctx = s.assertHeaderRequest(\"Authorization\", \"Basic bm90ZXhpc3Rpbmc6cHc=\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\tctx = s.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHd4\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\tctx = s.assertQueryRequest(\"tokenx\", \"clienttoken\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\tctx = s.assertQueryRequest(\"token\", \"apptoken_admin\", s.auth.Optional, 200)\n\tassert.Nil(s.T(), TryGetUserID(ctx))\n\n\t// user existing:pw\n\tctx = s.assertHeaderRequest(\"Authorization\", \"Basic ZXhpc3Rpbmc6cHc=\", s.auth.Optional, 200)\n\tassert.Equal(s.T(), uint(1), *TryGetUserID(ctx))\n\tctx = s.assertQueryRequest(\"token\", \"clienttoken\", s.auth.Optional, 200)\n\tassert.Equal(s.T(), uint(1), *TryGetUserID(ctx))\n\n\t// user admin:pw\n\tctx = s.assertHeaderRequest(\"Authorization\", \"Basic YWRtaW46cHc=\", s.auth.Optional, 200)\n\tassert.Equal(s.T(), uint(2), *TryGetUserID(ctx))\n\tctx = s.assertQueryRequest(\"token\", \"clienttoken_admin\", s.auth.Optional, 200)\n\tassert.Equal(s.T(), uint(2), *TryGetUserID(ctx))\n}\n\nfunc (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {\n\trecorder := httptest.NewRecorder()\n\tctx, _ = gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(\"GET\", \"/\", nil)\n\tctx.Request.Header.Set(key, value)\n\tf()(ctx)\n\tassert.Equal(s.T(), code, recorder.Code)\n\treturn ctx\n}\n\ntype fMiddleware func() gin.HandlerFunc\n"
  },
  {
    "path": "auth/cors.go",
    "content": "package auth\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gotify/server/v2/config\"\n\t\"github.com/gotify/server/v2/mode\"\n)\n\n// CorsConfig generates a config to use in gin cors middleware based on server configuration.\nfunc CorsConfig(conf *config.Configuration) cors.Config {\n\tcorsConf := cors.Config{\n\t\tMaxAge:                 12 * time.Hour,\n\t\tAllowBrowserExtensions: true,\n\t}\n\tif mode.IsDev() {\n\t\tcorsConf.AllowAllOrigins = true\n\t\tcorsConf.AllowMethods = []string{\"GET\", \"POST\", \"DELETE\", \"OPTIONS\", \"PUT\"}\n\t\tcorsConf.AllowHeaders = []string{\n\t\t\t\"X-Gotify-Key\", \"Authorization\", \"Content-Type\", \"Upgrade\", \"Origin\",\n\t\t\t\"Connection\", \"Accept-Encoding\", \"Accept-Language\", \"Host\",\n\t\t}\n\t} else {\n\t\tcompiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins)\n\t\tcorsConf.AllowMethods = conf.Server.Cors.AllowMethods\n\t\tcorsConf.AllowHeaders = conf.Server.Cors.AllowHeaders\n\t\tcorsConf.AllowOriginFunc = func(origin string) bool {\n\t\t\tfor _, compiledOrigin := range compiledOrigins {\n\t\t\t\tif compiledOrigin.MatchString(strings.ToLower(origin)) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t\tif allowedOrigin := headerIgnoreCase(conf, \"access-control-allow-origin\"); allowedOrigin != \"\" && len(compiledOrigins) == 0 {\n\t\t\tcorsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin)\n\t\t}\n\t}\n\n\treturn corsConf\n}\n\nfunc headerIgnoreCase(conf *config.Configuration, search string) (value string) {\n\tfor key, value := range conf.Server.ResponseHeaders {\n\t\tif strings.ToLower(key) == search {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc compileAllowedCORSOrigins(allowedOrigins []string) []*regexp.Regexp {\n\tvar compiledAllowedOrigins []*regexp.Regexp\n\tfor _, origin := range allowedOrigins {\n\t\tcompiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin))\n\t}\n\n\treturn compiledAllowedOrigins\n}\n"
  },
  {
    "path": "auth/cors_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gotify/server/v2/config\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestCorsConfig(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tserverConf := config.Configuration{}\n\tserverConf.Server.Cors.AllowOrigins = []string{\"http://test.com\"}\n\tserverConf.Server.Cors.AllowHeaders = []string{\"content-type\"}\n\tserverConf.Server.Cors.AllowMethods = []string{\"GET\"}\n\n\tactual := CorsConfig(&serverConf)\n\tallowF := actual.AllowOriginFunc\n\tactual.AllowOriginFunc = nil // func cannot be checked with equal\n\n\tassert.Equal(t, cors.Config{\n\t\tAllowAllOrigins:        false,\n\t\tAllowHeaders:           []string{\"content-type\"},\n\t\tAllowMethods:           []string{\"GET\"},\n\t\tMaxAge:                 12 * time.Hour,\n\t\tAllowBrowserExtensions: true,\n\t}, actual)\n\tassert.NotNil(t, allowF)\n\tassert.True(t, allowF(\"http://test.com\"))\n\tassert.False(t, allowF(\"https://test.com\"))\n\tassert.False(t, allowF(\"https://other.com\"))\n}\n\nfunc TestEmptyCorsConfigWithResponseHeaders(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tserverConf := config.Configuration{}\n\tserverConf.Server.ResponseHeaders = map[string]string{\"Access-control-allow-origin\": \"https://example.com\"}\n\n\tactual := CorsConfig(&serverConf)\n\tassert.NotNil(t, actual.AllowOriginFunc)\n\tactual.AllowOriginFunc = nil // func cannot be checked with equal\n\n\tassert.Equal(t, cors.Config{\n\t\tAllowAllOrigins:        false,\n\t\tAllowOrigins:           []string{\"https://example.com\"},\n\t\tMaxAge:                 12 * time.Hour,\n\t\tAllowBrowserExtensions: true,\n\t}, actual)\n}\n\nfunc TestDevCorsConfig(t *testing.T) {\n\tmode.Set(mode.Dev)\n\tserverConf := config.Configuration{}\n\tserverConf.Server.Cors.AllowOrigins = []string{\"http://test.com\"}\n\tserverConf.Server.Cors.AllowHeaders = []string{\"content-type\"}\n\tserverConf.Server.Cors.AllowMethods = []string{\"GET\"}\n\n\tactual := CorsConfig(&serverConf)\n\n\tassert.Equal(t, cors.Config{\n\t\tAllowHeaders: []string{\n\t\t\t\"X-Gotify-Key\", \"Authorization\", \"Content-Type\", \"Upgrade\", \"Origin\",\n\t\t\t\"Connection\", \"Accept-Encoding\", \"Accept-Language\", \"Host\",\n\t\t},\n\t\tAllowMethods:           []string{\"GET\", \"POST\", \"DELETE\", \"OPTIONS\", \"PUT\"},\n\t\tMaxAge:                 12 * time.Hour,\n\t\tAllowAllOrigins:        true,\n\t\tAllowBrowserExtensions: true,\n\t}, actual)\n}\n"
  },
  {
    "path": "auth/password/password.go",
    "content": "package password\n\nimport \"golang.org/x/crypto/bcrypt\"\n\n// CreatePassword returns a hashed version of the given password.\nfunc CreatePassword(pw string, strength int) []byte {\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), strength)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn hashedPassword\n}\n\n// ComparePassword compares a hashed password with its possible plaintext equivalent.\nfunc ComparePassword(hashedPassword, password []byte) bool {\n\treturn bcrypt.CompareHashAndPassword(hashedPassword, password) == nil\n}\n"
  },
  {
    "path": "auth/password/password_test.go",
    "content": "package password\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestPasswordSuccess(t *testing.T) {\n\tpassword := CreatePassword(\"secret\", 5)\n\tassert.Equal(t, true, ComparePassword(password, []byte(\"secret\")))\n}\n\nfunc TestPasswordFailure(t *testing.T) {\n\tpassword := CreatePassword(\"secret\", 5)\n\tassert.Equal(t, false, ComparePassword(password, []byte(\"secretx\")))\n}\n\nfunc TestBCryptFailure(t *testing.T) {\n\tassert.Panics(t, func() { CreatePassword(\"secret\", 12312) })\n}\n"
  },
  {
    "path": "auth/token.go",
    "content": "package auth\n\nimport (\n\t\"crypto/rand\"\n\t\"math/big\"\n)\n\nvar (\n\ttokenCharacters   = []byte(\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_\")\n\trandomTokenLength = 14\n\tapplicationPrefix = \"A\"\n\tclientPrefix      = \"C\"\n\tpluginPrefix      = \"P\"\n\n\trandReader = rand.Reader\n)\n\nfunc randIntn(n int) int {\n\tmax := big.NewInt(int64(n))\n\tres, err := rand.Int(randReader, max)\n\tif err != nil {\n\t\tpanic(\"random source is not available\")\n\t}\n\treturn int(res.Int64())\n}\n\n// GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token.\nfunc GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string {\n\tfor {\n\t\ttoken := generateToken()\n\t\tif !tokenExists(token) {\n\t\t\treturn token\n\t\t}\n\t}\n}\n\n// GenerateApplicationToken generates an application token.\nfunc GenerateApplicationToken() string {\n\treturn generateRandomToken(applicationPrefix)\n}\n\n// GenerateClientToken generates a client token.\nfunc GenerateClientToken() string {\n\treturn generateRandomToken(clientPrefix)\n}\n\n// GeneratePluginToken generates a plugin token.\nfunc GeneratePluginToken() string {\n\treturn generateRandomToken(pluginPrefix)\n}\n\n// GenerateImageName generates an image name.\nfunc GenerateImageName() string {\n\treturn generateRandomString(25)\n}\n\nfunc generateRandomToken(prefix string) string {\n\treturn prefix + generateRandomString(randomTokenLength)\n}\n\nfunc generateRandomString(length int) string {\n\tres := make([]byte, length)\n\tfor i := range res {\n\t\tindex := randIntn(len(tokenCharacters))\n\t\tres[i] = tokenCharacters[index]\n\t}\n\treturn string(res)\n}\n\nfunc init() {\n\trandIntn(2)\n}\n"
  },
  {
    "path": "auth/token_test.go",
    "content": "package auth\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTokenHavePrefix(t *testing.T) {\n\tfor i := 0; i < 50; i++ {\n\t\tassert.True(t, strings.HasPrefix(GenerateApplicationToken(), \"A\"))\n\t\tassert.True(t, strings.HasPrefix(GenerateClientToken(), \"C\"))\n\t\tassert.True(t, strings.HasPrefix(GeneratePluginToken(), \"P\"))\n\t\tassert.NotEmpty(t, GenerateImageName())\n\t}\n}\n\nfunc TestGenerateNotExistingToken(t *testing.T) {\n\tcount := 5\n\ttoken := GenerateNotExistingToken(func() string {\n\t\treturn fmt.Sprint(count)\n\t}, func(token string) bool {\n\t\tcount--\n\t\treturn token != \"0\"\n\t})\n\tassert.Equal(t, \"0\", token)\n}\n\nfunc TestBadCryptoReaderPanics(t *testing.T) {\n\tassert.Panics(t, func() {\n\t\trandReader = test.UnreadableReader()\n\t\tdefer func() {\n\t\t\trandReader = rand.Reader\n\t\t}()\n\t\trandIntn(2)\n\t})\n}\n"
  },
  {
    "path": "auth/util.go",
    "content": "package auth\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// RegisterAuthentication registers the user id, user and or token.\nfunc RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tokenID string) {\n\tctx.Set(\"user\", user)\n\tctx.Set(\"userid\", userID)\n\tctx.Set(\"tokenid\", tokenID)\n}\n\n// GetUserID returns the user id which was previously registered by RegisterAuthentication.\nfunc GetUserID(ctx *gin.Context) uint {\n\tid := TryGetUserID(ctx)\n\tif id == nil {\n\t\tpanic(\"token and user may not be null\")\n\t}\n\treturn *id\n}\n\n// TryGetUserID returns the user id or nil if one is not set.\nfunc TryGetUserID(ctx *gin.Context) *uint {\n\tuser := ctx.MustGet(\"user\").(*model.User)\n\tif user == nil {\n\t\tuserID := ctx.MustGet(\"userid\").(uint)\n\t\tif userID == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn &userID\n\t}\n\n\treturn &user.ID\n}\n\n// GetTokenID returns the tokenID.\nfunc GetTokenID(ctx *gin.Context) string {\n\treturn ctx.MustGet(\"tokenid\").(string)\n}\n"
  },
  {
    "path": "auth/util_test.go",
    "content": "package auth\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc TestUtilSuite(t *testing.T) {\n\tsuite.Run(t, new(UtilSuite))\n}\n\ntype UtilSuite struct {\n\tsuite.Suite\n}\n\nfunc (s *UtilSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n}\n\nfunc (s *UtilSuite) Test_getID() {\n\ts.expectUserIDWith(&model.User{ID: 2}, 0, 2)\n\ts.expectUserIDWith(nil, 5, 5)\n\tassert.Panics(s.T(), func() {\n\t\ts.expectUserIDWith(nil, 0, 0)\n\t})\n\ts.expectTryUserIDWith(nil, 0, nil)\n}\n\nfunc (s *UtilSuite) Test_getToken() {\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tRegisterAuthentication(ctx, nil, 1, \"asdasda\")\n\tactualID := GetTokenID(ctx)\n\tassert.Equal(s.T(), \"asdasda\", actualID)\n}\n\nfunc (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) {\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tRegisterAuthentication(ctx, user, tokenUserID, \"\")\n\tactualID := GetUserID(ctx)\n\tassert.Equal(s.T(), expectedID, actualID)\n}\n\nfunc (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) {\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tRegisterAuthentication(ctx, user, tokenUserID, \"\")\n\tactualID := TryGetUserID(ctx)\n\tassert.Equal(s.T(), expectedID, actualID)\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/jinzhu/configor\"\n)\n\n// Configuration is stuff that can be configured externally per env variables or config file (config.yml).\ntype Configuration struct {\n\tServer struct {\n\t\tKeepAlivePeriodSeconds int\n\t\tListenAddr             string `default:\"\"`\n\t\tPort                   int    `default:\"80\"`\n\n\t\tSSL struct {\n\t\t\tEnabled         bool   `default:\"false\"`\n\t\t\tRedirectToHTTPS bool   `default:\"true\"`\n\t\t\tListenAddr      string `default:\"\"`\n\t\t\tPort            int    `default:\"443\"`\n\t\t\tCertFile        string `default:\"\"`\n\t\t\tCertKey         string `default:\"\"`\n\t\t\tLetsEncrypt     struct {\n\t\t\t\tEnabled      bool   `default:\"false\"`\n\t\t\t\tAcceptTOS    bool   `default:\"false\"`\n\t\t\t\tCache        string `default:\"data/certs\"`\n\t\t\t\tDirectoryURL string `default:\"\"`\n\t\t\t\tHosts        []string\n\t\t\t}\n\t\t}\n\t\tResponseHeaders map[string]string\n\t\tStream          struct {\n\t\t\tPingPeriodSeconds int `default:\"45\"`\n\t\t\tAllowedOrigins    []string\n\t\t}\n\t\tCors struct {\n\t\t\tAllowOrigins []string\n\t\t\tAllowMethods []string\n\t\t\tAllowHeaders []string\n\t\t}\n\n\t\tTrustedProxies []string\n\t}\n\tDatabase struct {\n\t\tDialect    string `default:\"sqlite3\"`\n\t\tConnection string `default:\"data/gotify.db\"`\n\t}\n\tDefaultUser struct {\n\t\tName string `default:\"admin\"`\n\t\tPass string `default:\"admin\"`\n\t}\n\tPassStrength      int    `default:\"10\"`\n\tUploadedImagesDir string `default:\"data/images\"`\n\tPluginsDir        string `default:\"data/plugins\"`\n\tRegistration      bool   `default:\"false\"`\n}\n\nfunc configFiles() []string {\n\tif mode.Get() == mode.TestDev {\n\t\treturn []string{\"config.yml\"}\n\t}\n\treturn []string{\"config.yml\", \"/etc/gotify/config.yml\"}\n}\n\n// Get returns the configuration extracted from env variables or config file.\nfunc Get() *Configuration {\n\tconf := new(Configuration)\n\terr := configor.New(&configor.Config{ENVPrefix: \"GOTIFY\", Silent: true}).Load(conf, configFiles()...)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\taddTrailingSlashToPaths(conf)\n\treturn conf\n}\n\nfunc addTrailingSlashToPaths(conf *Configuration) {\n\tif !strings.HasSuffix(conf.UploadedImagesDir, \"/\") && !strings.HasSuffix(conf.UploadedImagesDir, \"\\\\\") {\n\t\tconf.UploadedImagesDir += string(filepath.Separator)\n\t}\n}\n"
  },
  {
    "path": "config/config_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestConfigEnv(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\tos.Setenv(\"GOTIFY_DEFAULTUSER_NAME\", \"jmattheis\")\n\tos.Setenv(\"GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS\", \"- push.example.tld\\n- push.other.tld\")\n\tos.Setenv(\"GOTIFY_SERVER_RESPONSEHEADERS\",\n\t\t\"Access-Control-Allow-Origin: \\\"*\\\"\\nAccess-Control-Allow-Methods: \\\"GET,POST\\\"\",\n\t)\n\tos.Setenv(\"GOTIFY_SERVER_CORS_ALLOWORIGINS\", \"- \\\".+.example.com\\\"\\n- \\\"otherdomain.com\\\"\")\n\tos.Setenv(\"GOTIFY_SERVER_CORS_ALLOWMETHODS\", \"- \\\"GET\\\"\\n- \\\"POST\\\"\")\n\tos.Setenv(\"GOTIFY_SERVER_CORS_ALLOWHEADERS\", \"- \\\"Authorization\\\"\\n- \\\"content-type\\\"\")\n\tos.Setenv(\"GOTIFY_SERVER_STREAM_ALLOWEDORIGINS\", \"- \\\".+.example.com\\\"\\n- \\\"otherdomain.com\\\"\")\n\n\tconf := Get()\n\tassert.Equal(t, 80, conf.Server.Port, \"should use defaults\")\n\tassert.Equal(t, \"jmattheis\", conf.DefaultUser.Name, \"should not use default but env var\")\n\tassert.Equal(t, []string{\"push.example.tld\", \"push.other.tld\"}, conf.Server.SSL.LetsEncrypt.Hosts)\n\tassert.Equal(t, \"*\", conf.Server.ResponseHeaders[\"Access-Control-Allow-Origin\"])\n\tassert.Equal(t, \"GET,POST\", conf.Server.ResponseHeaders[\"Access-Control-Allow-Methods\"])\n\tassert.Equal(t, []string{\".+.example.com\", \"otherdomain.com\"}, conf.Server.Cors.AllowOrigins)\n\tassert.Equal(t, []string{\"GET\", \"POST\"}, conf.Server.Cors.AllowMethods)\n\tassert.Equal(t, []string{\"Authorization\", \"content-type\"}, conf.Server.Cors.AllowHeaders)\n\tassert.Equal(t, []string{\".+.example.com\", \"otherdomain.com\"}, conf.Server.Stream.AllowedOrigins)\n\n\tos.Unsetenv(\"GOTIFY_DEFAULTUSER_NAME\")\n\tos.Unsetenv(\"GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS\")\n\tos.Unsetenv(\"GOTIFY_SERVER_RESPONSEHEADERS\")\n\tos.Unsetenv(\"GOTIFY_SERVER_CORS_ALLOWORIGINS\")\n\tos.Unsetenv(\"GOTIFY_SERVER_CORS_ALLOWMETHODS\")\n\tos.Unsetenv(\"GOTIFY_SERVER_CORS_ALLOWHEADERS\")\n\tos.Unsetenv(\"GOTIFY_SERVER_STREAM_ALLOWEDORIGINS\")\n}\n\nfunc TestAddSlash(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\tos.Setenv(\"GOTIFY_UPLOADEDIMAGESDIR\", \"../data/images\")\n\tconf := Get()\n\tassert.Equal(t, \"../data/images\"+string(filepath.Separator), conf.UploadedImagesDir)\n\tos.Unsetenv(\"GOTIFY_UPLOADEDIMAGESDIR\")\n}\n\nfunc TestNotAddSlash(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\tos.Setenv(\"GOTIFY_UPLOADEDIMAGESDIR\", \"../data/\")\n\tconf := Get()\n\tassert.Equal(t, \"../data/\", conf.UploadedImagesDir)\n\tos.Unsetenv(\"GOTIFY_UPLOADEDIMAGESDIR\")\n}\n\nfunc TestFileWithSyntaxErrors(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\tfile, err := os.Create(\"config.yml\")\n\tdefer func() {\n\t\tfile.Close()\n\t}()\n\tassert.Nil(t, err)\n\t_, err = file.WriteString(`\nsdgsgsdfgsdfg\n`)\n\tfile.Close()\n\tassert.Nil(t, err)\n\tassert.Panics(t, func() {\n\t\tGet()\n\t})\n\n\tassert.Nil(t, os.Remove(\"config.yml\"))\n}\n\nfunc TestConfigFile(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\tfile, err := os.Create(\"config.yml\")\n\tdefer func() {\n\t\tfile.Close()\n\t}()\n\tassert.Nil(t, err)\n\t_, err = file.WriteString(`\nserver:\n  port: 1234\n  ssl:\n    port: 3333\n    letsencrypt:\n      hosts:\n        - push.example.tld\n  responseheaders:\n    Access-Control-Allow-Origin: \"*\"\n    Access-Control-Allow-Methods: \"GET,POST\"\n  cors:\n    alloworigins:\n      - \".*\"\n      - \".+\"\n    allowmethods:\n      - \"GET\"\n      - \"POST\"\n    allowheaders:\n      - \"Authorization\"\n      - \"content-type\"\n  stream:\n    allowedorigins:\n      - \".+.example.com\"\n      - \"otherdomain.com\"\ndatabase:\n  dialect: mysql\n  connection: user name\ndefaultuser:\n  name: nicories\n  pass: 12345\npluginsdir: data/plugins\n`)\n\tfile.Close()\n\tassert.Nil(t, err)\n\tconf := Get()\n\tassert.Equal(t, 1234, conf.Server.Port)\n\tassert.Equal(t, 3333, conf.Server.SSL.Port)\n\tassert.Equal(t, []string{\"push.example.tld\"}, conf.Server.SSL.LetsEncrypt.Hosts)\n\tassert.Equal(t, \"nicories\", conf.DefaultUser.Name)\n\tassert.Equal(t, \"12345\", conf.DefaultUser.Pass)\n\tassert.Equal(t, \"mysql\", conf.Database.Dialect)\n\tassert.Equal(t, \"user name\", conf.Database.Connection)\n\tassert.Equal(t, \"*\", conf.Server.ResponseHeaders[\"Access-Control-Allow-Origin\"])\n\tassert.Equal(t, \"GET,POST\", conf.Server.ResponseHeaders[\"Access-Control-Allow-Methods\"])\n\tassert.Equal(t, []string{\".*\", \".+\"}, conf.Server.Cors.AllowOrigins)\n\tassert.Equal(t, []string{\"GET\", \"POST\"}, conf.Server.Cors.AllowMethods)\n\tassert.Equal(t, []string{\"Authorization\", \"content-type\"}, conf.Server.Cors.AllowHeaders)\n\tassert.Equal(t, []string{\".+.example.com\", \"otherdomain.com\"}, conf.Server.Stream.AllowedOrigins)\n\tassert.Equal(t, \"data/plugins\", conf.PluginsDir)\n\n\tassert.Nil(t, os.Remove(\"config.yml\"))\n}\n"
  },
  {
    "path": "config.example.yml",
    "content": "# Example configuration file for the server.\n# Save it to `config.yml` when edited\n\nserver:\n  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.\n  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\".\n  port: 80 # the port the HTTP server will listen on\n\n  ssl:\n    enabled: false # if https should be enabled\n    redirecttohttps: true # redirect to https if site is accessed by http\n    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\".\n    port: 443 # the https port\n    certfile: # the cert file (leave empty when using letsencrypt)\n    certkey: # the cert key (leave empty when using letsencrypt)\n    letsencrypt:\n      enabled: false # if the certificate should be requested from letsencrypt\n      accepttos: false # if you accept the tos from letsencrypt\n      cache: data/certs # the directory of the cache from letsencrypt\n      directoryurl: # override the directory url of the ACME server\n                    # Let's Encrypt highly recommend testing against their staging environment before using their production environment.\n                    # Staging server has high rate limits for testing and debugging, issued certificates are not valid\n                    # example: https://acme-staging-v02.api.letsencrypt.org/directory\n      hosts: # the hosts for which letsencrypt should request certificates\n#      - mydomain.tld\n#      - myotherdomain.tld\n\n  responseheaders: # response headers are added to every response (default: none)\n#    X-Custom-Header: \"custom value\"\n#\n  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)\n#   - 127.0.0.1/32\n#   - ::1\n\n  cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers.\n    alloworigins:\n#      - \".+.example.com\"\n#      - \"otherdomain.com\"\n    allowmethods:\n#      - \"GET\"\n#      - \"POST\"\n    allowheaders:\n#      - \"Authorization\"\n#      - \"content-type\"\n  stream:\n    pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing.\n    allowedorigins: # allowed origins for websocket connections (same origin is always allowed)\n#      - \".+.example.com\"\n#      - \"otherdomain.com\"\n\ndatabase: # for database see (configure database section)\n  dialect: sqlite3\n  connection: data/gotify.db\n\ndefaultuser: # on database creation, gotify creates an admin user\n  name: admin # the username of the default user\n  pass: admin # the password of the default user\npassstrength: 10 # the bcrypt password strength (higher = better but also slower)\nuploadedimagesdir: data/images # the directory for storing uploaded images\npluginsdir: data/plugins # the directory where plugin resides\nregistration: false # enable registrations\n"
  },
  {
    "path": "database/application.go",
    "content": "package database\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/fracdex\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetApplicationByToken returns the application for the given token or nil.\nfunc (d *GormDatabase) GetApplicationByToken(token string) (*model.Application, error) {\n\tapp := new(model.Application)\n\terr := d.DB.Where(\"token = ?\", token).Find(app).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif app.Token == token {\n\t\treturn app, err\n\t}\n\treturn nil, err\n}\n\n// GetApplicationByID returns the application for the given id or nil.\nfunc (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) {\n\tapp := new(model.Application)\n\terr := d.DB.Where(\"id = ?\", id).Find(app).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif app.ID == id {\n\t\treturn app, err\n\t}\n\treturn nil, err\n}\n\n// CreateApplication creates an application.\nfunc (d *GormDatabase) CreateApplication(application *model.Application) error {\n\treturn d.DB.Transaction(func(tx *gorm.DB) error {\n\t\tif application.SortKey == \"\" {\n\t\t\tsortKey := \"\"\n\t\t\terr := tx.Model(&model.Application{}).Select(\"sort_key\").Where(\"user_id = ?\", application.UserID).Order(\"sort_key DESC\").Limit(1).Find(&sortKey).Error\n\t\t\tif err != nil && err != gorm.ErrRecordNotFound {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tapplication.SortKey, err = fracdex.KeyBetween(sortKey, \"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn tx.Create(application).Error\n\t}, &sql.TxOptions{Isolation: sql.LevelSerializable})\n}\n\n// DeleteApplicationByID deletes an application by its id.\nfunc (d *GormDatabase) DeleteApplicationByID(id uint) error {\n\td.DeleteMessagesByApplication(id)\n\treturn d.DB.Where(\"id = ?\", id).Delete(&model.Application{}).Error\n}\n\n// GetApplicationsByUser returns all applications from a user.\nfunc (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) {\n\tvar apps []*model.Application\n\terr := d.DB.Where(\"user_id = ?\", userID).Order(\"sort_key, id ASC\").Find(&apps).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn apps, err\n}\n\n// UpdateApplication updates an application.\nfunc (d *GormDatabase) UpdateApplication(app *model.Application) error {\n\treturn d.DB.Save(app).Error\n}\n\n// UpdateApplicationTokenLastUsed updates the last used time of the application token.\nfunc (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error {\n\treturn d.DB.Model(&model.Application{}).Where(\"token = ?\", token).Update(\"last_used\", t).Error\n}\n"
  },
  {
    "path": "database/application_test.go",
    "content": "package database\n\nimport (\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc (s *DatabaseSuite) TestApplication() {\n\tif app, err := s.db.GetApplicationByToken(\"asdasdf\"); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), app, \"not existing app\")\n\t}\n\n\tif app, err := s.db.GetApplicationByID(uint(1)); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), app, \"not existing app\")\n\t}\n\n\tuser := &model.User{Name: \"test\", Pass: []byte{1}}\n\ts.db.CreateUser(user)\n\tassert.NotEqual(s.T(), 0, user.ID)\n\n\tif apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), apps)\n\t}\n\n\tapp := &model.Application{UserID: user.ID, Token: \"C0000000000\", Name: \"backupserver\"}\n\ts.db.CreateApplication(app)\n\n\tif apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Len(s.T(), apps, 1)\n\t\tassert.Contains(s.T(), apps, app)\n\t}\n\n\tnewApp, err := s.db.GetApplicationByToken(app.Token)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), app, newApp)\n\t}\n\n\tnewApp, err = s.db.GetApplicationByID(app.ID)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), app, newApp)\n\t}\n\n\tlastUsed := time.Now().Add(-time.Hour)\n\ts.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed)\n\tnewApp, err = s.db.GetApplicationByID(app.ID)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix())\n\t}\n\tapp.LastUsed = &lastUsed\n\n\tnewApp.Image = \"asdasd\"\n\tassert.NoError(s.T(), s.db.UpdateApplication(newApp))\n\n\tnewApp, err = s.db.GetApplicationByID(app.ID)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), \"asdasd\", newApp.Image)\n\t}\n\n\tassert.NoError(s.T(), s.db.DeleteApplicationByID(app.ID))\n\n\tif apps, err := s.db.GetApplicationsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), apps)\n\t}\n\n\tif app, err := s.db.GetApplicationByID(app.ID); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), app)\n\t}\n}\n\nfunc (s *DatabaseSuite) TestDeleteAppDeletesMessages() {\n\tassert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 55, Token: \"token\"}))\n\tassert.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 66, Token: \"token2\"}))\n\tassert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 12, ApplicationID: 55}))\n\tassert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 13, ApplicationID: 66}))\n\tassert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 14, ApplicationID: 55}))\n\tassert.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 15, ApplicationID: 55}))\n\n\tassert.NoError(s.T(), s.db.DeleteApplicationByID(55))\n\n\tif msg, err := s.db.GetMessageByID(12); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), msg)\n\t}\n\tif msg, err := s.db.GetMessageByID(13); assert.NoError(s.T(), err) {\n\t\tassert.NotNil(s.T(), msg)\n\t}\n\tif msg, err := s.db.GetMessageByID(14); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), msg)\n\t}\n\tif msg, err := s.db.GetMessageByID(15); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), msg)\n\t}\n\n\tif msgs, err := s.db.GetMessagesByApplication(55); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), msgs)\n\t}\n\tif msgs, err := s.db.GetMessagesByApplication(66); assert.NoError(s.T(), err) {\n\t\tassert.NotEmpty(s.T(), msgs)\n\t}\n}\n"
  },
  {
    "path": "database/client.go",
    "content": "package database\n\nimport (\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetClientByID returns the client for the given id or nil.\nfunc (d *GormDatabase) GetClientByID(id uint) (*model.Client, error) {\n\tclient := new(model.Client)\n\terr := d.DB.Where(\"id = ?\", id).Find(client).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif client.ID == id {\n\t\treturn client, err\n\t}\n\treturn nil, err\n}\n\n// GetClientByToken returns the client for the given token or nil.\nfunc (d *GormDatabase) GetClientByToken(token string) (*model.Client, error) {\n\tclient := new(model.Client)\n\terr := d.DB.Where(\"token = ?\", token).Find(client).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif client.Token == token {\n\t\treturn client, err\n\t}\n\treturn nil, err\n}\n\n// CreateClient creates a client.\nfunc (d *GormDatabase) CreateClient(client *model.Client) error {\n\treturn d.DB.Create(client).Error\n}\n\n// GetClientsByUser returns all clients from a user.\nfunc (d *GormDatabase) GetClientsByUser(userID uint) ([]*model.Client, error) {\n\tvar clients []*model.Client\n\terr := d.DB.Where(\"user_id = ?\", userID).Find(&clients).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn clients, err\n}\n\n// DeleteClientByID deletes a client by its id.\nfunc (d *GormDatabase) DeleteClientByID(id uint) error {\n\treturn d.DB.Where(\"id = ?\", id).Delete(&model.Client{}).Error\n}\n\n// UpdateClient updates a client.\nfunc (d *GormDatabase) UpdateClient(client *model.Client) error {\n\treturn d.DB.Save(client).Error\n}\n\n// UpdateClientTokensLastUsed updates the last used timestamp of clients.\nfunc (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error {\n\treturn d.DB.Model(&model.Client{}).Where(\"token IN (?)\", tokens).Update(\"last_used\", t).Error\n}\n"
  },
  {
    "path": "database/client_test.go",
    "content": "package database\n\nimport (\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc (s *DatabaseSuite) TestClient() {\n\tif client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), client, \"not existing client\")\n\t}\n\tif client, err := s.db.GetClientByToken(\"asdasd\"); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), client, \"not existing client\")\n\t}\n\n\tuser := &model.User{Name: \"test\", Pass: []byte{1}}\n\ts.db.CreateUser(user)\n\tassert.NotEqual(s.T(), 0, user.ID)\n\n\tif clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), clients)\n\t}\n\n\tclient := &model.Client{UserID: user.ID, Token: \"C0000000000\", Name: \"android\"}\n\tassert.NoError(s.T(), s.db.CreateClient(client))\n\n\tif clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Len(s.T(), clients, 1)\n\t\tassert.Contains(s.T(), clients, client)\n\t}\n\n\tnewClient, err := s.db.GetClientByID(client.ID)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), client, newClient)\n\t}\n\n\tif newClient, err := s.db.GetClientByToken(client.Token); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), client, newClient)\n\t}\n\n\tupdateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: \"C0000000000\", Name: \"new_name\"}\n\ts.db.UpdateClient(updateClient)\n\tif updatedClient, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), updateClient, updatedClient)\n\t}\n\n\tlastUsed := time.Now().Add(-time.Hour)\n\ts.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed)\n\tnewClient, err = s.db.GetClientByID(client.ID)\n\tif assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix())\n\t}\n\n\ts.db.DeleteClientByID(client.ID)\n\n\tif clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {\n\t\tassert.Empty(s.T(), clients)\n\t}\n\n\tif client, err := s.db.GetClientByID(client.ID); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), client)\n\t}\n}\n"
  },
  {
    "path": "database/database.go",
    "content": "package database\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/auth/password\"\n\t\"github.com/gotify/server/v2/fracdex\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/mattn/go-isatty\"\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nvar mkdirAll = os.MkdirAll\n\n// New creates a new wrapper for the gorm database framework.\nfunc New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) {\n\tcreateDirectoryIfSqlite(dialect, connection)\n\n\tdbLogger := logger.New(log.New(os.Stderr, \"\\r\\n\", log.LstdFlags), logger.Config{\n\t\tSlowThreshold:             200 * time.Millisecond,\n\t\tLogLevel:                  logger.Warn,\n\t\tIgnoreRecordNotFoundError: true,\n\t\tColorful:                  isatty.IsTerminal(os.Stderr.Fd()),\n\t})\n\tgormConfig := &gorm.Config{\n\t\tLogger:                                   dbLogger,\n\t\tDisableForeignKeyConstraintWhenMigrating: true,\n\t\tTranslateError:                           true,\n\t}\n\n\tvar db *gorm.DB\n\terr := errors.New(\"unsupported dialect: \" + dialect)\n\n\tswitch dialect {\n\tcase \"mysql\":\n\t\tdb, err = gorm.Open(mysql.Open(connection), gormConfig)\n\tcase \"postgres\":\n\t\tdb, err = gorm.Open(postgres.Open(connection), gormConfig)\n\tcase \"sqlite3\":\n\t\tdb, err = gorm.Open(sqlite.Open(connection), gormConfig)\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqldb, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// We normally don't need that much connections, so we limit them. F.ex. mysql complains about\n\t// \"too many connections\", while load testing Gotify.\n\tsqldb.SetMaxOpenConns(10)\n\n\tif dialect == \"sqlite3\" {\n\t\t// We use the database connection inside the handlers from the http\n\t\t// framework, therefore concurrent access occurs. Sqlite cannot handle\n\t\t// concurrent writes, so we limit sqlite to one connection.\n\t\t// see https://github.com/mattn/go-sqlite3/issues/274\n\t\tsqldb.SetMaxOpenConns(1)\n\t}\n\n\tif dialect == \"mysql\" {\n\t\t// Mysql has a setting called wait_timeout, which defines the duration\n\t\t// after which a connection may not be used anymore.\n\t\t// The default for this setting on mariadb is 10 minutes.\n\t\t// See https://github.com/docker-library/mariadb/issues/113\n\t\tsqldb.SetConnMaxLifetime(9 * time.Minute)\n\t}\n\n\tif err := db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf)); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserCount := int64(0)\n\tdb.Find(new(model.User)).Count(&userCount)\n\tif createDefaultUserIfNotExist && userCount == 0 {\n\t\tdb.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true})\n\t}\n\n\tif err := db.Transaction(fillMissingSortKeys, &sql.TxOptions{Isolation: sql.LevelSerializable}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &GormDatabase{DB: db}, nil\n}\n\nfunc fillMissingSortKeys(db *gorm.DB) error {\n\tmissingSort := int64(0)\n\tif err := db.Model(new(model.Application)).Where(\"sort_key IS NULL OR sort_key = ''\").Count(&missingSort).Error; err != nil {\n\t\treturn err\n\t}\n\n\tif missingSort == 0 {\n\t\treturn nil\n\t}\n\n\tvar apps []*model.Application\n\tif err := db.Order(\"user_id, sort_key, id ASC\").Find(&apps).Error; err != nil && err != gorm.ErrRecordNotFound {\n\t\treturn err\n\t}\n\tfmt.Println(\"Migrating\", len(apps), \"application sort keys\")\n\n\tsortKey := \"\"\n\tcurrentUser := uint(math.MaxUint)\n\tvar err error\n\tfor _, app := range apps {\n\t\tif currentUser != app.UserID {\n\t\t\tsortKey = \"\"\n\t\t\tcurrentUser = app.UserID\n\t\t}\n\t\tsortKey, err = fracdex.KeyBetween(sortKey, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tapp.SortKey = sortKey\n\t}\n\treturn db.Save(apps).Error\n}\n\nfunc createDirectoryIfSqlite(dialect, connection string) {\n\tif dialect == \"sqlite3\" {\n\t\tif _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) {\n\t\t\tif err := mkdirAll(filepath.Dir(connection), 0o777); err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// GormDatabase is a wrapper for the gorm framework.\ntype GormDatabase struct {\n\tDB *gorm.DB\n}\n\n// Close closes the gorm database connection.\nfunc (d *GormDatabase) Close() {\n\tsqldb, err := d.DB.DB()\n\tif err != nil {\n\t\treturn\n\t}\n\tsqldb.Close()\n}\n"
  },
  {
    "path": "database/database_test.go",
    "content": "package database\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestDatabaseSuite(t *testing.T) {\n\tsuite.Run(t, new(DatabaseSuite))\n}\n\ntype DatabaseSuite struct {\n\tsuite.Suite\n\tdb     *GormDatabase\n\ttmpDir test.TmpDir\n}\n\nfunc (s *DatabaseSuite) BeforeTest(suiteName, testName string) {\n\ts.tmpDir = test.NewTmpDir(\"gotify_databasesuite\")\n\tdb, err := New(\"sqlite3\", s.tmpDir.Path(\"testdb.db\"), \"defaultUser\", \"defaultPass\", 5, true)\n\tassert.Nil(s.T(), err)\n\ts.db = db\n}\n\nfunc (s *DatabaseSuite) AfterTest(suiteName, testName string) {\n\ts.db.Close()\n\tassert.Nil(s.T(), s.tmpDir.Clean())\n}\n\nfunc TestInvalidDialect(t *testing.T) {\n\ttmpDir := test.NewTmpDir(\"gotify_testinvaliddialect\")\n\tdefer tmpDir.Clean()\n\t_, err := New(\"asdf\", tmpDir.Path(\"testdb.db\"), \"defaultUser\", \"defaultPass\", 5, true)\n\tassert.Error(t, err)\n}\n\nfunc TestCreateSqliteFolder(t *testing.T) {\n\ttmpDir := test.NewTmpDir(\"gotify_testcreatesqlitefolder\")\n\tdefer tmpDir.Clean()\n\n\tdb, err := New(\"sqlite3\", tmpDir.Path(\"somepath/testdb.db\"), \"defaultUser\", \"defaultPass\", 5, true)\n\tassert.Nil(t, err)\n\tassert.DirExists(t, tmpDir.Path(\"somepath\"))\n\tdb.Close()\n}\n\nfunc TestWithAlreadyExistingSqliteFolder(t *testing.T) {\n\ttmpDir := test.NewTmpDir(\"gotify_testwithexistingfolder\")\n\tdefer tmpDir.Clean()\n\n\tdb, err := New(\"sqlite3\", tmpDir.Path(\"somepath/testdb.db\"), \"defaultUser\", \"defaultPass\", 5, true)\n\tassert.Nil(t, err)\n\tassert.DirExists(t, tmpDir.Path(\"somepath\"))\n\tdb.Close()\n}\n\nfunc TestPanicsOnMkdirError(t *testing.T) {\n\ttmpDir := test.NewTmpDir(\"gotify_testpanicsonmkdirerror\")\n\tdefer tmpDir.Clean()\n\tmkdirAll = func(path string, perm os.FileMode) error {\n\t\treturn errors.New(\"ERROR\")\n\t}\n\tassert.Panics(t, func() {\n\t\tNew(\"sqlite3\", tmpDir.Path(\"somepath/test.db\"), \"defaultUser\", \"defaultPass\", 5, true)\n\t})\n}\n\nfunc TestMigrateSortKey(t *testing.T) {\n\tdb, err := New(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=memory&cache=shared\", fmt.Sprint(time.Now().UnixNano())), \"admin\", \"pw\", 5, true)\n\tassert.Nil(t, err)\n\tassert.NotNil(t, db)\n\n\terr = db.CreateApplication(&model.Application{Name: \"one\", Token: \"one\", UserID: 1})\n\tassert.NoError(t, err)\n\terr = db.CreateApplication(&model.Application{Name: \"two\", Token: \"two\", UserID: 1})\n\tassert.NoError(t, err)\n\terr = db.CreateApplication(&model.Application{Name: \"three\", Token: \"three\", UserID: 1})\n\tassert.NoError(t, err)\n\terr = db.CreateApplication(&model.Application{Name: \"one-other\", Token: \"one-other\", UserID: 2})\n\tassert.NoError(t, err)\n\n\terr = db.DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(new(model.Application)).UpdateColumn(\"sort_key\", nil).Error\n\tassert.NoError(t, err)\n\n\terr = fillMissingSortKeys(db.DB)\n\tassert.NoError(t, err)\n\n\tapps, err := db.GetApplicationsByUser(1)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, apps, 3)\n\tassert.Equal(t, apps[0].Name, \"one\")\n\tassert.Equal(t, apps[0].SortKey, \"a0\")\n\tassert.Equal(t, apps[1].Name, \"two\")\n\tassert.Equal(t, apps[1].SortKey, \"a1\")\n\tassert.Equal(t, apps[2].Name, \"three\")\n\tassert.Equal(t, apps[2].SortKey, \"a2\")\n\n\tapps, err = db.GetApplicationsByUser(2)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, apps, 1)\n\tassert.Equal(t, apps[0].Name, \"one-other\")\n\tassert.Equal(t, apps[0].SortKey, \"a0\")\n}\n"
  },
  {
    "path": "database/message.go",
    "content": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetMessageByID returns the messages for the given id or nil.\nfunc (d *GormDatabase) GetMessageByID(id uint) (*model.Message, error) {\n\tmsg := new(model.Message)\n\terr := d.DB.Find(msg, id).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif msg.ID == id {\n\t\treturn msg, err\n\t}\n\treturn nil, err\n}\n\n// CreateMessage creates a message.\nfunc (d *GormDatabase) CreateMessage(message *model.Message) error {\n\treturn d.DB.Create(message).Error\n}\n\n// GetMessagesByUser returns all messages from a user.\nfunc (d *GormDatabase) GetMessagesByUser(userID uint) ([]*model.Message, error) {\n\tvar messages []*model.Message\n\terr := d.DB.Joins(\"JOIN applications ON applications.user_id = ?\", userID).\n\t\tWhere(\"messages.application_id = applications.id\").Order(\"messages.id desc\").Find(&messages).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn messages, err\n}\n\n// GetMessagesByUserSince returns limited messages from a user.\n// If since is 0 it will be ignored.\nfunc (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error) {\n\tvar messages []*model.Message\n\tdb := d.DB.Joins(\"JOIN applications ON applications.user_id = ?\", userID).\n\t\tWhere(\"messages.application_id = applications.id\").Order(\"messages.id desc\").Limit(limit)\n\tif since != 0 {\n\t\tdb = db.Where(\"messages.id < ?\", since)\n\t}\n\terr := db.Find(&messages).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn messages, err\n}\n\n// GetMessagesByApplication returns all messages from an application.\nfunc (d *GormDatabase) GetMessagesByApplication(tokenID uint) ([]*model.Message, error) {\n\tvar messages []*model.Message\n\terr := d.DB.Where(\"application_id = ?\", tokenID).Order(\"messages.id desc\").Find(&messages).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn messages, err\n}\n\n// GetMessagesByApplicationSince returns limited messages from an application.\n// If since is 0 it will be ignored.\nfunc (d *GormDatabase) GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error) {\n\tvar messages []*model.Message\n\tdb := d.DB.Where(\"application_id = ?\", appID).Order(\"messages.id desc\").Limit(limit)\n\tif since != 0 {\n\t\tdb = db.Where(\"messages.id < ?\", since)\n\t}\n\terr := db.Find(&messages).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn messages, err\n}\n\n// DeleteMessageByID deletes a message by its id.\nfunc (d *GormDatabase) DeleteMessageByID(id uint) error {\n\treturn d.DB.Where(\"id = ?\", id).Delete(&model.Message{}).Error\n}\n\n// DeleteMessagesByApplication deletes all messages from an application.\nfunc (d *GormDatabase) DeleteMessagesByApplication(applicationID uint) error {\n\treturn d.DB.Where(\"application_id = ?\", applicationID).Delete(&model.Message{}).Error\n}\n\n// DeleteMessagesByUser deletes all messages from a user.\nfunc (d *GormDatabase) DeleteMessagesByUser(userID uint) error {\n\tapp, _ := d.GetApplicationsByUser(userID)\n\tfor _, app := range app {\n\t\td.DeleteMessagesByApplication(app.ID)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "database/message_test.go",
    "content": "package database\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc (s *DatabaseSuite) TestMessage() {\n\tmessages, err := s.db.GetMessageByID(5)\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), messages, \"not existing message\")\n\n\tuser := &model.User{Name: \"test\", Pass: []byte{1}}\n\ts.db.CreateUser(user)\n\tassert.NotEqual(s.T(), 0, user.ID)\n\n\tbackupServer := &model.Application{UserID: user.ID, Token: \"A0000000000\", Name: \"backupserver\"}\n\ts.db.CreateApplication(backupServer)\n\tassert.NotEqual(s.T(), 0, backupServer.ID)\n\n\tmsgs, err := s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tmsgs, err = s.db.GetMessagesByApplication(backupServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tbackupdone := &model.Message{ApplicationID: backupServer.ID, Message: \"backup done\", Title: \"backup\", Priority: 1, Date: time.Now()}\n\trequire.NoError(s.T(), s.db.CreateMessage(backupdone))\n\tassert.NotEqual(s.T(), 0, backupdone.ID)\n\n\tmessages, err = s.db.GetMessageByID(backupdone.ID)\n\trequire.NoError(s.T(), err)\n\tassertEquals(s.T(), messages, backupdone)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassertEquals(s.T(), msgs[0], backupdone)\n\n\tmsgs, err = s.db.GetMessagesByApplication(backupServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassertEquals(s.T(), msgs[0], backupdone)\n\n\tloginServer := &model.Application{UserID: user.ID, Token: \"A0000000001\", Name: \"loginserver\"}\n\trequire.NoError(s.T(), s.db.CreateApplication(loginServer))\n\tassert.NotEqual(s.T(), 0, loginServer.ID)\n\n\tlogindone := &model.Message{ApplicationID: loginServer.ID, Message: \"login done\", Title: \"login\", Priority: 1, Date: time.Now()}\n\trequire.NoError(s.T(), s.db.CreateMessage(logindone))\n\tassert.NotEqual(s.T(), 0, logindone.ID)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 2)\n\tassertEquals(s.T(), msgs[0], logindone)\n\tassertEquals(s.T(), msgs[1], backupdone)\n\n\tmsgs, err = s.db.GetMessagesByApplication(backupServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassertEquals(s.T(), msgs[0], backupdone)\n\n\tloginfailed := &model.Message{ApplicationID: loginServer.ID, Message: \"login failed\", Title: \"login\", Priority: 1, Date: time.Now()}\n\trequire.NoError(s.T(), s.db.CreateMessage(loginfailed))\n\tassert.NotEqual(s.T(), 0, loginfailed.ID)\n\n\tmsgs, err = s.db.GetMessagesByApplication(backupServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassertEquals(s.T(), msgs[0], backupdone)\n\n\tmsgs, err = s.db.GetMessagesByApplication(loginServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 2)\n\tassertEquals(s.T(), msgs[0], loginfailed)\n\tassertEquals(s.T(), msgs[1], logindone)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 3)\n\tassertEquals(s.T(), msgs[0], loginfailed)\n\tassertEquals(s.T(), msgs[1], logindone)\n\tassertEquals(s.T(), msgs[2], backupdone)\n\n\tbackupfailed := &model.Message{ApplicationID: backupServer.ID, Message: \"backup failed\", Title: \"backup\", Priority: 1, Date: time.Now()}\n\trequire.NoError(s.T(), s.db.CreateMessage(backupfailed))\n\tassert.NotEqual(s.T(), 0, backupfailed.ID)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 4)\n\tassertEquals(s.T(), msgs[0], backupfailed)\n\tassertEquals(s.T(), msgs[1], loginfailed)\n\tassertEquals(s.T(), msgs[2], logindone)\n\tassertEquals(s.T(), msgs[3], backupdone)\n\n\tmsgs, err = s.db.GetMessagesByApplication(loginServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 2)\n\tassertEquals(s.T(), msgs[0], loginfailed)\n\tassertEquals(s.T(), msgs[1], logindone)\n\n\trequire.NoError(s.T(), s.db.DeleteMessagesByApplication(loginServer.ID))\n\tmsgs, err = s.db.GetMessagesByApplication(loginServer.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 2)\n\tassertEquals(s.T(), msgs[0], backupfailed)\n\tassertEquals(s.T(), msgs[1], backupdone)\n\n\tlogindone = &model.Message{ApplicationID: loginServer.ID, Message: \"login done\", Title: \"login\", Priority: 1, Date: time.Now()}\n\trequire.NoError(s.T(), s.db.CreateMessage(logindone))\n\tassert.NotEqual(s.T(), 0, logindone.ID)\n\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 3)\n\tassertEquals(s.T(), msgs[0], logindone)\n\tassertEquals(s.T(), msgs[1], backupfailed)\n\tassertEquals(s.T(), msgs[2], backupdone)\n\n\ts.db.DeleteMessagesByUser(user.ID)\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tlogout := &model.Message{ApplicationID: loginServer.ID, Message: \"logout success\", Title: \"logout\", Priority: 1, Date: time.Now()}\n\ts.db.CreateMessage(logout)\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), msgs, 1)\n\tassertEquals(s.T(), msgs[0], logout)\n\n\trequire.NoError(s.T(), s.db.DeleteMessageByID(logout.ID))\n\tmsgs, err = s.db.GetMessagesByUser(user.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n}\n\nfunc (s *DatabaseSuite) TestGetMessagesSince() {\n\tuser := &model.User{Name: \"test\", Pass: []byte{1}}\n\trequire.NoError(s.T(), s.db.CreateUser(user))\n\n\tapp := &model.Application{UserID: user.ID, Token: \"A0000000000\"}\n\tapp2 := &model.Application{UserID: user.ID, Token: \"A0000000001\"}\n\trequire.NoError(s.T(), s.db.CreateApplication(app))\n\trequire.NoError(s.T(), s.db.CreateApplication(app2))\n\n\tcurDate := time.Now()\n\tfor i := 1; i <= 500; i++ {\n\t\ts.db.CreateMessage(&model.Message{ApplicationID: app.ID, Message: \"abc\", Date: curDate.Add(time.Duration(i) * time.Second)})\n\t\ts.db.CreateMessage(&model.Message{ApplicationID: app2.ID, Message: \"abc\", Date: curDate.Add(time.Duration(i) * time.Second)})\n\t}\n\n\tactual, err := s.db.GetMessagesByUserSince(user.ID, 50, 0)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 1000, 951, 1)\n\n\tactual, err = s.db.GetMessagesByUserSince(user.ID, 50, 951)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 950, 901, 1)\n\n\tactual, err = s.db.GetMessagesByUserSince(user.ID, 100, 951)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 100)\n\thasIDInclusiveBetween(s.T(), actual, 950, 851, 1)\n\n\tactual, err = s.db.GetMessagesByUserSince(user.ID, 100, 51)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 50, 1, 1)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 0)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 999, 901, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app.ID, 50, 901)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 899, 801, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 666)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 100)\n\thasIDInclusiveBetween(s.T(), actual, 665, 467, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app.ID, 100, 101)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 99, 1, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 0)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 1000, 902, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app2.ID, 50, 902)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 900, 802, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 667)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 100)\n\thasIDInclusiveBetween(s.T(), actual, 666, 468, 2)\n\n\tactual, err = s.db.GetMessagesByApplicationSince(app2.ID, 100, 102)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), actual, 50)\n\thasIDInclusiveBetween(s.T(), actual, 100, 2, 2)\n}\n\nfunc hasIDInclusiveBetween(t *testing.T, msgs []*model.Message, from, to, decrement int) {\n\tindex := 0\n\tfor expectedID := from; expectedID >= to; expectedID -= decrement {\n\t\tif !assert.Equal(t, uint(expectedID), msgs[index].ID) {\n\t\t\tbreak\n\t\t}\n\t\tindex++\n\t}\n\tassert.Equal(t, index, len(msgs), \"not all entries inside msgs were checked\")\n}\n\n// assertEquals compares messages and correctly check dates.\nfunc assertEquals(t *testing.T, left, right *model.Message) {\n\tassert.Equal(t, left.Date.Unix(), right.Date.Unix())\n\tleft.Date = right.Date\n\tassert.Equal(t, left, right)\n}\n"
  },
  {
    "path": "database/migration_test.go",
    "content": "package database\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestMigration(t *testing.T) {\n\tsuite.Run(t, &MigrationSuite{})\n}\n\ntype MigrationSuite struct {\n\tsuite.Suite\n\ttmpDir test.TmpDir\n}\n\nfunc (s *MigrationSuite) BeforeTest(suiteName, testName string) {\n\ts.tmpDir = test.NewTmpDir(\"gotify_migrationsuite\")\n\tdb, err := gorm.Open(sqlite.Open(s.tmpDir.Path(\"test_obsolete.db\")), &gorm.Config{})\n\tassert.NoError(s.T(), err)\n\tsqlDB, err := db.DB()\n\tassert.NoError(s.T(), err)\n\tdefer sqlDB.Close()\n\n\tassert.Nil(s.T(), db.Migrator().CreateTable(new(model.User)))\n\tassert.Nil(s.T(), db.Create(&model.User{\n\t\tName:  \"test_user\",\n\t\tAdmin: true,\n\t}).Error)\n\n\t// we should not be able to create applications by now\n\tassert.False(s.T(), db.Migrator().HasTable(new(model.Application)))\n}\n\nfunc (s *MigrationSuite) AfterTest(suiteName, testName string) {\n\tassert.Nil(s.T(), s.tmpDir.Clean())\n}\n\nfunc (s *MigrationSuite) TestMigration() {\n\tdb, err := New(\"sqlite3\", s.tmpDir.Path(\"test_obsolete.db\"), \"admin\", \"admin\", 6, true)\n\tassert.Nil(s.T(), err)\n\tdefer db.Close()\n\n\tassert.True(s.T(), db.DB.Migrator().HasTable(new(model.Application)))\n\n\t// a user already exist, not adding a new user\n\tif user, err := db.GetUserByName(\"admin\"); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), user)\n\t}\n\n\t// the old user should persist\n\tif user, err := db.GetUserByName(\"test_user\"); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), true, user.Admin)\n\t}\n\n\t// we should be able to create applications\n\tif user, err := db.GetUserByName(\"test_user\"); assert.NoError(s.T(), err) {\n\t\tassert.Nil(s.T(), db.CreateApplication(&model.Application{\n\t\t\tToken:       \"A1234\",\n\t\t\tUserID:      user.ID,\n\t\t\tDescription: \"this is a test application\",\n\t\t\tName:        \"test application\",\n\t\t}))\n\t}\n\tif app, err := db.GetApplicationByToken(\"A1234\"); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), \"test application\", app.Name)\n\t}\n}\n"
  },
  {
    "path": "database/ping.go",
    "content": "package database\n\n// Ping pings the database to verify the connection.\nfunc (d *GormDatabase) Ping() error {\n\tsqldb, err := d.DB.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqldb.Ping()\n}\n"
  },
  {
    "path": "database/ping_test.go",
    "content": "package database\n\nimport (\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc (s *DatabaseSuite) TestPing_onValidDB() {\n\terr := s.db.Ping()\n\tassert.NoError(s.T(), err)\n}\n\nfunc (s *DatabaseSuite) TestPing_onClosedDB() {\n\ts.db.Close()\n\terr := s.db.Ping()\n\tassert.Error(s.T(), err)\n}\n"
  },
  {
    "path": "database/plugin.go",
    "content": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetPluginConfByUser gets plugin configurations from a user.\nfunc (d *GormDatabase) GetPluginConfByUser(userid uint) ([]*model.PluginConf, error) {\n\tvar plugins []*model.PluginConf\n\terr := d.DB.Where(\"user_id = ?\", userid).Find(&plugins).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\treturn plugins, err\n}\n\n// GetPluginConfByUserAndPath gets plugin configuration by user and file name.\nfunc (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error) {\n\tplugin := new(model.PluginConf)\n\terr := d.DB.Where(\"user_id = ? AND module_path = ?\", userid, path).First(plugin).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif plugin.ModulePath == path {\n\t\treturn plugin, err\n\t}\n\treturn nil, err\n}\n\n// GetPluginConfByApplicationID gets plugin configuration by its internal appid.\nfunc (d *GormDatabase) GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error) {\n\tplugin := new(model.PluginConf)\n\terr := d.DB.Where(\"application_id = ?\", appid).First(plugin).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif plugin.ApplicationID == appid {\n\t\treturn plugin, err\n\t}\n\treturn nil, err\n}\n\n// CreatePluginConf creates a new plugin configuration.\nfunc (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error {\n\treturn d.DB.Create(p).Error\n}\n\n// GetPluginConfByToken gets plugin configuration by plugin token.\nfunc (d *GormDatabase) GetPluginConfByToken(token string) (*model.PluginConf, error) {\n\tplugin := new(model.PluginConf)\n\terr := d.DB.Where(\"token = ?\", token).First(plugin).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif plugin.Token == token {\n\t\treturn plugin, err\n\t}\n\treturn nil, err\n}\n\n// GetPluginConfByID gets plugin configuration by plugin ID.\nfunc (d *GormDatabase) GetPluginConfByID(id uint) (*model.PluginConf, error) {\n\tplugin := new(model.PluginConf)\n\terr := d.DB.Where(\"id = ?\", id).First(plugin).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif plugin.ID == id {\n\t\treturn plugin, err\n\t}\n\treturn nil, err\n}\n\n// UpdatePluginConf updates plugin configuration.\nfunc (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error {\n\treturn d.DB.Save(p).Error\n}\n\n// DeletePluginConfByID deletes a plugin configuration by its id.\nfunc (d *GormDatabase) DeletePluginConfByID(id uint) error {\n\treturn d.DB.Where(\"id = ?\", id).Delete(&model.PluginConf{}).Error\n}\n"
  },
  {
    "path": "database/plugin_test.go",
    "content": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc (s *DatabaseSuite) TestPluginConf() {\n\tplugin := model.PluginConf{\n\t\tModulePath:    \"github.com/gotify/example-plugin\",\n\t\tToken:         \"Pabc\",\n\t\tUserID:        1,\n\t\tEnabled:       true,\n\t\tConfig:        nil,\n\t\tApplicationID: 2,\n\t}\n\n\tassert.Nil(s.T(), s.db.CreatePluginConf(&plugin))\n\n\tassert.Equal(s.T(), uint(1), plugin.ID)\n\tpluginConf, err := s.db.GetPluginConfByUserAndPath(1, \"github.com/gotify/example-plugin\")\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), \"Pabc\", pluginConf.Token)\n\n\tpluginConf, err = s.db.GetPluginConfByToken(\"Pabc\")\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), true, pluginConf.Enabled)\n\n\tpluginConf, err = s.db.GetPluginConfByApplicationID(2)\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), \"Pabc\", pluginConf.Token)\n\n\tpluginConf, err = s.db.GetPluginConfByID(1)\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), \"github.com/gotify/example-plugin\", pluginConf.ModulePath)\n\n\tpluginConf, err = s.db.GetPluginConfByToken(\"Pnotexist\")\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), pluginConf)\n\n\tpluginConf, err = s.db.GetPluginConfByID(12)\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), pluginConf)\n\n\tpluginConf, err = s.db.GetPluginConfByUserAndPath(1, \"not/exist\")\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), pluginConf)\n\n\tpluginConf, err = s.db.GetPluginConfByApplicationID(99)\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), pluginConf)\n\n\tpluginConfs, err := s.db.GetPluginConfByUser(1)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), pluginConfs, 1)\n\n\tpluginConfs, err = s.db.GetPluginConfByUser(0)\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), pluginConfs, 0)\n\n\ttestConf := `{\"test_config_key\":\"hello\"}`\n\tplugin.Enabled = false\n\tplugin.Config = []byte(testConf)\n\tassert.Nil(s.T(), s.db.UpdatePluginConf(&plugin))\n\tpluginConf, err = s.db.GetPluginConfByToken(\"Pabc\")\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), false, pluginConf.Enabled)\n\tassert.Equal(s.T(), testConf, string(pluginConf.Config))\n}\n"
  },
  {
    "path": "database/user.go",
    "content": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetUserByName returns the user by the given name or nil.\nfunc (d *GormDatabase) GetUserByName(name string) (*model.User, error) {\n\tuser := new(model.User)\n\terr := d.DB.Where(\"name = ?\", name).Find(user).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif user.Name == name {\n\t\treturn user, err\n\t}\n\treturn nil, err\n}\n\n// GetUserByID returns the user by the given id or nil.\nfunc (d *GormDatabase) GetUserByID(id uint) (*model.User, error) {\n\tuser := new(model.User)\n\terr := d.DB.Find(user, id).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\terr = nil\n\t}\n\tif user.ID == id {\n\t\treturn user, err\n\t}\n\treturn nil, err\n}\n\n// CountUser returns the user count which satisfies the given condition.\nfunc (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) {\n\tc := int64(-1)\n\thandle := d.DB.Model(new(model.User))\n\tif len(condition) == 1 {\n\t\thandle = handle.Where(condition[0])\n\t} else if len(condition) > 1 {\n\t\thandle = handle.Where(condition[0], condition[1:]...)\n\t}\n\terr := handle.Count(&c).Error\n\treturn c, err\n}\n\n// GetUsers returns all users.\nfunc (d *GormDatabase) GetUsers() ([]*model.User, error) {\n\tvar users []*model.User\n\terr := d.DB.Find(&users).Error\n\treturn users, err\n}\n\n// DeleteUserByID deletes a user by its id.\nfunc (d *GormDatabase) DeleteUserByID(id uint) error {\n\tapps, _ := d.GetApplicationsByUser(id)\n\tfor _, app := range apps {\n\t\td.DeleteApplicationByID(app.ID)\n\t}\n\tclients, _ := d.GetClientsByUser(id)\n\tfor _, client := range clients {\n\t\td.DeleteClientByID(client.ID)\n\t}\n\tpluginConfs, _ := d.GetPluginConfByUser(id)\n\tfor _, conf := range pluginConfs {\n\t\td.DeletePluginConfByID(conf.ID)\n\t}\n\treturn d.DB.Where(\"id = ?\", id).Delete(&model.User{}).Error\n}\n\n// UpdateUser updates a user.\nfunc (d *GormDatabase) UpdateUser(user *model.User) error {\n\treturn d.DB.Save(user).Error\n}\n\n// CreateUser creates a user.\nfunc (d *GormDatabase) CreateUser(user *model.User) error {\n\treturn d.DB.Create(user).Error\n}\n"
  },
  {
    "path": "database/user_test.go",
    "content": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc (s *DatabaseSuite) TestUser() {\n\tuser, err := s.db.GetUserByID(55)\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), user, \"not existing user\")\n\n\tuser, err = s.db.GetUserByName(\"nicories\")\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), user, \"not existing user\")\n\n\tjmattheis, err := s.db.GetUserByID(1)\n\trequire.NoError(s.T(), err)\n\tassert.NotNil(s.T(), jmattheis, \"on bootup the first user should be automatically created\")\n\n\tadminCount, err := s.db.CountUser(\"admin = ?\", true)\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), int64(1), adminCount, \"there is initially one admin\")\n\n\tusers, err := s.db.GetUsers()\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), users, 1)\n\tassert.Contains(s.T(), users, jmattheis)\n\n\tnicories := &model.User{Name: \"nicories\", Pass: []byte{1, 2, 3, 4}, Admin: false}\n\ts.db.CreateUser(nicories)\n\tassert.NotEqual(s.T(), 0, nicories.ID, \"on create user a new id should be assigned\")\n\tuserCount, err := s.db.CountUser()\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), int64(2), userCount, \"two users should exist\")\n\n\tuser, err = s.db.GetUserByName(\"nicories\")\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), nicories, user)\n\n\tusers, err = s.db.GetUsers()\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), users, 2)\n\tassert.Contains(s.T(), users, jmattheis)\n\tassert.Contains(s.T(), users, nicories)\n\n\tnicories.Name = \"tom\"\n\tnicories.Pass = []byte{12}\n\tnicories.Admin = true\n\trequire.NoError(s.T(), s.db.UpdateUser(nicories))\n\n\ttom, err := s.db.GetUserByID(nicories.ID)\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), &model.User{ID: nicories.ID, Name: \"tom\", Pass: []byte{12}, Admin: true}, tom)\n\n\tusers, err = s.db.GetUsers()\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), users, 2)\n\n\tadminCount, err = s.db.CountUser(&model.User{Admin: true})\n\trequire.NoError(s.T(), err)\n\tassert.Equal(s.T(), int64(2), adminCount, \"two admins exist\")\n\n\trequire.NoError(s.T(), s.db.DeleteUserByID(tom.ID))\n\tusers, err = s.db.GetUsers()\n\trequire.NoError(s.T(), err)\n\tassert.Len(s.T(), users, 1)\n\tassert.Contains(s.T(), users, jmattheis)\n\n\ts.db.DeleteUserByID(jmattheis.ID)\n\tusers, err = s.db.GetUsers()\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), users)\n}\n\nfunc (s *DatabaseSuite) TestUserPlugins() {\n\tassert.NoError(s.T(), s.db.CreateUser(&model.User{Name: \"geek\", ID: 16}))\n\tif geekUser, err := s.db.GetUserByName(\"geek\"); assert.NoError(s.T(), err) {\n\t\ts.db.CreatePluginConf(&model.PluginConf{\n\t\t\tUserID:     geekUser.ID,\n\t\t\tModulePath: \"github.com/gotify/example-plugin\",\n\t\t\tToken:      \"P1234\",\n\t\t\tEnabled:    true,\n\t\t})\n\t\ts.db.CreatePluginConf(&model.PluginConf{\n\t\t\tUserID:     geekUser.ID,\n\t\t\tModulePath: \"github.com/gotify/example-plugin/v2\",\n\t\t\tToken:      \"P5678\",\n\t\t\tEnabled:    true,\n\t\t})\n\t}\n\n\tif geekUser, err := s.db.GetUserByName(\"geek\"); assert.NoError(s.T(), err) {\n\t\tif pluginConfs, err := s.db.GetPluginConfByUser(geekUser.ID); assert.NoError(s.T(), err) {\n\t\t\tassert.Len(s.T(), pluginConfs, 2)\n\t\t}\n\t}\n\tif pluginConf, err := s.db.GetPluginConfByToken(\"P1234\"); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), \"github.com/gotify/example-plugin\", pluginConf.ModulePath)\n\t}\n}\n\nfunc (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClientsAndPluginConfs() {\n\trequire.NoError(s.T(), s.db.CreateUser(&model.User{Name: \"nicories\", ID: 10}))\n\trequire.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 100, Token: \"apptoken\", UserID: 10}))\n\trequire.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100}))\n\trequire.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 10000, Token: \"clienttoken\", UserID: 10}))\n\trequire.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 1000, Token: \"plugintoken\", UserID: 10}))\n\n\trequire.NoError(s.T(), s.db.CreateUser(&model.User{Name: \"nicories2\", ID: 20}))\n\trequire.NoError(s.T(), s.db.CreateApplication(&model.Application{ID: 200, Token: \"apptoken2\", UserID: 20}))\n\trequire.NoError(s.T(), s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200}))\n\trequire.NoError(s.T(), s.db.CreateClient(&model.Client{ID: 20000, Token: \"clienttoken2\", UserID: 20}))\n\trequire.NoError(s.T(), s.db.CreatePluginConf(&model.PluginConf{ID: 2000, Token: \"plugintoken2\", UserID: 20}))\n\n\trequire.NoError(s.T(), s.db.DeleteUserByID(10))\n\n\tapp, err := s.db.GetApplicationByToken(\"apptoken\")\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), app)\n\n\tclient, err := s.db.GetClientByToken(\"clienttoken\")\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), client)\n\n\tclients, err := s.db.GetClientsByUser(10)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), clients)\n\n\tapps, err := s.db.GetApplicationsByUser(10)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), apps)\n\n\tmsgs, err := s.db.GetMessagesByApplication(100)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tmsgs, err = s.db.GetMessagesByUser(10)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), msgs)\n\n\tpluginConfs, err := s.db.GetPluginConfByUser(10)\n\trequire.NoError(s.T(), err)\n\tassert.Empty(s.T(), pluginConfs)\n\n\tmsg, err := s.db.GetMessageByID(1000)\n\trequire.NoError(s.T(), err)\n\tassert.Nil(s.T(), msg)\n\n\tapp, err = s.db.GetApplicationByToken(\"apptoken2\")\n\trequire.NoError(s.T(), err)\n\tassert.NotNil(s.T(), app)\n\n\tclient, err = s.db.GetClientByToken(\"clienttoken2\")\n\trequire.NoError(s.T(), err)\n\tassert.NotNil(s.T(), client)\n\n\tclients, err = s.db.GetClientsByUser(20)\n\trequire.NoError(s.T(), err)\n\tassert.NotEmpty(s.T(), clients)\n\n\tapps, err = s.db.GetApplicationsByUser(20)\n\trequire.NoError(s.T(), err)\n\tassert.NotEmpty(s.T(), apps)\n\n\tpluginConf, err := s.db.GetPluginConfByUser(20)\n\trequire.NoError(s.T(), err)\n\tassert.NotEmpty(s.T(), pluginConf)\n\n\tmsgs, err = s.db.GetMessagesByApplication(200)\n\trequire.NoError(s.T(), err)\n\tassert.NotEmpty(s.T(), msgs)\n\n\tmsgs, err = s.db.GetMessagesByUser(20)\n\trequire.NoError(s.T(), err)\n\tassert.NotEmpty(s.T(), msgs)\n\n\tmsg, err = s.db.GetMessageByID(2000)\n\trequire.NoError(s.T(), err)\n\tassert.NotNil(s.T(), msg)\n}\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "ARG BUILDKIT_SBOM_SCAN_CONTEXT=true\n# Suppress warning about invalid variable expansion\nARG GO_VERSION=PLEASE_PROVIDE_GO_VERSION\nARG DEBIAN=sid-slim\n\n# Hack to normalize platform to match the chosed build image\n# Get the gotify/build image tag\nARG __TARGETPLATFORM_DASHES=${TARGETPLATFORM/\\//-}\nARG __TARGETPLATFORM_GO_NOTATION=${__TARGETPLATFORM_DASHES/arm\\/v7/arm-7}\n\n# --- JS Builder ---\n\nFROM --platform=${BUILDPLATFORM} node:24 AS js-builder\n\nARG BUILD_JS=0\n\nCOPY ./Makefile /src/gotify/Makefile\nCOPY ./ui /src/gotify/ui\n\nRUN if [ \"$BUILD_JS\" = \"1\" ]; then \\\n    (cd /src/gotify/ui && yarn install) && \\\n    (cd /src/gotify && make build-js) \\\n    else \\\n    mkdir -p /src/gotify/ui/build; \\\n    fi\n\n# --- Go Builder ---\n\nFROM --platform=${BUILDPLATFORM} gotify/build:${GO_VERSION}-${__TARGETPLATFORM_GO_NOTATION} AS builder\n\nARG BUILDPLATFORM\nARG TARGETPLATFORM\nARG BUILD_JS=0\nARG RUN_TESTS=0 # 0=never, 1=native only\nARG LD_FLAGS=\"\"\nENV DEBIAN_FRONTEND=noninteractive\n\nRUN apt-get update && apt-get install -yq --no-install-recommends \\\n    ca-certificates \\\n    git\n\nCOPY . /src/gotify\nCOPY --from=js-builder /src/gotify/ui/build /ui-build\n\nRUN if [ \"$BUILD_JS\" = \"1\" ]; then \\\n    cp -r --update /ui-build /src/gotify/ui/build; \\\n    fi\n\nRUN cd /src/gotify && \\\n    if [ \"$RUN_TESTS\" = \"1\" ] && [ \"$BUILDPLATFORM\" = \"$TARGETPLATFORM\" ]; then \\\n    go test -v ./...; \\\n    fi && \\\n    LD_FLAGS=${LD_FLAGS} make OUTPUT=/target/app/gotify-app _build_within_docker\n\nFROM debian:${DEBIAN}\n\nLABEL org.opencontainers.image.documentation=https://gotify.net/\nLABEL org.opencontainers.image.source=https://github.com/gotify/server\n\n# Build-time configurables\nARG GOTIFY_SERVER_EXPOSE=80\nENV GOTIFY_SERVER_PORT=$GOTIFY_SERVER_EXPOSE\n\nWORKDIR /app\n\nRUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -yq --no-install-recommends \\\n    tzdata \\\n    curl \\\n    ca-certificates && \\\n    rm -rf /var/lib/apt/lists/*\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD curl --fail http://localhost:$GOTIFY_SERVER_PORT/health || exit 1\nEXPOSE $GOTIFY_SERVER_EXPOSE\n\nCOPY --from=builder /target /\n\nENTRYPOINT [\"./gotify-app\"]\n"
  },
  {
    "path": "docs/package.go",
    "content": "// Package docs Gotify REST-API.\n//\n// This is the documentation of the Gotify REST-API.\n//\n//\t# Authentication\n//\tIn Gotify there are two token types:\n//\t__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//\t__appToken__: an application is something that sends messages (f.ex. this token should be used for a shell script)\n//\n//\tThe token can be transmitted in a header named `X-Gotify-Key`, in a query parameter named `token` or\n//\tthrough a header named `Authorization` with the value prefixed with `Bearer` (Ex. `Bearer randomtoken`).\n//\tThere is also the possibility to authenticate through basic auth, this should only be used for creating a clientToken.\n//\n//\t\\---\n//\n//\tFound a bug or have some questions? [Create an issue on GitHub](https://github.com/gotify/server/issues)\n//\n//\t    Schemes: http, https\n//\t    Host: localhost\n//\t    Version: 2.0.2\n//\t    License: MIT https://github.com/gotify/server/blob/master/LICENSE\n//\n//\t    Consumes:\n//\t    - application/json\n//\n//\t    Produces:\n//\t    - application/json\n//\n//\t    SecurityDefinitions:\n//\t       appTokenQuery:\n//\t          type: apiKey\n//\t          name: token\n//\t          in: query\n//\t       clientTokenQuery:\n//\t          type: apiKey\n//\t          name: token\n//\t          in: query\n//\t\t      appTokenHeader:\n//\t          type: apiKey\n//\t          name: X-Gotify-Key\n//\t          in: header\n//\t\t      clientTokenHeader:\n//\t          type: apiKey\n//\t          name: X-Gotify-Key\n//\t          in: header\n//\t\t      appTokenAuthorizationHeader:\n//\t          type: apiKey\n//\t          name: Authorization\n//\t          in: header\n//\t          description: >-\n//\t              Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`.\n//\t\t      clientTokenAuthorizationHeader:\n//\t          type: apiKey\n//\t          name: Authorization\n//\t          in: header\n//\t          description: >-\n//\t              Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`.\n//\t       basicAuth:\n//\t          type: basic\n//\n//\tswagger:meta\npackage docs\n"
  },
  {
    "path": "docs/spec.json",
    "content": "{\n  \"consumes\": [\n    \"application/json\"\n  ],\n  \"produces\": [\n    \"application/json\"\n  ],\n  \"schemes\": [\n    \"http\",\n    \"https\"\n  ],\n  \"swagger\": \"2.0\",\n  \"info\": {\n    \"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)\",\n    \"title\": \"Gotify REST-API.\",\n    \"license\": {\n      \"name\": \"MIT\",\n      \"url\": \"https://github.com/gotify/server/blob/master/LICENSE\"\n    },\n    \"version\": \"2.0.2\"\n  },\n  \"host\": \"localhost\",\n  \"paths\": {\n    \"/application\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Return all applications.\",\n        \"operationId\": \"getApps\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"$ref\": \"#/definitions/Application\"\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Create an application.\",\n        \"operationId\": \"createApp\",\n        \"parameters\": [\n          {\n            \"description\": \"the application to add\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/ApplicationParams\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Application\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/application/{id}\": {\n      \"put\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Update an application.\",\n        \"operationId\": \"updateApplication\",\n        \"parameters\": [\n          {\n            \"description\": \"the application to update\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/ApplicationParams\"\n            }\n          },\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Application\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Delete an application.\",\n        \"operationId\": \"deleteApp\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/application/{id}/image\": {\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"multipart/form-data\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Upload an image for an application.\",\n        \"operationId\": \"uploadAppImage\",\n        \"parameters\": [\n          {\n            \"type\": \"file\",\n            \"description\": \"the application image\",\n            \"name\": \"file\",\n            \"in\": \"formData\",\n            \"required\": true\n          },\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Application\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Deletes an image of an application.\",\n        \"operationId\": \"removeAppImage\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/application/{id}/message\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Return all messages from a specific application.\",\n        \"operationId\": \"getAppMessages\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"maximum\": 200,\n            \"minimum\": 1,\n            \"type\": \"integer\",\n            \"default\": 100,\n            \"description\": \"the maximal amount of messages to return\",\n            \"name\": \"limit\",\n            \"in\": \"query\"\n          },\n          {\n            \"minimum\": 0,\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"return all messages with an ID less than this value\",\n            \"name\": \"since\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/PagedMessages\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Delete all messages from a specific application.\",\n        \"operationId\": \"deleteAppMessages\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the application id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/client\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"client\"\n        ],\n        \"summary\": \"Return all clients.\",\n        \"operationId\": \"getClients\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"$ref\": \"#/definitions/Client\"\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"client\"\n        ],\n        \"summary\": \"Create a client.\",\n        \"operationId\": \"createClient\",\n        \"parameters\": [\n          {\n            \"description\": \"the client to add\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/ClientParams\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Client\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/client/{id}\": {\n      \"put\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"client\"\n        ],\n        \"summary\": \"Update a client.\",\n        \"operationId\": \"updateClient\",\n        \"parameters\": [\n          {\n            \"description\": \"the client to update\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/ClientParams\"\n            }\n          },\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the client id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Client\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"client\"\n        ],\n        \"summary\": \"Delete a client.\",\n        \"operationId\": \"deleteClient\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the client id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/current/user\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Return the current user.\",\n        \"operationId\": \"currentUser\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/User\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/current/user/password\": {\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Update the password of the current user.\",\n        \"operationId\": \"updateCurrentUser\",\n        \"parameters\": [\n          {\n            \"description\": \"the user\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/UserPass\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/health\": {\n      \"get\": {\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"health\"\n        ],\n        \"summary\": \"Get health information.\",\n        \"operationId\": \"getHealth\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Health\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Health\"\n            }\n          }\n        }\n      }\n    },\n    \"/message\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Return all messages.\",\n        \"operationId\": \"getMessages\",\n        \"parameters\": [\n          {\n            \"maximum\": 200,\n            \"minimum\": 1,\n            \"type\": \"integer\",\n            \"default\": 100,\n            \"description\": \"the maximal amount of messages to return\",\n            \"name\": \"limit\",\n            \"in\": \"query\"\n          },\n          {\n            \"minimum\": 0,\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"return all messages with an ID less than this value\",\n            \"name\": \"since\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/PagedMessages\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"appTokenAuthorizationHeader\": []\n          },\n          {\n            \"appTokenHeader\": []\n          },\n          {\n            \"appTokenQuery\": []\n          }\n        ],\n        \"description\": \"__NOTE__: This API ONLY accepts an application token as authentication.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Create a message.\",\n        \"operationId\": \"createMessage\",\n        \"parameters\": [\n          {\n            \"description\": \"the message to add\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/Message\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Message\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Delete all messages.\",\n        \"operationId\": \"deleteMessages\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/message/{id}\": {\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Deletes a message with an id.\",\n        \"operationId\": \"deleteMessage\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the message id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/plugin\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Return all plugins.\",\n        \"operationId\": \"getPlugins\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"$ref\": \"#/definitions/PluginConf\"\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/plugin/{id}/config\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/x-yaml\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Get YAML configuration for Configurer plugin.\",\n        \"operationId\": \"getPluginConfig\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the plugin id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"description\": \"plugin configuration\",\n              \"type\": \"object\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/x-yaml\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Update YAML configuration for Configurer plugin.\",\n        \"operationId\": \"updatePluginConfig\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the plugin id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/plugin/{id}/disable\": {\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Disable a plugin.\",\n        \"operationId\": \"disablePlugin\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the plugin id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/plugin/{id}/display\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Get display info for a Displayer plugin.\",\n        \"operationId\": \"getPluginDisplay\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the plugin id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/plugin/{id}/enable\": {\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"plugin\"\n        ],\n        \"summary\": \"Enable a plugin.\",\n        \"operationId\": \"enablePlugin\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the plugin id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Internal Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/stream\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Websocket, return newly created messages.\",\n        \"operationId\": \"streamMessages\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Message\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"500\": {\n            \"description\": \"Server Error\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/user\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Return all users.\",\n        \"operationId\": \"getUsers\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"$ref\": \"#/definitions/User\"\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"description\": \"With enabled registration: non admin users can be created without authentication.\\nWith disabled registrations: users can only be created by admin users.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Create a user.\",\n        \"operationId\": \"createUser\",\n        \"parameters\": [\n          {\n            \"description\": \"the user to add\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/CreateUserExternal\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/User\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/user/{id}\": {\n      \"get\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Get a user.\",\n        \"operationId\": \"getUser\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the user id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/User\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Update a user.\",\n        \"operationId\": \"updateUser\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the user id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"description\": \"the updated user\",\n            \"name\": \"body\",\n            \"in\": \"body\",\n            \"required\": true,\n            \"schema\": {\n              \"$ref\": \"#/definitions/UpdateUserExternal\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/User\"\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"security\": [\n          {\n            \"clientTokenAuthorizationHeader\": []\n          },\n          {\n            \"clientTokenHeader\": []\n          },\n          {\n            \"clientTokenQuery\": []\n          },\n          {\n            \"basicAuth\": []\n          }\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"user\"\n        ],\n        \"summary\": \"Deletes a user.\",\n        \"operationId\": \"deleteUser\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"description\": \"the user id\",\n            \"name\": \"id\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\"\n          },\n          \"400\": {\n            \"description\": \"Bad Request\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"401\": {\n            \"description\": \"Unauthorized\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          },\n          \"404\": {\n            \"description\": \"Not Found\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Error\"\n            }\n          }\n        }\n      }\n    },\n    \"/version\": {\n      \"get\": {\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"tags\": [\n          \"version\"\n        ],\n        \"summary\": \"Get version information.\",\n        \"operationId\": \"getVersion\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Ok\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/VersionInfo\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"definitions\": {\n    \"Application\": {\n      \"description\": \"The Application holds information about an app which can send notifications.\",\n      \"type\": \"object\",\n      \"title\": \"Application Model\",\n      \"required\": [\n        \"id\",\n        \"token\",\n        \"name\",\n        \"description\",\n        \"internal\",\n        \"image\",\n        \"sortKey\"\n      ],\n      \"properties\": {\n        \"defaultPriority\": {\n          \"description\": \"The default priority of messages sent by this application. Defaults to 0.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"DefaultPriority\",\n          \"example\": 4\n        },\n        \"description\": {\n          \"description\": \"The description of the application.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Description\",\n          \"example\": \"Backup server for the interwebs\"\n        },\n        \"id\": {\n          \"description\": \"The application id.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ID\",\n          \"readOnly\": true,\n          \"example\": 5\n        },\n        \"image\": {\n          \"description\": \"The image of the application.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Image\",\n          \"readOnly\": true,\n          \"example\": \"image/image.jpeg\"\n        },\n        \"internal\": {\n          \"description\": \"Whether the application is an internal application. Internal applications should not be deleted.\",\n          \"type\": \"boolean\",\n          \"x-go-name\": \"Internal\",\n          \"readOnly\": true,\n          \"example\": false\n        },\n        \"lastUsed\": {\n          \"description\": \"The last time the application token was used.\",\n          \"type\": \"string\",\n          \"format\": \"date-time\",\n          \"x-go-name\": \"LastUsed\",\n          \"readOnly\": true,\n          \"example\": \"2019-01-01T00:00:00Z\"\n        },\n        \"name\": {\n          \"description\": \"The application name. This is how the application should be displayed to the user.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"Backup Server\"\n        },\n        \"sortKey\": {\n          \"description\": \"The sort key of this application. Uses fractional indexing.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"SortKey\",\n          \"example\": \"a1\"\n        },\n        \"token\": {\n          \"description\": \"The application token. Can be used as `appToken`. See Authentication.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Token\",\n          \"readOnly\": true,\n          \"example\": \"AWH0wZ5r0Mbac.r\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"ApplicationParams\": {\n      \"description\": \"Params allowed to create or update Applications.\",\n      \"type\": \"object\",\n      \"title\": \"Application Params Model\",\n      \"required\": [\n        \"name\"\n      ],\n      \"properties\": {\n        \"defaultPriority\": {\n          \"description\": \"The default priority of messages sent by this application. Defaults to 0.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"DefaultPriority\",\n          \"example\": 5\n        },\n        \"description\": {\n          \"description\": \"The description of the application.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Description\",\n          \"example\": \"Backup server for the interwebs\"\n        },\n        \"name\": {\n          \"description\": \"The application name. This is how the application should be displayed to the user.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"Backup Server\"\n        },\n        \"sortKey\": {\n          \"description\": \"The sortKey for the application. Uses fractional indexing.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"SortKey\",\n          \"example\": \"a1\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/api\"\n    },\n    \"Client\": {\n      \"description\": \"The Client holds information about a device which can receive notifications (and other stuff).\",\n      \"type\": \"object\",\n      \"title\": \"Client Model\",\n      \"required\": [\n        \"id\",\n        \"token\",\n        \"name\"\n      ],\n      \"properties\": {\n        \"id\": {\n          \"description\": \"The client id.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ID\",\n          \"readOnly\": true,\n          \"example\": 5\n        },\n        \"lastUsed\": {\n          \"description\": \"The last time the client token was used.\",\n          \"type\": \"string\",\n          \"format\": \"date-time\",\n          \"x-go-name\": \"LastUsed\",\n          \"readOnly\": true,\n          \"example\": \"2019-01-01T00:00:00Z\"\n        },\n        \"name\": {\n          \"description\": \"The client name. This is how the client should be displayed to the user.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"Android Phone\"\n        },\n        \"token\": {\n          \"description\": \"The client token. Can be used as `clientToken`. See Authentication.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Token\",\n          \"readOnly\": true,\n          \"example\": \"CWH0wZ5r0Mbac.r\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"ClientParams\": {\n      \"description\": \"Params allowed to create or update Clients.\",\n      \"type\": \"object\",\n      \"title\": \"Client Params Model\",\n      \"required\": [\n        \"name\"\n      ],\n      \"properties\": {\n        \"name\": {\n          \"description\": \"The client name\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"My Client\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/api\"\n    },\n    \"CreateUserExternal\": {\n      \"description\": \"Used for user creation.\",\n      \"type\": \"object\",\n      \"title\": \"CreateUserExternal Model\",\n      \"required\": [\n        \"name\",\n        \"admin\",\n        \"pass\"\n      ],\n      \"properties\": {\n        \"admin\": {\n          \"description\": \"If the user is an administrator.\",\n          \"type\": \"boolean\",\n          \"x-go-name\": \"Admin\",\n          \"example\": true\n        },\n        \"name\": {\n          \"description\": \"The user name. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"unicorn\"\n        },\n        \"pass\": {\n          \"description\": \"The user password. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Pass\",\n          \"example\": \"nrocinu\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"Error\": {\n      \"description\": \"The Error contains error relevant information.\",\n      \"type\": \"object\",\n      \"title\": \"Error Model\",\n      \"required\": [\n        \"error\",\n        \"errorCode\",\n        \"errorDescription\"\n      ],\n      \"properties\": {\n        \"error\": {\n          \"description\": \"The general error message\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Error\",\n          \"example\": \"Unauthorized\"\n        },\n        \"errorCode\": {\n          \"description\": \"The http error code.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ErrorCode\",\n          \"example\": 401\n        },\n        \"errorDescription\": {\n          \"description\": \"The http error code.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"ErrorDescription\",\n          \"example\": \"you need to provide a valid access token or user credentials to access this api\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"Health\": {\n      \"description\": \"Health represents how healthy the application is.\",\n      \"type\": \"object\",\n      \"title\": \"Health Model\",\n      \"required\": [\n        \"health\",\n        \"database\"\n      ],\n      \"properties\": {\n        \"database\": {\n          \"description\": \"The health of the database connection.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Database\",\n          \"example\": \"green\"\n        },\n        \"health\": {\n          \"description\": \"The health of the overall application.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Health\",\n          \"example\": \"green\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"Message\": {\n      \"description\": \"The MessageExternal holds information about a message which was sent by an Application.\",\n      \"type\": \"object\",\n      \"title\": \"MessageExternal Model\",\n      \"required\": [\n        \"id\",\n        \"appid\",\n        \"message\",\n        \"date\"\n      ],\n      \"properties\": {\n        \"appid\": {\n          \"description\": \"The application id that send this message.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ApplicationID\",\n          \"readOnly\": true,\n          \"example\": 5\n        },\n        \"date\": {\n          \"description\": \"The date the message was created.\",\n          \"type\": \"string\",\n          \"format\": \"date-time\",\n          \"x-go-name\": \"Date\",\n          \"readOnly\": true,\n          \"example\": \"2018-02-27T19:36:10.5045044+01:00\"\n        },\n        \"extras\": {\n          \"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.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {},\n          \"x-go-name\": \"Extras\",\n          \"example\": {\n            \"home::appliances::lighting::on\": {\n              \"brightness\": 15\n            },\n            \"home::appliances::thermostat::change_temperature\": {\n              \"temperature\": 23\n            }\n          }\n        },\n        \"id\": {\n          \"description\": \"The message id.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ID\",\n          \"readOnly\": true,\n          \"example\": 25\n        },\n        \"message\": {\n          \"description\": \"The message. Markdown (excluding html) is allowed.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Message\",\n          \"example\": \"**Backup** was successfully finished.\"\n        },\n        \"priority\": {\n          \"description\": \"The priority of the message. If unset, then the default priority of the\\napplication will be used.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"Priority\",\n          \"example\": 2\n        },\n        \"title\": {\n          \"description\": \"The title of the message.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Title\",\n          \"example\": \"Backup\"\n        }\n      },\n      \"x-go-name\": \"MessageExternal\",\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"PagedMessages\": {\n      \"description\": \"Wrapper for the paging and the messages.\",\n      \"type\": \"object\",\n      \"title\": \"PagedMessages Model\",\n      \"required\": [\n        \"paging\",\n        \"messages\"\n      ],\n      \"properties\": {\n        \"messages\": {\n          \"description\": \"The messages.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Message\"\n          },\n          \"x-go-name\": \"Messages\",\n          \"readOnly\": true\n        },\n        \"paging\": {\n          \"$ref\": \"#/definitions/Paging\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"Paging\": {\n      \"description\": \"The Paging holds information about the limit and making requests to the next page.\",\n      \"type\": \"object\",\n      \"title\": \"Paging Model\",\n      \"required\": [\n        \"size\",\n        \"since\",\n        \"limit\"\n      ],\n      \"properties\": {\n        \"limit\": {\n          \"description\": \"The limit of the messages for the current request.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"maximum\": 200,\n          \"minimum\": 1,\n          \"x-go-name\": \"Limit\",\n          \"readOnly\": true,\n          \"example\": 123\n        },\n        \"next\": {\n          \"description\": \"The request url for the next page. Empty/Null when no next page is available.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Next\",\n          \"readOnly\": true,\n          \"example\": \"http://example.com/message?limit=50\\u0026since=123456\"\n        },\n        \"since\": {\n          \"description\": \"The ID of the last message returned in the current request. Use this as alternative to the next link.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"minimum\": 0,\n          \"x-go-name\": \"Since\",\n          \"readOnly\": true,\n          \"example\": 5\n        },\n        \"size\": {\n          \"description\": \"The amount of messages that got returned in the current request.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"Size\",\n          \"readOnly\": true,\n          \"example\": 5\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"PluginConf\": {\n      \"description\": \"Holds information about a plugin instance for one user.\",\n      \"type\": \"object\",\n      \"title\": \"PluginConfExternal Model\",\n      \"required\": [\n        \"id\",\n        \"name\",\n        \"token\",\n        \"modulePath\",\n        \"enabled\",\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"author\": {\n          \"description\": \"The author of the plugin.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Author\",\n          \"readOnly\": true,\n          \"example\": \"jmattheis\"\n        },\n        \"capabilities\": {\n          \"description\": \"Capabilities the plugin provides\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"x-go-name\": \"Capabilities\",\n          \"example\": [\n            \"webhook\",\n            \"display\"\n          ]\n        },\n        \"enabled\": {\n          \"description\": \"Whether the plugin instance is enabled.\",\n          \"type\": \"boolean\",\n          \"x-go-name\": \"Enabled\",\n          \"example\": true\n        },\n        \"id\": {\n          \"description\": \"The plugin id.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ID\",\n          \"readOnly\": true,\n          \"example\": 25\n        },\n        \"license\": {\n          \"description\": \"The license of the plugin.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"License\",\n          \"readOnly\": true,\n          \"example\": \"MIT\"\n        },\n        \"modulePath\": {\n          \"description\": \"The module path of the plugin.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"ModulePath\",\n          \"readOnly\": true,\n          \"example\": \"github.com/gotify/server/plugin/example/echo\"\n        },\n        \"name\": {\n          \"description\": \"The plugin name.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"readOnly\": true,\n          \"example\": \"RSS poller\"\n        },\n        \"token\": {\n          \"description\": \"The user name. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Token\",\n          \"example\": \"P1234\"\n        },\n        \"website\": {\n          \"description\": \"The website of the plugin.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Website\",\n          \"readOnly\": true,\n          \"example\": \"gotify.net\"\n        }\n      },\n      \"x-go-name\": \"PluginConfExternal\",\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"UpdateUserExternal\": {\n      \"description\": \"Used for updating a user.\",\n      \"type\": \"object\",\n      \"title\": \"UpdateUserExternal Model\",\n      \"required\": [\n        \"name\",\n        \"admin\"\n      ],\n      \"properties\": {\n        \"admin\": {\n          \"description\": \"If the user is an administrator.\",\n          \"type\": \"boolean\",\n          \"x-go-name\": \"Admin\",\n          \"example\": true\n        },\n        \"name\": {\n          \"description\": \"The user name. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"unicorn\"\n        },\n        \"pass\": {\n          \"description\": \"The user password. For login. Empty for using old password\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Pass\",\n          \"example\": \"nrocinu\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"User\": {\n      \"description\": \"The User holds information about permission and other stuff.\",\n      \"type\": \"object\",\n      \"title\": \"UserExternal Model\",\n      \"required\": [\n        \"id\",\n        \"name\",\n        \"admin\"\n      ],\n      \"properties\": {\n        \"admin\": {\n          \"description\": \"If the user is an administrator.\",\n          \"type\": \"boolean\",\n          \"x-go-name\": \"Admin\",\n          \"example\": true\n        },\n        \"id\": {\n          \"description\": \"The user id.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"ID\",\n          \"readOnly\": true,\n          \"example\": 25\n        },\n        \"name\": {\n          \"description\": \"The user name. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Name\",\n          \"example\": \"unicorn\"\n        }\n      },\n      \"x-go-name\": \"UserExternal\",\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"UserPass\": {\n      \"description\": \"The Password for updating the user.\",\n      \"type\": \"object\",\n      \"title\": \"UserExternalPass Model\",\n      \"required\": [\n        \"pass\"\n      ],\n      \"properties\": {\n        \"pass\": {\n          \"description\": \"The user password. For login.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Pass\",\n          \"example\": \"nrocinu\"\n        }\n      },\n      \"x-go-name\": \"UserExternalPass\",\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    },\n    \"VersionInfo\": {\n      \"description\": \"VersionInfo Model\",\n      \"type\": \"object\",\n      \"required\": [\n        \"version\",\n        \"commit\",\n        \"buildDate\"\n      ],\n      \"properties\": {\n        \"buildDate\": {\n          \"description\": \"The date on which this binary was built.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"BuildDate\",\n          \"example\": \"2018-02-27T19:36:10.5045044+01:00\"\n        },\n        \"commit\": {\n          \"description\": \"The git commit hash on which this binary was built.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Commit\",\n          \"example\": \"ae9512b6b6feea56a110d59a3353ea3b9c293864\"\n        },\n        \"version\": {\n          \"description\": \"The current version.\",\n          \"type\": \"string\",\n          \"x-go-name\": \"Version\",\n          \"example\": \"5.2.6\"\n        }\n      },\n      \"x-go-package\": \"github.com/gotify/server/v2/model\"\n    }\n  },\n  \"securityDefinitions\": {\n    \"appTokenAuthorizationHeader\": {\n      \"description\": \"Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`.\",\n      \"type\": \"apiKey\",\n      \"name\": \"Authorization\",\n      \"in\": \"header\"\n    },\n    \"appTokenHeader\": {\n      \"type\": \"apiKey\",\n      \"name\": \"X-Gotify-Key\",\n      \"in\": \"header\"\n    },\n    \"appTokenQuery\": {\n      \"type\": \"apiKey\",\n      \"name\": \"token\",\n      \"in\": \"query\"\n    },\n    \"basicAuth\": {\n      \"type\": \"basic\"\n    },\n    \"clientTokenAuthorizationHeader\": {\n      \"description\": \"Enter a client token with the `Bearer` prefix, e.g. `Bearer Cxxxxxxxxxx`.\",\n      \"type\": \"apiKey\",\n      \"name\": \"Authorization\",\n      \"in\": \"header\"\n    },\n    \"clientTokenHeader\": {\n      \"type\": \"apiKey\",\n      \"name\": \"X-Gotify-Key\",\n      \"in\": \"header\"\n    },\n    \"clientTokenQuery\": {\n      \"type\": \"apiKey\",\n      \"name\": \"token\",\n      \"in\": \"query\"\n    }\n  }\n}"
  },
  {
    "path": "docs/swagger.go",
    "content": "package docs\n\nimport (\n\t_ \"embed\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/location\"\n)\n\n//go:embed spec.json\nvar spec string\n\n// Serve serves the documentation.\nfunc Serve(ctx *gin.Context) {\n\tbase := location.Get(ctx).Host\n\tif basePathFromQuery := ctx.Query(\"base\"); basePathFromQuery != \"\" {\n\t\tbase = basePathFromQuery\n\t}\n\tctx.Writer.WriteString(getSwaggerJSON(base))\n}\n\nfunc getSwaggerJSON(base string) string {\n\treturn strings.Replace(spec, \"localhost\", base, 1)\n}\n"
  },
  {
    "path": "docs/swagger_test.go",
    "content": "package docs\n\nimport (\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestServe(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\twithURL(ctx, \"http\", \"example.com\")\n\n\tctx.Request = httptest.NewRequest(\"GET\", \"/swagger?base=\"+url.QueryEscape(\"127.0.0.1/proxy/\"), nil)\n\n\tServe(ctx)\n\n\tcontent := recorder.Body.String()\n\tassert.NotEmpty(t, content)\n\tassert.Contains(t, content, \"127.0.0.1/proxy/\")\n}\n\nfunc withURL(ctx *gin.Context, scheme, host string) {\n\tctx.Set(\"location\", &url.URL{Scheme: scheme, Host: host})\n}\n"
  },
  {
    "path": "docs/ui.go",
    "content": "package docs\n\nimport \"github.com/gin-gonic/gin\"\n\nvar ui = `\n<!-- HTML for static distribution bundle build -->\n<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <title>Swagger UI</title>\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.css\" >\n    <link rel=\"icon\" type=\"image/png\" href=\"./favicon-32x32.png\" sizes=\"32x32\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"./favicon-16x16.png\" sizes=\"16x16\" />\n    <style>\n      html\n      {\n        box-sizing: border-box;\n        overflow: -moz-scrollbars-vertical;\n        overflow-y: scroll;\n      }\n      *,\n      *:before,\n      *:after\n      {\n        box-sizing: inherit;\n      }\n      body\n      {\n        margin:0;\n        background: #fafafa;\n      }\n    </style>\n  </head>\n\n  <body>\n    <div id=\"swagger-ui\"></div>\n\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-bundle.js\"> </script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-standalone-preset.js\"> </script>\n    <script>\n    function getBaseURL() {\n      var path = window.location.pathname\n      path = path.substr(0, path.lastIndexOf('/')+1)\n      return window.location.host + path;\n    }\n    window.onload = function() {\n      // Begin Swagger UI call region\n      const ui = SwaggerUIBundle({\n        url: \"swagger?base=\"+encodeURIComponent(getBaseURL()),\n        dom_id: '#swagger-ui',\n        deepLinking: true,\n        presets: [\n          SwaggerUIBundle.presets.apis,\n          SwaggerUIStandalonePreset\n        ],\n        plugins: [\n          SwaggerUIBundle.plugins.DownloadUrl\n        ],\n        layout: \"StandaloneLayout\"\n      })\n      // End Swagger UI call region\n      window.ui = ui\n    }\n  </script>\n  </body>\n</html>\n`\n\n// UI serves the swagger ui.\nfunc UI(ctx *gin.Context) {\n\tctx.Writer.WriteString(ui)\n}\n"
  },
  {
    "path": "docs/ui_test.go",
    "content": "package docs\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestUI(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\twithURL(ctx, \"http\", \"example.com\")\n\n\tctx.Request = httptest.NewRequest(\"GET\", \"/swagger\", nil)\n\n\tUI(ctx)\n\n\tcontent := recorder.Body.String()\n\tassert.NotEmpty(t, content)\n}\n"
  },
  {
    "path": "error/handler.go",
    "content": "package error\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-playground/validator/v10\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// Handler creates a gin middleware for handling errors.\nfunc Handler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.Next()\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tfor _, e := range c.Errors {\n\t\t\t\tswitch e.Type {\n\t\t\t\tcase gin.ErrorTypeBind:\n\t\t\t\t\terrs, ok := e.Err.(validator.ValidationErrors)\n\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\twriteError(c, e.Error())\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tvar stringErrors []string\n\t\t\t\t\tfor _, err := range errs {\n\t\t\t\t\t\tstringErrors = append(stringErrors, validationErrorToText(err))\n\t\t\t\t\t}\n\t\t\t\t\twriteError(c, strings.Join(stringErrors, \"; \"))\n\t\t\t\tdefault:\n\t\t\t\t\twriteError(c, e.Err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc validationErrorToText(e validator.FieldError) string {\n\trunes := []rune(e.Field())\n\trunes[0] = unicode.ToLower(runes[0])\n\tfieldName := string(runes)\n\tswitch e.Tag() {\n\tcase \"required\":\n\t\treturn fmt.Sprintf(\"Field '%s' is required\", fieldName)\n\tcase \"max\":\n\t\treturn fmt.Sprintf(\"Field '%s' must be less or equal to %s\", fieldName, e.Param())\n\tcase \"min\":\n\t\treturn fmt.Sprintf(\"Field '%s' must be more or equal to %s\", fieldName, e.Param())\n\t}\n\treturn fmt.Sprintf(\"Field '%s' is not valid\", fieldName)\n}\n\nfunc writeError(ctx *gin.Context, errString string) {\n\tstatus := http.StatusBadRequest\n\tif ctx.Writer.Status() != http.StatusOK {\n\t\tstatus = ctx.Writer.Status()\n\t}\n\tctx.JSON(status, &model.Error{Error: http.StatusText(status), ErrorCode: status, ErrorDescription: errString})\n}\n"
  },
  {
    "path": "error/handler_test.go",
    "content": "package error\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestDefaultErrorInternal(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trec := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(rec)\n\tctx.AbortWithError(500, errors.New(\"something went wrong\"))\n\n\tHandler()(ctx)\n\n\tassertJSONResponse(t, rec, 500, `{\"errorCode\":500, \"errorDescription\":\"something went wrong\", \"error\":\"Internal Server Error\"}`)\n}\n\nfunc TestBindingErrorDefault(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trec := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(rec)\n\tctx.AbortWithError(400, errors.New(\"you need todo something\")).SetType(gin.ErrorTypeBind)\n\n\tHandler()(ctx)\n\n\tassertJSONResponse(t, rec, 400, `{\"errorCode\":400, \"errorDescription\":\"you need todo something\", \"error\":\"Bad Request\"}`)\n}\n\nfunc TestDefaultErrorBadRequest(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trec := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(rec)\n\tctx.AbortWithError(400, errors.New(\"you need todo something\"))\n\n\tHandler()(ctx)\n\n\tassertJSONResponse(t, rec, 400, `{\"errorCode\":400, \"errorDescription\":\"you need todo something\", \"error\":\"Bad Request\"}`)\n}\n\ntype testValidate struct {\n\tUsername string `json:\"username\" binding:\"required\"`\n\tMail     string `json:\"mail\" binding:\"email\"`\n\tAge      int    `json:\"age\" binding:\"max=100\"`\n\tLimit    int    `json:\"limit\" binding:\"min=50\"`\n}\n\nfunc TestValidationError(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trec := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(rec)\n\tctx.Request = httptest.NewRequest(\"GET\", \"/uri\", nil)\n\n\tassert.Error(t, ctx.Bind(&testValidate{Age: 150, Limit: 20}))\n\tHandler()(ctx)\n\n\terr := new(model.Error)\n\tjson.NewDecoder(rec.Body).Decode(err)\n\tassert.Equal(t, 400, rec.Code)\n\tassert.Equal(t, \"Bad Request\", err.Error)\n\tassert.Equal(t, 400, err.ErrorCode)\n\tassert.Contains(t, err.ErrorDescription, \"Field 'username' is required\")\n\tassert.Contains(t, err.ErrorDescription, \"Field 'mail' is not valid\")\n\tassert.Contains(t, err.ErrorDescription, \"Field 'age' must be less or equal to 100\")\n\tassert.Contains(t, err.ErrorDescription, \"Field 'limit' must be more or equal to 50\")\n}\n\nfunc assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, code int, json string) {\n\tbytes, _ := io.ReadAll(rec.Body)\n\tassert.Equal(t, code, rec.Code)\n\tassert.JSONEq(t, json, string(bytes))\n}\n"
  },
  {
    "path": "error/notfound.go",
    "content": "package error\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// NotFound creates a gin middleware for handling page not found.\nfunc NotFound() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.JSON(http.StatusNotFound, &model.Error{\n\t\t\tError:            http.StatusText(http.StatusNotFound),\n\t\t\tErrorCode:        http.StatusNotFound,\n\t\t\tErrorDescription: \"page not found\",\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "error/notfound_test.go",
    "content": "package error\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/mode\"\n)\n\nfunc TestNotFound(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\trec := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(rec)\n\n\tNotFound()(ctx)\n\n\tassertJSONResponse(t, rec, 404, `{\"errorCode\":404, \"errorDescription\":\"page not found\", \"error\":\"Not Found\"}`)\n}\n"
  },
  {
    "path": "fracdex/fracdex.go",
    "content": "// Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex\npackage fracdex\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n)\n\nconst (\n\tbase62Digits = \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\"\n\tsmallestInt  = \"A00000000000000000000000000\"\n\tzero         = \"a0\"\n)\n\n// KeyBetween returns a key that sorts lexicographically between a and b.\n// Either a or b can be empty strings. If a is empty it indicates smallest key,\n// If b is empty it indicates largest key.\n// b must be empty string or > a.\nfunc KeyBetween(a, b string) (string, error) {\n\tif a != \"\" {\n\t\terr := validateOrderKey(a)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tif b != \"\" {\n\t\terr := validateOrderKey(b)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tif a != \"\" && b != \"\" && a >= b {\n\t\treturn \"\", fmt.Errorf(\"%s >= %s\", a, b)\n\t}\n\tif a == \"\" {\n\t\tif b == \"\" {\n\t\t\treturn zero, nil\n\t\t}\n\n\t\tib, err := getIntPart(b)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tfb := b[len(ib):]\n\t\tif ib == smallestInt {\n\t\t\treturn ib + midpoint(\"\", fb), nil\n\t\t}\n\t\tif ib < b {\n\t\t\treturn ib, nil\n\t\t}\n\t\tres, err := decrementInt(ib)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif res == \"\" {\n\t\t\treturn \"\", errors.New(\"range underflow\")\n\t\t}\n\t\treturn res, nil\n\t}\n\n\tif b == \"\" {\n\t\tia, err := getIntPart(a)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tfa := a[len(ia):]\n\t\ti, err := incrementInt(ia)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif i == \"\" {\n\t\t\treturn ia + midpoint(fa, \"\"), nil\n\t\t}\n\t\treturn i, nil\n\t}\n\n\tia, err := getIntPart(a)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tfa := a[len(ia):]\n\tib, err := getIntPart(b)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tfb := b[len(ib):]\n\tif ia == ib {\n\t\treturn ia + midpoint(fa, fb), nil\n\t}\n\ti, err := incrementInt(ia)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif i == \"\" {\n\t\treturn \"\", errors.New(\"range overflow\")\n\t}\n\tif i < b {\n\t\treturn i, nil\n\t}\n\treturn ia + midpoint(fa, \"\"), nil\n}\n\n// `a < b` lexicographically if `b` is non-empty.\n// a == \"\" means first possible string.\n// b == \"\" means last possible string.\nfunc midpoint(a, b string) string {\n\tif b != \"\" {\n\t\t// remove longest common prefix.  pad `a` with 0s as we\n\t\t// go.  note that we don't need to pad `b`, because it can't\n\t\t// end before `a` while traversing the common prefix.\n\t\ti := 0\n\t\tfor ; i < len(b); i++ {\n\t\t\tc := byte('0')\n\t\t\tif len(a) > i {\n\t\t\t\tc = a[i]\n\t\t\t}\n\t\t\tif c != b[i] {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif i > 0 {\n\t\t\tif i > len(a) {\n\t\t\t\treturn b[0:i] + midpoint(\"\", b[i:])\n\t\t\t}\n\t\t\treturn b[0:i] + midpoint(a[i:], b[i:])\n\t\t}\n\t}\n\n\t// first digits (or lack of digit) are different\n\tdigitA := 0\n\tif a != \"\" {\n\t\tdigitA = strings.Index(base62Digits, string(a[0]))\n\t}\n\tdigitB := len(base62Digits)\n\tif b != \"\" {\n\t\tdigitB = strings.Index(base62Digits, string(b[0]))\n\t}\n\tif digitB-digitA > 1 {\n\t\tmidDigit := int(math.Round(0.5 * float64(digitA+digitB)))\n\t\treturn string(base62Digits[midDigit])\n\t}\n\n\t// first digits are consecutive\n\tif len(b) > 1 {\n\t\treturn b[0:1]\n\t}\n\n\t// `b` is empty or has length 1 (a single digit).\n\t// the first digit of `a` is the previous digit to `b`,\n\t// or 9 if `b` is null.\n\t// given, for example, midpoint('49', '5'), return\n\t// '4' + midpoint('9', null), which will become\n\t// '4' + '9' + midpoint('', null), which is '495'\n\tsa := \"\"\n\tif len(a) > 0 {\n\t\tsa = a[1:]\n\t}\n\treturn string(base62Digits[digitA]) + midpoint(sa, \"\")\n}\n\nfunc validateInt(i string) error {\n\texp, err := getIntLen(i[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(i) != exp {\n\t\treturn fmt.Errorf(\"invalid integer part of order key: %s\", i)\n\t}\n\treturn nil\n}\n\nfunc getIntLen(head byte) (int, error) {\n\tif head >= 'a' && head <= 'z' {\n\t\treturn int(head - 'a' + 2), nil\n\t} else if head >= 'A' && head <= 'Z' {\n\t\treturn int('Z' - head + 2), nil\n\t} else {\n\t\treturn 0, fmt.Errorf(\"invalid order key head: %s\", string(head))\n\t}\n}\n\nfunc getIntPart(key string) (string, error) {\n\tintPartLen, err := getIntLen(key[0])\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif intPartLen > len(key) {\n\t\treturn \"\", fmt.Errorf(\"invalid order key: %s\", key)\n\t}\n\treturn key[0:intPartLen], nil\n}\n\nfunc validateOrderKey(key string) error {\n\tif key == smallestInt {\n\t\treturn fmt.Errorf(\"invalid order key: %s\", key)\n\t}\n\t// getIntPart will return error if the first character is bad,\n\t// or the key is too short.  we'd call it to check these things\n\t// even if we didn't need the result\n\ti, err := getIntPart(key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tf := key[len(i):]\n\tif strings.HasSuffix(f, \"0\") {\n\t\treturn fmt.Errorf(\"invalid order key: %s\", key)\n\t}\n\treturn nil\n}\n\n// returns error if x is invalid, or if range is exceeded.\nfunc incrementInt(x string) (string, error) {\n\terr := validateInt(x)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdigs := strings.Split(x, \"\")\n\thead := digs[0]\n\tdigs = digs[1:]\n\tcarry := true\n\tfor i := len(digs) - 1; carry && i >= 0; i-- {\n\t\td := strings.Index(base62Digits, digs[i]) + 1\n\t\tif d == len(base62Digits) {\n\t\t\tdigs[i] = \"0\"\n\t\t} else {\n\t\t\tdigs[i] = string(base62Digits[d])\n\t\t\tcarry = false\n\t\t}\n\t}\n\tif carry {\n\t\tif head == \"Z\" {\n\t\t\treturn \"a0\", nil\n\t\t}\n\t\tif head == \"z\" {\n\t\t\treturn \"\", nil\n\t\t}\n\t\th := string(head[0] + 1)\n\t\tif h > \"a\" {\n\t\t\tdigs = append(digs, \"0\")\n\t\t} else {\n\t\t\tdigs = digs[1:]\n\t\t}\n\t\treturn h + strings.Join(digs, \"\"), nil\n\t}\n\treturn head + strings.Join(digs, \"\"), nil\n}\n\nfunc decrementInt(x string) (string, error) {\n\terr := validateInt(x)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdigs := strings.Split(x, \"\")\n\thead := digs[0]\n\tdigs = digs[1:]\n\tborrow := true\n\tfor i := len(digs) - 1; borrow && i >= 0; i-- {\n\t\td := strings.Index(base62Digits, digs[i]) - 1\n\t\tif d == -1 {\n\t\t\tdigs[i] = string(base62Digits[len(base62Digits)-1])\n\t\t} else {\n\t\t\tdigs[i] = string(base62Digits[d])\n\t\t\tborrow = false\n\t\t}\n\t}\n\n\tif borrow {\n\t\tif head == \"a\" {\n\t\t\treturn \"Z\" + string(base62Digits[len(base62Digits)-1]), nil\n\t\t}\n\t\tif head == \"A\" {\n\t\t\treturn \"\", nil\n\t\t}\n\t\th := head[0] - 1\n\t\tif h < 'Z' {\n\t\t\tdigs = append(digs, string(base62Digits[len(base62Digits)-1]))\n\t\t} else {\n\t\t\tdigs = digs[1:]\n\t\t}\n\t\treturn string(h) + strings.Join(digs, \"\"), nil\n\t}\n\n\treturn head + strings.Join(digs, \"\"), nil\n}\n\n// Float64Approx converts a key as generated by KeyBetween() to a float64.\n// Because the range of keys is far larger than float64 can represent\n// accurately, this is necessarily approximate. But for many use cases it should\n// be, as they say, close enough for jazz.\nfunc Float64Approx(key string) (float64, error) {\n\tif key == \"\" {\n\t\treturn 0.0, errors.New(\"invalid order key\")\n\t}\n\n\terr := validateOrderKey(key)\n\tif err != nil {\n\t\treturn 0.0, err\n\t}\n\n\tip, err := getIntPart(key)\n\tif err != nil {\n\t\treturn 0.0, err\n\t}\n\n\tdigs := strings.Split(ip, \"\")\n\thead := digs[0]\n\tdigs = digs[1:]\n\trv := float64(0)\n\tfor i := 0; i < len(digs); i++ {\n\t\td := digs[len(digs)-i-1]\n\t\tp := strings.Index(base62Digits, d)\n\t\tif p == -1 {\n\t\t\treturn 0.0, fmt.Errorf(\"invalid order key: %s\", key)\n\t\t}\n\t\trv += math.Pow(float64(len(base62Digits)), float64(i)) * float64(p)\n\t}\n\n\tfp := key[len(ip):]\n\tfor i, d := range fp {\n\t\tp := strings.Index(base62Digits, string(d))\n\t\tif p == -1 {\n\t\t\treturn 0.0, fmt.Errorf(\"invalid key: %s\", key)\n\t\t}\n\t\trv += (float64(p) / math.Pow(float64(len(base62Digits)), float64(i+1)))\n\t}\n\n\tif head < \"a\" {\n\t\trv *= -1\n\t}\n\n\treturn rv, nil\n}\n\n// NKeysBetween returns n keys between a and b that sorts lexicographically.\n// Either a or b can be empty strings. If a is empty it indicates smallest key,\n// If b is empty it indicates largest key.\n// b must be empty string or > a.\nfunc NKeysBetween(a, b string, n uint) ([]string, error) {\n\tif n == 0 {\n\t\treturn []string{}, nil\n\t}\n\tif n == 1 {\n\t\tc, err := KeyBetween(a, b)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn []string{c}, nil\n\t}\n\tif b == \"\" {\n\t\tc, err := KeyBetween(a, b)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult := make([]string, 0, n)\n\t\tresult = append(result, c)\n\t\tfor i := 0; i < int(n)-1; i++ {\n\t\t\tc, err = KeyBetween(c, b)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tresult = append(result, c)\n\t\t}\n\t\treturn result, nil\n\t}\n\tif a == \"\" {\n\t\tc, err := KeyBetween(a, b)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult := make([]string, 0, n)\n\t\tresult = append(result, c)\n\t\tfor i := 0; i < int(n)-1; i++ {\n\t\t\tc, err = KeyBetween(a, c)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tresult = append(result, c)\n\t\t}\n\t\treverse(result)\n\t\treturn result, nil\n\t}\n\tmid := n / 2\n\tc, err := KeyBetween(a, b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]string, 0, n)\n\t{\n\t\tr, err := NKeysBetween(a, c, mid)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, r...)\n\t}\n\tresult = append(result, c)\n\t{\n\t\tr, err := NKeysBetween(c, b, n-mid-1)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, r...)\n\t}\n\treturn result, nil\n}\n\nfunc reverse(values []string) {\n\tfor i := 0; i < len(values)/2; i++ {\n\t\tj := len(values) - i - 1\n\t\tvalues[i], values[j] = values[j], values[i]\n\t}\n}\n"
  },
  {
    "path": "fracdex/fracdex_test.go",
    "content": "// Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex\npackage fracdex\n\nimport (\n\t\"math\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestKeys(t *testing.T) {\n\tassert := assert.New(t)\n\n\ttest := func(a, b, exp string) {\n\t\tact, err := KeyBetween(a, b)\n\t\tif err != nil {\n\t\t\tassert.Equal(\"\", act)\n\t\t\tassert.Equal(exp, err.Error())\n\t\t} else {\n\t\t\tassert.Nil(err)\n\t\t\tassert.Equal(exp, act)\n\t\t}\n\t}\n\n\ttest(\"\", \"\", \"a0\")\n\ttest(\"\", \"a0\", \"Zz\")\n\ttest(\"\", \"Zz\", \"Zy\")\n\ttest(\"a0\", \"\", \"a1\")\n\ttest(\"a1\", \"\", \"a2\")\n\ttest(\"a0\", \"a1\", \"a0V\")\n\ttest(\"a1\", \"a2\", \"a1V\")\n\ttest(\"a0V\", \"a1\", \"a0l\")\n\ttest(\"Zz\", \"a0\", \"ZzV\")\n\ttest(\"Zz\", \"a1\", \"a0\")\n\ttest(\"\", \"Y00\", \"Xzzz\")\n\ttest(\"bzz\", \"\", \"c000\")\n\ttest(\"a0\", \"a0V\", \"a0G\")\n\ttest(\"a0\", \"a0G\", \"a08\")\n\ttest(\"b125\", \"b129\", \"b127\")\n\ttest(\"a0\", \"a1V\", \"a1\")\n\ttest(\"Zz\", \"a01\", \"a0\")\n\ttest(\"\", \"a0V\", \"a0\")\n\ttest(\"\", \"b999\", \"b99\")\n\ttest(\"aV\", \"aV0V\", \"aV0G\")\n\ttest(\n\t\t\"\",\n\t\t\"A00000000000000000000000000\",\n\t\t\"invalid order key: A00000000000000000000000000\",\n\t)\n\ttest(\"\", \"A000000000000000000000000001\", \"A000000000000000000000000000V\")\n\ttest(\"zzzzzzzzzzzzzzzzzzzzzzzzzzy\", \"\", \"zzzzzzzzzzzzzzzzzzzzzzzzzzz\")\n\ttest(\"zzzzzzzzzzzzzzzzzzzzzzzzzzz\", \"\", \"zzzzzzzzzzzzzzzzzzzzzzzzzzzV\")\n\ttest(\"a00\", \"\", \"invalid order key: a00\")\n\ttest(\"a00\", \"a1\", \"invalid order key: a00\")\n\ttest(\"0\", \"1\", \"invalid order key head: 0\")\n\ttest(\"a1\", \"a0\", \"a1 >= a0\")\n}\n\nfunc TestNKeys(t *testing.T) {\n\tassert := assert.New(t)\n\n\ttest := func(a, b string, n uint, exp string) {\n\t\tactSlice, err := NKeysBetween(a, b, n)\n\t\tact := strings.Join(actSlice, \" \")\n\t\tif err != nil {\n\t\t\tassert.Equal(\"\", act)\n\t\t\tassert.Equal(exp, err.Error())\n\t\t} else {\n\t\t\tassert.Nil(err)\n\t\t\tassert.Equal(exp, act)\n\t\t}\n\t}\n\ttest(\"\", \"\", 5, \"a0 a1 a2 a3 a4\")\n\ttest(\"a4\", \"\", 10, \"a5 a6 a7 a8 a9 aA aB aC aD aE\")\n\ttest(\"\", \"a0\", 5, \"Zv Zw Zx Zy Zz\")\n\ttest(\n\t\t\"a0\",\n\t\t\"a2\",\n\t\t20,\n\t\t\"a04 a08 a0G a0K a0O a0V a0Z a0d a0l a0t a1 a14 a18 a1G a1O a1V a1Z a1d a1l a1t\",\n\t)\n}\n\nfunc TestToFloat64Approx(t *testing.T) {\n\tassert := assert.New(t)\n\n\ttest := func(key string, exp float64, expErr string) {\n\t\tact, err := Float64Approx(key)\n\t\tif expErr != \"\" {\n\t\t\tassert.Equal(0.0, act)\n\t\t\tassert.Equal(expErr, err.Error())\n\t\t} else {\n\t\t\tassert.Equal(exp, act)\n\t\t\tassert.NoError(err)\n\t\t}\n\t}\n\n\ttest(\"a0\", 0.0, \"\")\n\ttest(\"a1\", 1.0, \"\")\n\ttest(\"az\", 61.0, \"\")\n\ttest(\"b10\", 62.0, \"\")\n\ttest(\"z20000000000000000000000000\", math.Pow(62.0, 25.0)*2.0, \"\")\n\ttest(\"Z1\", -1.0, \"\")\n\ttest(\"Zz\", -61.0, \"\")\n\ttest(\"Y10\", -62.0, \"\")\n\ttest(\"A20000000000000000000000000\", math.Pow(62.0, 25.0)*-2.0, \"\")\n\n\ttest(\"a0V\", 0.5, \"\")\n\ttest(\"a00V\", 31.0/math.Pow(62.0, 2.0), \"\")\n\ttest(\"aVV\", 31.5, \"\")\n\ttest(\"ZVV\", -31.5, \"\")\n\n\ttest(\"\", 0.0, \"invalid order key\")\n\ttest(\"!\", 0.0, \"invalid order key head: !\")\n\ttest(\"a400\", 0.0, \"invalid order key: a400\")\n\ttest(\"a!\", 0.0, \"invalid order key: a!\")\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/gotify/server/v2\n\nrequire (\n\tgithub.com/fortytw2/leaktest v1.3.0\n\tgithub.com/gin-contrib/cors v1.7.6\n\tgithub.com/gin-contrib/gzip v1.2.5\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/go-playground/validator/v10 v10.30.1\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/gotify/location v0.0.0-20170722210143-03bc4ad20437\n\tgithub.com/gotify/plugin-api v1.0.0\n\tgithub.com/h2non/filetype v1.1.3\n\tgithub.com/jinzhu/configor v1.2.2\n\tgithub.com/mattn/go-isatty v0.0.20\n\tgithub.com/robfig/cron v1.2.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgolang.org/x/crypto v0.48.0\n\tgopkg.in/yaml.v3 v3.0.1\n\tgorm.io/driver/mysql v1.6.0\n\tgorm.io/driver/postgres v1.6.0\n\tgorm.io/driver/sqlite v1.6.0\n\tgorm.io/gorm v1.31.1\n)\n\nrequire (\n\tfilippo.io/edwards25519 v1.1.0 // indirect\n\tgithub.com/BurntSushi/toml v1.5.0 // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.12 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-sql-driver/mysql v1.9.3 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/pgx/v5 v5.7.6 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.32 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/quic-go/qpack v0.6.0 // indirect\n\tgithub.com/quic-go/quic-go v0.59.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgolang.org/x/arch v0.22.0 // indirect\n\tgolang.org/x/net v0.51.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgolang.org/x/text v0.34.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.10 // indirect\n)\n\ngo 1.25.0\n\ntoolchain go1.26.0\n"
  },
  {
    "path": "go.sum",
    "content": "filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=\ngithub.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=\ngithub.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=\ngithub.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=\ngithub.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=\ngithub.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=\ngithub.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=\ngithub.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=\ngithub.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=\ngithub.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=\ngithub.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=\ngithub.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=\ngithub.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=\ngithub.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=\ngithub.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms=\ngithub.com/gotify/location v0.0.0-20170722210143-03bc4ad20437/go.mod h1:5JgfyQg+71Ck3uXX/4FBHc4YxdKZ9shU8gs2AUj7Nj0=\ngithub.com/gotify/plugin-api v1.0.0 h1:kab40p2TEPLzjmcafOc7JOz75aTsYQyS2PXtElH8xmI=\ngithub.com/gotify/plugin-api v1.0.0/go.mod h1:xZfEyqVK/Zvu3RwA/CtpuiwFmzFDxifrrqMaH9BHnyU=\ngithub.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=\ngithub.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=\ngithub.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=\ngithub.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=\ngithub.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=\ngithub.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=\ngithub.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=\ngithub.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=\ngithub.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=\ngithub.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=\ngithub.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=\ngithub.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=\ngithub.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=\ngithub.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngo.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=\ngo.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngolang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=\ngolang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=\ngolang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=\ngolang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=\ngopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=\ngorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=\ngorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=\ngorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=\ngorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=\ngorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=\ngorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=\ngorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=\n"
  },
  {
    "path": "mode/mode.go",
    "content": "package mode\n\nimport \"github.com/gin-gonic/gin\"\n\nconst (\n\t// Dev for development mode.\n\tDev = \"dev\"\n\t// Prod for production mode.\n\tProd = \"prod\"\n\t// TestDev used for tests.\n\tTestDev = \"testdev\"\n)\n\nvar mode = Dev\n\n// Set sets the new mode.\nfunc Set(newMode string) {\n\tmode = newMode\n\tupdateGinMode()\n}\n\n// Get returns the current mode.\nfunc Get() string {\n\treturn mode\n}\n\n// IsDev returns true if the current mode is dev mode.\nfunc IsDev() bool {\n\treturn Get() == Dev || Get() == TestDev\n}\n\nfunc updateGinMode() {\n\tswitch Get() {\n\tcase Dev:\n\t\tgin.SetMode(gin.DebugMode)\n\tcase TestDev:\n\t\tgin.SetMode(gin.TestMode)\n\tcase Prod:\n\t\tgin.SetMode(gin.ReleaseMode)\n\tdefault:\n\t\tpanic(\"unknown mode\")\n\t}\n}\n"
  },
  {
    "path": "mode/mode_test.go",
    "content": "package mode\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestDevMode(t *testing.T) {\n\tSet(Dev)\n\tassert.Equal(t, Get(), Dev)\n\tassert.True(t, IsDev())\n\tassert.Equal(t, gin.Mode(), gin.DebugMode)\n}\n\nfunc TestTestDevMode(t *testing.T) {\n\tSet(TestDev)\n\tassert.Equal(t, Get(), TestDev)\n\tassert.True(t, IsDev())\n\tassert.Equal(t, gin.Mode(), gin.TestMode)\n}\n\nfunc TestProdMode(t *testing.T) {\n\tSet(Prod)\n\tassert.Equal(t, Get(), Prod)\n\tassert.False(t, IsDev())\n\tassert.Equal(t, gin.Mode(), gin.ReleaseMode)\n}\n\nfunc TestInvalidMode(t *testing.T) {\n\tassert.Panics(t, func() {\n\t\tSet(\"asdasda\")\n\t})\n}\n"
  },
  {
    "path": "model/application.go",
    "content": "package model\n\nimport \"time\"\n\n// Application Model\n//\n// The Application holds information about an app which can send notifications.\n//\n// swagger:model Application\ntype Application struct {\n\t// The application id.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 5\n\tID uint `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\t// The application token. Can be used as `appToken`. See Authentication.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: AWH0wZ5r0Mbac.r\n\tToken  string `gorm:\"type:varchar(180);uniqueIndex:uix_applications_token\" json:\"token\"`\n\tUserID uint   `gorm:\"index;uniqueIndex:uix_application_user_id_sort_key,priority:1\" json:\"-\"`\n\t// The application name. This is how the application should be displayed to the user.\n\t//\n\t// required: true\n\t// example: Backup Server\n\tName string `gorm:\"type:text\" form:\"name\" query:\"name\" json:\"name\" binding:\"required\"`\n\t// The description of the application.\n\t//\n\t// required: true\n\t// example: Backup server for the interwebs\n\tDescription string `gorm:\"type:text\" form:\"description\" query:\"description\" json:\"description\"`\n\t// Whether the application is an internal application. Internal applications should not be deleted.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: false\n\tInternal bool `form:\"internal\" query:\"internal\" json:\"internal\"`\n\t// The image of the application.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: image/image.jpeg\n\tImage    string            `gorm:\"type:text\" json:\"image\"`\n\tMessages []MessageExternal `gorm:\"-\" json:\"-\"`\n\t// The default priority of messages sent by this application. Defaults to 0.\n\t//\n\t// required: false\n\t// example: 4\n\tDefaultPriority int `form:\"defaultPriority\" query:\"defaultPriority\" json:\"defaultPriority\"`\n\t// The last time the application token was used.\n\t//\n\t// read only: true\n\t// example: 2019-01-01T00:00:00Z\n\tLastUsed *time.Time `json:\"lastUsed\"`\n\t// The sort key of this application. Uses fractional indexing.\n\t//\n\t// required: true\n\t// example: a1\n\tSortKey string `gorm:\"type:bytes;uniqueIndex:uix_application_user_id_sort_key,priority:2,length:255\" form:\"sortKey\" query:\"sortKey\" json:\"sortKey\"`\n}\n"
  },
  {
    "path": "model/client.go",
    "content": "package model\n\nimport \"time\"\n\n// Client Model\n//\n// The Client holds information about a device which can receive notifications (and other stuff).\n//\n// swagger:model Client\ntype Client struct {\n\t// The client id.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 5\n\tID uint `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n\t// The client token. Can be used as `clientToken`. See Authentication.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: CWH0wZ5r0Mbac.r\n\tToken  string `gorm:\"type:varchar(180);uniqueIndex:uix_clients_token\" json:\"token\"`\n\tUserID uint   `gorm:\"index\" json:\"-\"`\n\t// The client name. This is how the client should be displayed to the user.\n\t//\n\t// required: true\n\t// example: Android Phone\n\tName string `gorm:\"type:text\" form:\"name\" query:\"name\" json:\"name\" binding:\"required\"`\n\t// The last time the client token was used.\n\t//\n\t// read only: true\n\t// example: 2019-01-01T00:00:00Z\n\tLastUsed *time.Time `json:\"lastUsed\"`\n}\n"
  },
  {
    "path": "model/error.go",
    "content": "package model\n\n// Error Model\n//\n// The Error contains error relevant information.\n//\n// swagger:model Error\ntype Error struct {\n\t// The general error message\n\t//\n\t// required: true\n\t// example: Unauthorized\n\tError string `json:\"error\"`\n\t// The http error code.\n\t//\n\t// required: true\n\t// example: 401\n\tErrorCode int `json:\"errorCode\"`\n\t// The http error code.\n\t//\n\t// required: true\n\t// example: you need to provide a valid access token or user credentials to access this api\n\tErrorDescription string `json:\"errorDescription\"`\n}\n"
  },
  {
    "path": "model/health.go",
    "content": "package model\n\n// Health Model\n//\n// Health represents how healthy the application is.\n//\n// swagger:model Health\ntype Health struct {\n\t// The health of the overall application.\n\t//\n\t// required: true\n\t// example: green\n\tHealth string `json:\"health\"`\n\t// The health of the database connection.\n\t//\n\t// required: true\n\t// example: green\n\tDatabase string `json:\"database\"`\n}\n\nconst (\n\t// StatusGreen everything is alright.\n\tStatusGreen = \"green\"\n\t// StatusOrange some things are alright.\n\tStatusOrange = \"orange\"\n\t// StatusRed nothing is alright.\n\tStatusRed = \"red\"\n)\n"
  },
  {
    "path": "model/message.go",
    "content": "package model\n\nimport (\n\t\"time\"\n)\n\n// Message holds information about a message.\ntype Message struct {\n\tID            uint `gorm:\"autoIncrement;primaryKey;index\"`\n\tApplicationID uint\n\tMessage       string `gorm:\"type:text\"`\n\tTitle         string `gorm:\"type:text\"`\n\tPriority      int\n\tExtras        []byte\n\tDate          time.Time\n}\n\n// MessageExternal Model\n//\n// The MessageExternal holds information about a message which was sent by an Application.\n//\n// swagger:model Message\ntype MessageExternal struct {\n\t// The message id.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 25\n\tID uint `json:\"id\"`\n\t// The application id that send this message.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 5\n\tApplicationID uint `json:\"appid\"`\n\t// The message. Markdown (excluding html) is allowed.\n\t//\n\t// required: true\n\t// example: **Backup** was successfully finished.\n\tMessage string `form:\"message\" query:\"message\" json:\"message\" binding:\"required\"`\n\t// The title of the message.\n\t//\n\t// example: Backup\n\tTitle string `form:\"title\" query:\"title\" json:\"title\"`\n\t// The priority of the message. If unset, then the default priority of the\n\t// application will be used.\n\t//\n\t// example: 2\n\tPriority *int `form:\"priority\" query:\"priority\" json:\"priority\"`\n\t// The extra data sent along the message.\n\t//\n\t// The extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type.\n\t//\n\t// The keys should be in the following format: &lt;top-namespace&gt;::[&lt;sub-namespace&gt;::]&lt;action&gt;\n\t//\n\t// 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.\n\t//\n\t// example: {\"home::appliances::thermostat::change_temperature\":{\"temperature\":23},\"home::appliances::lighting::on\":{\"brightness\":15}}\n\tExtras map[string]interface{} `form:\"-\" query:\"-\" json:\"extras,omitempty\"`\n\t// The date the message was created.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 2018-02-27T19:36:10.5045044+01:00\n\tDate time.Time `json:\"date\"`\n}\n"
  },
  {
    "path": "model/paging.go",
    "content": "package model\n\n// Paging Model\n//\n// The Paging holds information about the limit and making requests to the next page.\n//\n// swagger:model Paging\ntype Paging struct {\n\t// The request url for the next page. Empty/Null when no next page is available.\n\t//\n\t// read only: true\n\t// required: false\n\t// example: http://example.com/message?limit=50&since=123456\n\tNext string `json:\"next,omitempty\"`\n\t// The amount of messages that got returned in the current request.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 5\n\tSize int `json:\"size\"`\n\t// The ID of the last message returned in the current request. Use this as alternative to the next link.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 5\n\t// min: 0\n\tSince uint `json:\"since\"`\n\t// The limit of the messages for the current request.\n\t//\n\t// read only: true\n\t// required: true\n\t// min: 1\n\t// max: 200\n\t// example: 123\n\tLimit int `json:\"limit\"`\n}\n\n// PagedMessages Model\n//\n// Wrapper for the paging and the messages.\n//\n// swagger:model PagedMessages\ntype PagedMessages struct {\n\t// The paging of the messages.\n\t//\n\t// read only: true\n\t// required: true\n\tPaging Paging `json:\"paging\"`\n\t// The messages.\n\t//\n\t// read only: true\n\t// required: true\n\tMessages []*MessageExternal `json:\"messages\"`\n}\n"
  },
  {
    "path": "model/pluginconf.go",
    "content": "package model\n\n// PluginConf holds information about the plugin.\ntype PluginConf struct {\n\tID            uint `gorm:\"primaryKey;autoIncrement\"`\n\tUserID        uint\n\tModulePath    string `gorm:\"type:text\"`\n\tToken         string `gorm:\"type:varchar(180);uniqueIndex:uix_plugin_confs_token\"`\n\tApplicationID uint\n\tEnabled       bool\n\tConfig        []byte\n\tStorage       []byte\n}\n\n// PluginConfExternal Model\n//\n// Holds information about a plugin instance for one user.\n//\n// swagger:model PluginConf\ntype PluginConfExternal struct {\n\t// The plugin id.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 25\n\tID uint `json:\"id\"`\n\t// The plugin name.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: RSS poller\n\tName string `json:\"name\"`\n\t// The user name. For login.\n\t//\n\t// required: true\n\t// example: P1234\n\tToken string `binding:\"required\" json:\"token\" query:\"token\" form:\"token\"`\n\t// The module path of the plugin.\n\t//\n\t// example: github.com/gotify/server/plugin/example/echo\n\t// read only: true\n\t// required: true\n\tModulePath string `json:\"modulePath\" form:\"modulePath\" query:\"modulePath\"`\n\t// The author of the plugin.\n\t//\n\t// example: jmattheis\n\t// read only: true\n\tAuthor string `json:\"author,omitempty\" form:\"author\" query:\"author\"`\n\t// The website of the plugin.\n\t//\n\t// example: gotify.net\n\t// read only: true\n\tWebsite string `json:\"website,omitempty\" form:\"website\" query:\"website\"`\n\t// The license of the plugin.\n\t//\n\t// example: MIT\n\t// read only: true\n\tLicense string `json:\"license,omitempty\" form:\"license\" query:\"license\"`\n\t// Whether the plugin instance is enabled.\n\t//\n\t// example: true\n\t// required: true\n\tEnabled bool `json:\"enabled\"`\n\t// Capabilities the plugin provides\n\t//\n\t// example: [\"webhook\",\"display\"]\n\t// required: true\n\tCapabilities []string `json:\"capabilities\"`\n}\n"
  },
  {
    "path": "model/user.go",
    "content": "package model\n\n// The User holds information about the credentials of a user and its application and client tokens.\ntype User struct {\n\tID           uint   `gorm:\"primaryKey;autoIncrement\"`\n\tName         string `gorm:\"type:varchar(180);uniqueIndex:uix_users_name\"`\n\tPass         []byte\n\tAdmin        bool\n\tApplications []Application\n\tClients      []Client\n\tPlugins      []PluginConf\n}\n\n// UserExternal Model\n//\n// The User holds information about permission and other stuff.\n//\n// swagger:model User\ntype UserExternal struct {\n\t// The user id.\n\t//\n\t// read only: true\n\t// required: true\n\t// example: 25\n\tID uint `json:\"id\"`\n\t// The user name. For login.\n\t//\n\t// required: true\n\t// example: unicorn\n\tName string `binding:\"required\" json:\"name\" query:\"name\" form:\"name\"`\n\t// If the user is an administrator.\n\t//\n\t// required: true\n\t// example: true\n\tAdmin bool `json:\"admin\" form:\"admin\" query:\"admin\"`\n}\n\n// CreateUserExternal Model\n//\n// Used for user creation.\n//\n// swagger:model CreateUserExternal\ntype CreateUserExternal struct {\n\t// The user name. For login.\n\t//\n\t// required: true\n\t// example: unicorn\n\tName string `binding:\"required\" json:\"name\" query:\"name\" form:\"name\"`\n\t// If the user is an administrator.\n\t//\n\t// required: true\n\t// example: true\n\tAdmin bool `json:\"admin\" form:\"admin\" query:\"admin\"`\n\t// The user password. For login.\n\t//\n\t// required: true\n\t// example: nrocinu\n\tPass string `json:\"pass,omitempty\" form:\"pass\" query:\"pass\" binding:\"required\"`\n}\n\n// UpdateUserExternal Model\n//\n// Used for updating a user.\n//\n// swagger:model UpdateUserExternal\ntype UpdateUserExternal struct {\n\t// The user name. For login.\n\t//\n\t// required: true\n\t// example: unicorn\n\tName string `binding:\"required\" json:\"name\" query:\"name\" form:\"name\"`\n\t// If the user is an administrator.\n\t//\n\t// required: true\n\t// example: true\n\tAdmin bool `json:\"admin\" form:\"admin\" query:\"admin\"`\n\t// The user password. For login. Empty for using old password\n\t//\n\t// example: nrocinu\n\tPass string `json:\"pass,omitempty\" form:\"pass\" query:\"pass\"`\n}\n\n// UserExternalPass Model\n//\n// The Password for updating the user.\n//\n// swagger:model UserPass\ntype UserExternalPass struct {\n\t// The user password. For login.\n\t//\n\t// required: true\n\t// example: nrocinu\n\tPass string `json:\"pass,omitempty\" form:\"pass\" query:\"pass\" binding:\"required\"`\n}\n"
  },
  {
    "path": "model/version.go",
    "content": "package model\n\n// VersionInfo Model\n//\n// swagger:model VersionInfo\ntype VersionInfo struct {\n\t// The current version.\n\t//\n\t// required: true\n\t// example: 5.2.6\n\tVersion string `json:\"version\"`\n\t// The git commit hash on which this binary was built.\n\t//\n\t// required: true\n\t// example: ae9512b6b6feea56a110d59a3353ea3b9c293864\n\tCommit string `json:\"commit\"`\n\t// The date on which this binary was built.\n\t//\n\t// required: true\n\t// example: 2018-02-27T19:36:10.5045044+01:00\n\tBuildDate string `json:\"buildDate\"`\n}\n"
  },
  {
    "path": "plugin/compat/instance.go",
    "content": "package compat\n\nimport (\n\t\"net/url\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Capability is a capability the plugin provides.\ntype Capability string\n\nconst (\n\t// Messenger sends notifications.\n\tMessenger = Capability(\"messenger\")\n\t// Configurer are consigurables.\n\tConfigurer = Capability(\"configurer\")\n\t// Storager stores data.\n\tStorager = Capability(\"storager\")\n\t// Webhooker registers webhooks.\n\tWebhooker = Capability(\"webhooker\")\n\t// Displayer displays instructions.\n\tDisplayer = Capability(\"displayer\")\n)\n\n// PluginInstance is an encapsulation layer of plugin instances of different backends.\ntype PluginInstance interface {\n\tEnable() error\n\tDisable() error\n\n\t// GetDisplay see Displayer\n\tGetDisplay(location *url.URL) string\n\n\t// DefaultConfig see Configurer\n\tDefaultConfig() interface{}\n\t// ValidateAndSetConfig see Configurer\n\tValidateAndSetConfig(c interface{}) error\n\n\t// SetMessageHandler see Messenger#SetMessageHandler\n\tSetMessageHandler(h MessageHandler)\n\n\t// RegisterWebhook see Webhooker#RegisterWebhook\n\tRegisterWebhook(basePath string, mux *gin.RouterGroup)\n\n\t// SetStorageHandler see Storager#SetStorageHandler.\n\tSetStorageHandler(handler StorageHandler)\n\n\t// Returns the supported modules, f.ex. storager\n\tSupports() Capabilities\n}\n\n// HasSupport tests a PluginInstance for a capability.\nfunc HasSupport(p PluginInstance, toCheck Capability) bool {\n\tfor _, module := range p.Supports() {\n\t\tif module == toCheck {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Capabilities is a slice of module.\ntype Capabilities []Capability\n\n// Strings converts []Module to []string.\nfunc (m Capabilities) Strings() []string {\n\tvar result []string\n\tfor _, module := range m {\n\t\tresult = append(result, string(module))\n\t}\n\treturn result\n}\n\n// MessageHandler see plugin.MessageHandler.\ntype MessageHandler interface {\n\t// SendMessage see plugin.MessageHandler\n\tSendMessage(msg Message) error\n}\n\n// StorageHandler see plugin.StorageHandler.\ntype StorageHandler interface {\n\tSave(b []byte) error\n\tLoad() ([]byte, error)\n}\n\n// Message describes a message to be send by MessageHandler#SendMessage.\ntype Message struct {\n\tMessage  string\n\tTitle    string\n\tPriority int\n\tExtras   map[string]interface{}\n}\n"
  },
  {
    "path": "plugin/compat/plugin.go",
    "content": "package compat\n\n// Plugin is an abstraction of plugin handler.\ntype Plugin interface {\n\tPluginInfo() Info\n\tNewPluginInstance(ctx UserContext) PluginInstance\n\tAPIVersion() string\n}\n\n// Info is the plugin info.\ntype Info struct {\n\tVersion     string\n\tAuthor      string\n\tName        string\n\tWebsite     string\n\tDescription string\n\tLicense     string\n\tModulePath  string\n}\n\nfunc (c Info) String() string {\n\tif c.Name != \"\" {\n\t\treturn c.Name\n\t}\n\treturn c.ModulePath\n}\n\n// UserContext is the user context used to create plugin instance.\ntype UserContext struct {\n\tID    uint\n\tName  string\n\tAdmin bool\n}\n"
  },
  {
    "path": "plugin/compat/plugin_test.go",
    "content": "package compat\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst examplePluginPath = \"github.com/gotify/server/v2/plugin/example/echo\"\n\nfunc TestPluginInfoStringer(t *testing.T) {\n\tinfo := Info{\n\t\tModulePath: examplePluginPath,\n\t}\n\tassert.Equal(t, examplePluginPath, info.String())\n\tinfo.Name = \"test name\"\n\tassert.Equal(t, \"test name\", info.String())\n}\n"
  },
  {
    "path": "plugin/compat/v1.go",
    "content": "package compat\n\nimport (\n\t\"net/url\"\n\n\t\"github.com/gin-gonic/gin\"\n\tpapiv1 \"github.com/gotify/plugin-api\"\n)\n\n// PluginV1 is an abstraction of a plugin written in the v1 plugin API. Exported for testing purposes only.\ntype PluginV1 struct {\n\tInfo        papiv1.Info\n\tConstructor func(ctx papiv1.UserContext) papiv1.Plugin\n}\n\n// APIVersion returns the API version.\nfunc (c PluginV1) APIVersion() string {\n\treturn \"v1\"\n}\n\n// PluginInfo implements compat/Plugin.\nfunc (c PluginV1) PluginInfo() Info {\n\treturn Info{\n\t\tVersion:     c.Info.Version,\n\t\tAuthor:      c.Info.Author,\n\t\tName:        c.Info.Name,\n\t\tWebsite:     c.Info.Website,\n\t\tDescription: c.Info.Description,\n\t\tLicense:     c.Info.License,\n\t\tModulePath:  c.Info.ModulePath,\n\t}\n}\n\n// NewPluginInstance implements compat/Plugin.\nfunc (c PluginV1) NewPluginInstance(ctx UserContext) PluginInstance {\n\tinstance := c.Constructor(papiv1.UserContext{\n\t\tID:    ctx.ID,\n\t\tName:  ctx.Name,\n\t\tAdmin: ctx.Admin,\n\t})\n\n\tcompat := &PluginV1Instance{\n\t\tinstance: instance,\n\t}\n\n\tif displayer, ok := instance.(papiv1.Displayer); ok {\n\t\tcompat.displayer = displayer\n\t}\n\n\tif messenger, ok := instance.(papiv1.Messenger); ok {\n\t\tcompat.messenger = messenger\n\t}\n\n\tif configurer, ok := instance.(papiv1.Configurer); ok {\n\t\tcompat.configurer = configurer\n\t}\n\n\tif storager, ok := instance.(papiv1.Storager); ok {\n\t\tcompat.storager = storager\n\t}\n\n\tif webhooker, ok := instance.(papiv1.Webhooker); ok {\n\t\tcompat.webhooker = webhooker\n\t}\n\n\treturn compat\n}\n\n// PluginV1Instance is an adapter for plugin using v1 API.\ntype PluginV1Instance struct {\n\tinstance   papiv1.Plugin\n\tmessenger  papiv1.Messenger\n\tconfigurer papiv1.Configurer\n\tstorager   papiv1.Storager\n\twebhooker  papiv1.Webhooker\n\tdisplayer  papiv1.Displayer\n}\n\n// DefaultConfig see papiv1.Configurer.\nfunc (c *PluginV1Instance) DefaultConfig() interface{} {\n\tif c.configurer != nil {\n\t\treturn c.configurer.DefaultConfig()\n\t}\n\treturn struct{}{}\n}\n\n// ValidateAndSetConfig see papiv1.Configurer.\nfunc (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) error {\n\tif c.configurer != nil {\n\t\treturn c.configurer.ValidateAndSetConfig(config)\n\t}\n\treturn nil\n}\n\n// GetDisplay see papiv1.Displayer.\nfunc (c *PluginV1Instance) GetDisplay(location *url.URL) string {\n\tif c.displayer != nil {\n\t\treturn c.displayer.GetDisplay(location)\n\t}\n\treturn \"\"\n}\n\n// SetMessageHandler see papiv1.Messenger.\nfunc (c *PluginV1Instance) SetMessageHandler(h MessageHandler) {\n\tif c.messenger != nil {\n\t\tc.messenger.SetMessageHandler(&PluginV1MessageHandler{WrapperHandler: h})\n\t}\n}\n\n// RegisterWebhook see papiv1.Webhooker.\nfunc (c *PluginV1Instance) RegisterWebhook(basePath string, mux *gin.RouterGroup) {\n\tif c.webhooker != nil {\n\t\tc.webhooker.RegisterWebhook(basePath, mux)\n\t}\n}\n\n// SetStorageHandler see papiv1.Storager.\nfunc (c *PluginV1Instance) SetStorageHandler(handler StorageHandler) {\n\tif c.storager != nil {\n\t\tc.storager.SetStorageHandler(&PluginV1StorageHandler{WrapperHandler: handler})\n\t}\n}\n\n// Supports returns a slice of capabilities the plugin instance provides.\nfunc (c *PluginV1Instance) Supports() Capabilities {\n\tmodules := Capabilities{}\n\tif c.configurer != nil {\n\t\tmodules = append(modules, Configurer)\n\t}\n\tif c.displayer != nil {\n\t\tmodules = append(modules, Displayer)\n\t}\n\tif c.messenger != nil {\n\t\tmodules = append(modules, Messenger)\n\t}\n\tif c.storager != nil {\n\t\tmodules = append(modules, Storager)\n\t}\n\tif c.webhooker != nil {\n\t\tmodules = append(modules, Webhooker)\n\t}\n\treturn modules\n}\n\n// PluginV1MessageHandler is an adapter for messenger plugin handler using v1 API.\ntype PluginV1MessageHandler struct {\n\tWrapperHandler MessageHandler\n}\n\n// SendMessage implements papiv1.MessageHandler.\nfunc (c *PluginV1MessageHandler) SendMessage(msg papiv1.Message) error {\n\treturn c.WrapperHandler.SendMessage(Message{\n\t\tMessage:  msg.Message,\n\t\tPriority: msg.Priority,\n\t\tTitle:    msg.Title,\n\t\tExtras:   msg.Extras,\n\t})\n}\n\n// Enable implements wrapper.Plugin.\nfunc (c *PluginV1Instance) Enable() error {\n\treturn c.instance.Enable()\n}\n\n// Disable implements wrapper.Plugin.\nfunc (c *PluginV1Instance) Disable() error {\n\treturn c.instance.Disable()\n}\n\n// PluginV1StorageHandler is a wrapper for v1 storage handler.\ntype PluginV1StorageHandler struct {\n\tWrapperHandler StorageHandler\n}\n\n// Save implements wrapper.Storager.\nfunc (c *PluginV1StorageHandler) Save(b []byte) error {\n\treturn c.WrapperHandler.Save(b)\n}\n\n// Load implements wrapper.Storager.\nfunc (c *PluginV1StorageHandler) Load() ([]byte, error) {\n\treturn c.WrapperHandler.Load()\n}\n"
  },
  {
    "path": "plugin/compat/v1_test.go",
    "content": "package compat\n\nimport (\n\t\"testing\"\n\n\tpapiv1 \"github.com/gotify/plugin-api\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\ntype v1MockInstance struct {\n\tEnabled bool\n}\n\nfunc (c *v1MockInstance) Enable() error {\n\tc.Enabled = true\n\treturn nil\n}\n\nfunc (c *v1MockInstance) Disable() error {\n\tc.Enabled = false\n\treturn nil\n}\n\ntype V1WrapperSuite struct {\n\tsuite.Suite\n\ti PluginV1Instance\n}\n\nfunc (s *V1WrapperSuite) SetupSuite() {\n\tinst := new(v1MockInstance)\n\ts.i.instance = inst\n}\n\nfunc (s *V1WrapperSuite) TestConfigurer_notSupported_expectEmpty() {\n\tassert.Equal(s.T(), struct{}{}, s.i.DefaultConfig())\n\tassert.Nil(s.T(), s.i.ValidateAndSetConfig(struct{}{}))\n}\n\nfunc (s *V1WrapperSuite) TestDisplayer_notSupported_expectEmpty() {\n\tassert.Equal(s.T(), \"\", s.i.GetDisplay(nil))\n}\n\ntype v1StorageHandler struct {\n\tstorage []byte\n}\n\nfunc (c *v1StorageHandler) Save(b []byte) error {\n\tc.storage = b\n\treturn nil\n}\n\nfunc (c *v1StorageHandler) Load() ([]byte, error) {\n\treturn c.storage, nil\n}\n\ntype v1Storager struct {\n\thandler papiv1.StorageHandler\n}\n\nfunc (c *v1Storager) Enable() error {\n\treturn nil\n}\n\nfunc (c *v1Storager) Disable() error {\n\treturn nil\n}\n\nfunc (c *v1Storager) SetStorageHandler(h papiv1.StorageHandler) {\n\tc.handler = h\n}\n\nfunc (s *V1WrapperSuite) TestStorager() {\n\tstorager := new(v1Storager)\n\ts.i.storager = storager\n\n\ts.i.SetStorageHandler(new(v1StorageHandler))\n\n\tassert.Nil(s.T(), storager.handler.Save([]byte(\"test\")))\n\tstorage, err := storager.handler.Load()\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), \"test\", string(storage))\n}\n\ntype v1MessengerHandler struct {\n\tmsgSent Message\n}\n\nfunc (c *v1MessengerHandler) SendMessage(msg Message) error {\n\tc.msgSent = msg\n\treturn nil\n}\n\ntype v1Messenger struct {\n\thandler papiv1.MessageHandler\n}\n\nfunc (c *v1Messenger) Enable() error {\n\treturn nil\n}\n\nfunc (c *v1Messenger) Disable() error {\n\treturn nil\n}\n\nfunc (c *v1Messenger) SetMessageHandler(h papiv1.MessageHandler) {\n\tc.handler = h\n}\n\nfunc (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() {\n\tmessenger := new(v1Messenger)\n\ts.i.messenger = messenger\n\n\thandler := new(v1MessengerHandler)\n\ts.i.SetMessageHandler(handler)\n\n\tmsg := papiv1.Message{\n\t\tTitle:    \"test message\",\n\t\tMessage:  \"test\",\n\t\tPriority: 2,\n\t\tExtras: map[string]interface{}{\n\t\t\t\"test::string\": \"test\",\n\t\t},\n\t}\n\tassert.Nil(s.T(), messenger.handler.SendMessage(msg))\n\tassert.Equal(s.T(), Message{\n\t\tTitle:    \"test message\",\n\t\tMessage:  \"test\",\n\t\tPriority: 2,\n\t\tExtras: map[string]interface{}{\n\t\t\t\"test::string\": \"test\",\n\t\t},\n\t}, handler.msgSent)\n}\n\nfunc (s *V1WrapperSuite) TestMessenger_sendMessageWithoutExtras() {\n\tmessenger := new(v1Messenger)\n\ts.i.messenger = messenger\n\n\thandler := new(v1MessengerHandler)\n\ts.i.SetMessageHandler(handler)\n\n\tmsg := papiv1.Message{\n\t\tTitle:    \"test message\",\n\t\tMessage:  \"test\",\n\t\tPriority: 2,\n\t\tExtras:   nil,\n\t}\n\tassert.Nil(s.T(), messenger.handler.SendMessage(msg))\n\tassert.Equal(s.T(), Message{\n\t\tTitle:    \"test message\",\n\t\tMessage:  \"test\",\n\t\tPriority: 2,\n\t\tExtras:   nil,\n\t}, handler.msgSent)\n}\n\nfunc TestV1Wrapper(t *testing.T) {\n\tsuite.Run(t, new(V1WrapperSuite))\n}\n"
  },
  {
    "path": "plugin/compat/wrap.go",
    "content": "package compat\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"plugin\"\n\n\tpapiv1 \"github.com/gotify/plugin-api\"\n)\n\n// Wrap wraps around a raw go plugin to provide typesafe access.\nfunc Wrap(p *plugin.Plugin) (Plugin, error) {\n\tgetInfoHandle, err := p.Lookup(\"GetGotifyPluginInfo\")\n\tif err != nil {\n\t\treturn nil, errors.New(\"missing GetGotifyPluginInfo symbol\")\n\t}\n\tswitch getInfoHandle := getInfoHandle.(type) {\n\tcase func() papiv1.Info:\n\t\tv1 := PluginV1{}\n\n\t\tv1.Info = getInfoHandle()\n\t\tnewInstanceHandle, err := p.Lookup(\"NewGotifyPluginInstance\")\n\t\tif err != nil {\n\t\t\treturn nil, errors.New(\"missing NewGotifyPluginInstance symbol\")\n\t\t}\n\t\tconstructor, ok := newInstanceHandle.(func(ctx papiv1.UserContext) papiv1.Plugin)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"NewGotifyPluginInstance signature mismatch, func(ctx plugin.UserContext) plugin.Plugin expected, got %T\", newInstanceHandle)\n\t\t}\n\t\tv1.Constructor = constructor\n\t\treturn v1, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown plugin version (unrecogninzed GetGotifyPluginInfo signature %T)\", getInfoHandle)\n\t}\n}\n"
  },
  {
    "path": "plugin/compat/wrap_test.go",
    "content": "//go:build linux || darwin\n// +build linux darwin\n\npackage compat\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path\"\n\t\"plugin\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\ntype CompatSuite struct {\n\tsuite.Suite\n\n\tp      Plugin\n\ttmpDir test.TmpDir\n}\n\nfunc (s *CompatSuite) SetupSuite() {\n\ts.tmpDir = test.NewTmpDir(\"gotify_compatsuite\")\n\n\ttest.WithWd(path.Join(test.GetProjectDir(), \"./plugin/example/echo\"), func(origWd string) {\n\t\texec.Command(\"go\", \"get\", \"-d\").Run()\n\t\tgoBuildFlags := []string{\"build\", \"-buildmode=plugin\", \"-o=\" + s.tmpDir.Path(\"echo.so\")}\n\n\t\tgoBuildFlags = append(goBuildFlags, extraGoBuildFlags...)\n\n\t\tcmd := exec.Command(\"go\", goBuildFlags...)\n\t\tcmd.Stderr = os.Stderr\n\t\tassert.Nil(s.T(), cmd.Run())\n\t})\n\n\tplugin, err := plugin.Open(s.tmpDir.Path(\"echo.so\"))\n\tassert.Nil(s.T(), err)\n\twrappedPlugin, err := Wrap(plugin)\n\tassert.Nil(s.T(), err)\n\n\ts.p = wrappedPlugin\n}\n\nfunc (s *CompatSuite) TearDownSuite() {\n\tassert.Nil(s.T(), s.tmpDir.Clean())\n}\n\nfunc (s *CompatSuite) TestGetPluginAPIVersion() {\n\tassert.Equal(s.T(), \"v1\", s.p.APIVersion())\n}\n\nfunc (s *CompatSuite) TestGetPluginInfo() {\n\tinfo := s.p.PluginInfo()\n\n\tassert.Equal(s.T(), examplePluginPath, info.ModulePath)\n\tassert.True(s.T(), info.String() != \"\")\n}\n\nfunc (s *CompatSuite) TestInstantiatePlugin() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   1,\n\t\tName: \"test\",\n\t})\n\n\tassert.NotNil(s.T(), inst)\n}\n\nfunc (s *CompatSuite) TestGetCapabilities() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   2,\n\t\tName: \"test2\",\n\t})\n\n\tc := inst.Supports()\n\n\tassert.Contains(s.T(), c, Webhooker)\n\tassert.Contains(s.T(), c.Strings(), string(Webhooker))\n\tassert.True(s.T(), HasSupport(inst, Webhooker))\n\tassert.False(s.T(), HasSupport(inst, \"not_exist\"))\n}\n\nfunc (s *CompatSuite) TestSetConfig() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   3,\n\t\tName: \"test3\",\n\t})\n\n\tdefaultConfig := inst.DefaultConfig()\n\tassert.Nil(s.T(), inst.ValidateAndSetConfig(defaultConfig))\n}\n\nfunc (s *CompatSuite) TestRegisterWebhook() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   4,\n\t\tName: \"test4\",\n\t})\n\n\te := gin.New()\n\tg := e.Group(\"/\")\n\tassert.NotPanics(s.T(), func() {\n\t\tinst.RegisterWebhook(\"/plugin/4/custom/Pabcd/\", g)\n\t})\n}\n\nfunc (s *CompatSuite) TestEnableDisable() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   5,\n\t\tName: \"test5\",\n\t})\n\tassert.Nil(s.T(), inst.Enable())\n\tassert.Nil(s.T(), inst.Disable())\n}\n\nfunc (s *CompatSuite) TestGetDisplay() {\n\tinst := s.p.NewPluginInstance(UserContext{\n\t\tID:   6,\n\t\tName: \"test6\",\n\t})\n\n\tassert.NotEqual(s.T(), \"\", inst.GetDisplay(nil))\n}\n\nfunc TestCompatSuite(t *testing.T) {\n\tsuite.Run(t, new(CompatSuite))\n}\n\nfunc TestWrapIncompatiblePlugins(t *testing.T) {\n\ttmpDir := test.NewTmpDir(\"gotify_testwrapincompatibleplugins\")\n\tdefer tmpDir.Clean()\n\tfor i, modulePath := range []string{\n\t\t\"github.com/gotify/server/v2/plugin/testing/broken/noinstance\",\n\t\t\"github.com/gotify/server/v2/plugin/testing/broken/nothing\",\n\t\t\"github.com/gotify/server/v2/plugin/testing/broken/unknowninfo\",\n\t\t\"github.com/gotify/server/v2/plugin/testing/broken/malformedconstructor\",\n\t} {\n\t\tfName := tmpDir.Path(fmt.Sprintf(\"broken_%d.so\", i))\n\t\texec.Command(\"go\", \"get\", \"-d\").Run()\n\t\tgoBuildFlags := []string{\"build\", \"-buildmode=plugin\", \"-o=\" + fName}\n\t\tgoBuildFlags = append(goBuildFlags, extraGoBuildFlags...)\n\t\tgoBuildFlags = append(goBuildFlags, modulePath)\n\n\t\tcmd := exec.Command(\"go\", goBuildFlags...)\n\t\tcmd.Stderr = os.Stderr\n\t\tassert.Nil(t, cmd.Run())\n\n\t\tplugin, err := plugin.Open(fName)\n\t\tassert.Nil(t, err)\n\t\t_, err = Wrap(plugin)\n\t\tassert.Error(t, err)\n\t\tos.Remove(fName)\n\t}\n}\n"
  },
  {
    "path": "plugin/compat/wrap_test_norace.go",
    "content": "//go:build !race\n// +build !race\n\npackage compat\n\nvar extraGoBuildFlags = []string{}\n"
  },
  {
    "path": "plugin/compat/wrap_test_race.go",
    "content": "//go:build race\n// +build race\n\npackage compat\n\nvar extraGoBuildFlags = []string{\"-race\"}\n"
  },
  {
    "path": "plugin/example/clock/main.go",
    "content": "package main\n\nimport (\n\t\"time\"\n\n\t\"github.com/gotify/plugin-api\"\n\t\"github.com/robfig/cron\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tName:        \"clock\",\n\t\tDescription: \"Sends an hourly reminder\",\n\t\tModulePath:  \"github.com/gotify/server/v2/example/clock\",\n\t}\n}\n\n// Plugin is plugin instance\ntype Plugin struct {\n\tmsgHandler  plugin.MessageHandler\n\tenabled     bool\n\tcronHandler *cron.Cron\n}\n\n// Enable implements plugin.Plugin\nfunc (c *Plugin) Enable() error {\n\tc.enabled = true\n\tc.cronHandler = cron.New()\n\tc.cronHandler.AddFunc(\"0 0 * * *\", func() {\n\t\tc.msgHandler.SendMessage(plugin.Message{\n\t\t\tTitle:   \"Tick Tock!\",\n\t\t\tMessage: time.Now().Format(\"It is 15:04:05 now.\"),\n\t\t})\n\t})\n\tc.cronHandler.Start()\n\treturn nil\n}\n\n// Disable implements plugin.Plugin\nfunc (c *Plugin) Disable() error {\n\tif c.cronHandler != nil {\n\t\tc.cronHandler.Stop()\n\t}\n\tc.enabled = false\n\treturn nil\n}\n\n// SetMessageHandler implements plugin.Messenger.\nfunc (c *Plugin) SetMessageHandler(h plugin.MessageHandler) {\n\tc.msgHandler = h\n}\n\n// NewGotifyPluginInstance creates a plugin instance for a user context.\nfunc NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {\n\tp := &Plugin{}\n\n\treturn p\n}\n\nfunc main() {\n\tpanic(\"this should be built as go plugin\")\n}\n"
  },
  {
    "path": "plugin/example/echo/echo.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/url\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info.\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tModulePath: \"github.com/gotify/server/v2/plugin/example/echo\",\n\t\tName:       \"test plugin\",\n\t}\n}\n\n// EchoPlugin is the gotify plugin instance.\ntype EchoPlugin struct {\n\tmsgHandler     plugin.MessageHandler\n\tstorageHandler plugin.StorageHandler\n\tconfig         *Config\n\tbasePath       string\n}\n\n// SetStorageHandler implements plugin.Storager\nfunc (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) {\n\tc.storageHandler = h\n}\n\n// SetMessageHandler implements plugin.Messenger.\nfunc (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) {\n\tc.msgHandler = h\n}\n\n// Storage defines the plugin storage scheme\ntype Storage struct {\n\tCalledTimes int `json:\"called_times\"`\n}\n\n// Config defines the plugin config scheme\ntype Config struct {\n\tMagicString string `yaml:\"magic_string\"`\n}\n\n// DefaultConfig implements plugin.Configurer\nfunc (c *EchoPlugin) DefaultConfig() interface{} {\n\treturn &Config{\n\t\tMagicString: \"hello world\",\n\t}\n}\n\n// ValidateAndSetConfig implements plugin.Configurer\nfunc (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error {\n\tc.config = config.(*Config)\n\treturn nil\n}\n\n// Enable enables the plugin.\nfunc (c *EchoPlugin) Enable() error {\n\tlog.Println(\"echo plugin enabled\")\n\treturn nil\n}\n\n// Disable disables the plugin.\nfunc (c *EchoPlugin) Disable() error {\n\tlog.Println(\"echo plugin disbled\")\n\treturn nil\n}\n\n// RegisterWebhook implements plugin.Webhooker.\nfunc (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) {\n\tc.basePath = baseURL\n\tg.GET(\"/echo\", func(ctx *gin.Context) {\n\t\tstorage, _ := c.storageHandler.Load()\n\t\tconf := new(Storage)\n\t\tjson.Unmarshal(storage, conf)\n\t\tconf.CalledTimes++\n\t\tnewStorage, _ := json.Marshal(conf)\n\t\tc.storageHandler.Save(newStorage)\n\n\t\tc.msgHandler.SendMessage(plugin.Message{\n\t\t\tTitle:    \"Hello received\",\n\t\t\tMessage:  fmt.Sprintf(\"echo server received a hello message %d times\", conf.CalledTimes),\n\t\t\tPriority: 2,\n\t\t\tExtras: map[string]interface{}{\n\t\t\t\t\"plugin::name\": \"echo\",\n\t\t\t},\n\t\t})\n\t\tctx.Writer.WriteString(fmt.Sprintf(\"Magic string is: %s\\r\\nEcho server running at %secho\", c.config.MagicString, c.basePath))\n\t})\n}\n\n// GetDisplay implements plugin.Displayer.\nfunc (c *EchoPlugin) GetDisplay(location *url.URL) string {\n\tloc := &url.URL{\n\t\tPath: c.basePath,\n\t}\n\tif location != nil {\n\t\tloc.Scheme = location.Scheme\n\t\tloc.Host = location.Host\n\t}\n\tloc = loc.ResolveReference(&url.URL{\n\t\tPath: \"echo\",\n\t})\n\treturn \"Echo plugin running at: \" + loc.String()\n}\n\n// NewGotifyPluginInstance creates a plugin instance for a user context.\nfunc NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {\n\treturn &EchoPlugin{}\n}\n\nfunc main() {\n\tpanic(\"this should be built as go plugin\")\n}\n"
  },
  {
    "path": "plugin/example/minimal/main.go",
    "content": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tName:       \"minimal plugin\",\n\t\tModulePath: \"github.com/gotify/server/v2/example/minimal\",\n\t}\n}\n\n// Plugin is plugin instance\ntype Plugin struct{}\n\n// Enable implements plugin.Plugin\nfunc (c *Plugin) Enable() error {\n\treturn nil\n}\n\n// Disable implements plugin.Plugin\nfunc (c *Plugin) Disable() error {\n\treturn nil\n}\n\n// NewGotifyPluginInstance creates a plugin instance for a user context.\nfunc NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {\n\treturn &Plugin{}\n}\n\nfunc main() {\n\tpanic(\"this should be built as go plugin\")\n}\n"
  },
  {
    "path": "plugin/manager.go",
    "content": "package plugin\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"plugin\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin/compat\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// The Database interface for encapsulating database access.\ntype Database interface {\n\tGetUsers() ([]*model.User, error)\n\tGetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error)\n\tCreatePluginConf(p *model.PluginConf) error\n\tGetPluginConfByApplicationID(appid uint) (*model.PluginConf, error)\n\tUpdatePluginConf(p *model.PluginConf) error\n\tCreateMessage(message *model.Message) error\n\tGetPluginConfByID(id uint) (*model.PluginConf, error)\n\tGetPluginConfByToken(token string) (*model.PluginConf, error)\n\tGetUserByID(id uint) (*model.User, error)\n\tCreateApplication(application *model.Application) error\n\tUpdateApplication(app *model.Application) error\n\tGetApplicationsByUser(userID uint) ([]*model.Application, error)\n\tGetApplicationByToken(token string) (*model.Application, error)\n}\n\n// Notifier notifies when a new message was created.\ntype Notifier interface {\n\tNotify(userID uint, message *model.MessageExternal)\n}\n\n// Manager is an encapsulating layer for plugins and manages all plugins and its instances.\ntype Manager struct {\n\tmutex     *sync.RWMutex\n\tinstances map[uint]compat.PluginInstance\n\tplugins   map[string]compat.Plugin\n\tmessages  chan MessageWithUserID\n\tdb        Database\n\tmux       *gin.RouterGroup\n}\n\n// NewManager created a Manager from configurations.\nfunc NewManager(db Database, directory string, mux *gin.RouterGroup, notifier Notifier) (*Manager, error) {\n\tmanager := &Manager{\n\t\tmutex:     &sync.RWMutex{},\n\t\tinstances: map[uint]compat.PluginInstance{},\n\t\tplugins:   map[string]compat.Plugin{},\n\t\tmessages:  make(chan MessageWithUserID),\n\t\tdb:        db,\n\t\tmux:       mux,\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\tmessage := <-manager.messages\n\t\t\tinternalMsg := &model.Message{\n\t\t\t\tApplicationID: message.Message.ApplicationID,\n\t\t\t\tTitle:         message.Message.Title,\n\t\t\t\tPriority:      *message.Message.Priority,\n\t\t\t\tDate:          message.Message.Date,\n\t\t\t\tMessage:       message.Message.Message,\n\t\t\t}\n\t\t\tif message.Message.Extras != nil {\n\t\t\t\tinternalMsg.Extras, _ = json.Marshal(message.Message.Extras)\n\t\t\t}\n\t\t\tdb.CreateMessage(internalMsg)\n\t\t\tmessage.Message.ID = internalMsg.ID\n\t\t\tnotifier.Notify(message.UserID, &message.Message)\n\t\t}\n\t}()\n\n\tif err := manager.loadPlugins(directory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tusers, err := manager.db.GetUsers()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, user := range users {\n\t\tif err := manager.initializeForUser(*user); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn manager, nil\n}\n\n// ErrAlreadyEnabledOrDisabled is returned on SetPluginEnabled call when a plugin is already enabled or disabled.\nvar ErrAlreadyEnabledOrDisabled = errors.New(\"config is already enabled/disabled\")\n\nfunc (m *Manager) applicationExists(token string) bool {\n\tapp, _ := m.db.GetApplicationByToken(token)\n\treturn app != nil\n}\n\nfunc (m *Manager) pluginConfExists(token string) bool {\n\tpluginConf, _ := m.db.GetPluginConfByToken(token)\n\treturn pluginConf != nil\n}\n\n// SetPluginEnabled sets the plugins enabled state.\nfunc (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error {\n\tinstance, err := m.Instance(pluginID)\n\tif err != nil {\n\t\treturn errors.New(\"instance not found\")\n\t}\n\tconf, err := m.db.GetPluginConfByID(pluginID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif conf.Enabled == enabled {\n\t\treturn ErrAlreadyEnabledOrDisabled\n\t}\n\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tif enabled {\n\t\terr = instance.Enable()\n\t} else {\n\t\terr = instance.Disable()\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newConf, err := m.db.GetPluginConfByID(pluginID); /* conf might be updated by instance */ err == nil {\n\t\tconf = newConf\n\t}\n\tconf.Enabled = enabled\n\treturn m.db.UpdatePluginConf(conf)\n}\n\n// PluginInfo returns plugin info.\nfunc (m *Manager) PluginInfo(modulePath string) compat.Info {\n\tm.mutex.RLock()\n\tdefer m.mutex.RUnlock()\n\n\tif p, ok := m.plugins[modulePath]; ok {\n\t\treturn p.PluginInfo()\n\t}\n\tfmt.Println(\"Could not get plugin info for\", modulePath)\n\treturn compat.Info{\n\t\tName:        \"UNKNOWN\",\n\t\tModulePath:  modulePath,\n\t\tDescription: \"Oops something went wrong\",\n\t}\n}\n\n// Instance returns an instance with the given ID.\nfunc (m *Manager) Instance(pluginID uint) (compat.PluginInstance, error) {\n\tm.mutex.RLock()\n\tdefer m.mutex.RUnlock()\n\n\tif instance, ok := m.instances[pluginID]; ok {\n\t\treturn instance, nil\n\t}\n\treturn nil, errors.New(\"instance not found\")\n}\n\n// HasInstance returns whether the given plugin ID has a corresponding instance.\nfunc (m *Manager) HasInstance(pluginID uint) bool {\n\tinstance, err := m.Instance(pluginID)\n\treturn err == nil && instance != nil\n}\n\n// RemoveUser disabled all plugins of a user when the user is disabled.\nfunc (m *Manager) RemoveUser(userID uint) error {\n\tfor _, p := range m.plugins {\n\t\tpluginConf, err := m.db.GetPluginConfByUserAndPath(userID, p.PluginInfo().ModulePath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif pluginConf == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif pluginConf.Enabled {\n\t\t\tinst, err := m.Instance(pluginConf.ID)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tm.mutex.Lock()\n\t\t\terr = inst.Disable()\n\t\t\tm.mutex.Unlock()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tdelete(m.instances, pluginConf.ID)\n\t}\n\treturn nil\n}\n\ntype pluginFileLoadError struct {\n\tFilename        string\n\tUnderlyingError error\n}\n\nfunc (c pluginFileLoadError) Error() string {\n\treturn fmt.Sprintf(\"error while loading plugin %s: %s\", c.Filename, c.UnderlyingError)\n}\n\nfunc (m *Manager) loadPlugins(directory string) error {\n\tif directory == \"\" {\n\t\treturn nil\n\t}\n\n\tpluginFiles, err := os.ReadDir(directory)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error while reading directory %s\", err)\n\t}\n\tfor _, f := range pluginFiles {\n\t\tif f.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := f.Name()\n\t\tif strings.HasPrefix(name, \".\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tpluginPath := filepath.Join(directory, \"./\", name)\n\n\t\tfmt.Println(\"Loading plugin\", pluginPath)\n\t\tpRaw, err := plugin.Open(pluginPath)\n\t\tif err != nil {\n\t\t\treturn pluginFileLoadError{name, err}\n\t\t}\n\t\tcompatPlugin, err := compat.Wrap(pRaw)\n\t\tif err != nil {\n\t\t\treturn pluginFileLoadError{name, err}\n\t\t}\n\t\tif err := m.LoadPlugin(compatPlugin); err != nil {\n\t\t\treturn pluginFileLoadError{name, err}\n\t\t}\n\t}\n\treturn nil\n}\n\n// LoadPlugin loads a compat plugin, exported to sideload plugins for testing purposes.\nfunc (m *Manager) LoadPlugin(compatPlugin compat.Plugin) error {\n\tmodulePath := compatPlugin.PluginInfo().ModulePath\n\tif _, ok := m.plugins[modulePath]; ok {\n\t\treturn fmt.Errorf(\"plugin with module path %s is present at least twice\", modulePath)\n\t}\n\tm.plugins[modulePath] = compatPlugin\n\treturn nil\n}\n\n// InitializeForUserID initializes all plugin instances for a given user.\nfunc (m *Manager) InitializeForUserID(userID uint) error {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tuser, err := m.db.GetUserByID(userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif user != nil {\n\t\treturn m.initializeForUser(*user)\n\t}\n\treturn fmt.Errorf(\"user with id %d not found\", userID)\n}\n\nfunc (m *Manager) initializeForUser(user model.User) error {\n\tuserCtx := compat.UserContext{\n\t\tID:    user.ID,\n\t\tName:  user.Name,\n\t\tAdmin: user.Admin,\n\t}\n\n\tfor _, p := range m.plugins {\n\t\tif err := m.initializeSingleUserPlugin(userCtx, p); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tapps, err := m.db.GetApplicationsByUser(user.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, app := range apps {\n\t\tconf, err := m.db.GetPluginConfByApplicationID(app.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif conf != nil {\n\t\t\t_, compatExist := m.plugins[conf.ModulePath]\n\t\t\tapp.Internal = compatExist\n\t\t} else {\n\t\t\tapp.Internal = false\n\t\t}\n\t\tm.db.UpdateApplication(app)\n\t}\n\n\treturn nil\n}\n\nfunc (m *Manager) initializeSingleUserPlugin(userCtx compat.UserContext, p compat.Plugin) error {\n\tinfo := p.PluginInfo()\n\tinstance := p.NewPluginInstance(userCtx)\n\tuserID := userCtx.ID\n\n\tpluginConf, err := m.db.GetPluginConfByUserAndPath(userID, info.ModulePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif pluginConf == nil {\n\t\tvar err error\n\t\tpluginConf, err = m.createPluginConf(instance, info, userID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tm.instances[pluginConf.ID] = instance\n\n\tif compat.HasSupport(instance, compat.Messenger) {\n\t\tinstance.SetMessageHandler(redirectToChannel{\n\t\t\tApplicationID: pluginConf.ApplicationID,\n\t\t\tUserID:        pluginConf.UserID,\n\t\t\tMessages:      m.messages,\n\t\t})\n\t}\n\tif compat.HasSupport(instance, compat.Storager) {\n\t\tinstance.SetStorageHandler(dbStorageHandler{pluginConf.ID, m.db})\n\t}\n\tif compat.HasSupport(instance, compat.Configurer) {\n\t\tm.initializeConfigurerForSingleUserPlugin(instance, pluginConf)\n\t}\n\tif compat.HasSupport(instance, compat.Webhooker) {\n\t\tid := pluginConf.ID\n\t\tg := m.mux.Group(pluginConf.Token+\"/\", requirePluginEnabled(id, m.db))\n\t\tinstance.RegisterWebhook(strings.Replace(g.BasePath(), \":id\", strconv.Itoa(int(id)), 1), g)\n\t}\n\tif pluginConf.Enabled {\n\t\terr := instance.Enable()\n\t\tif err != nil {\n\t\t\t// Single user plugin cannot be enabled\n\t\t\t// Don't panic, disable for now and wait for user to update config\n\t\t\tlog.Printf(\"Plugin initialize failed for user %s: %s. Disabling now...\", userCtx.Name, err.Error())\n\t\t\tpluginConf.Enabled = false\n\t\t\tm.db.UpdatePluginConf(pluginConf)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (m *Manager) initializeConfigurerForSingleUserPlugin(instance compat.PluginInstance, pluginConf *model.PluginConf) {\n\tif len(pluginConf.Config) == 0 {\n\t\t// The Configurer is newly implemented\n\t\t// Use the default config\n\t\tpluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig())\n\t\tm.db.UpdatePluginConf(pluginConf)\n\t}\n\tc := instance.DefaultConfig()\n\tif yaml.Unmarshal(pluginConf.Config, c) != nil || instance.ValidateAndSetConfig(c) != nil {\n\t\tpluginConf.Enabled = false\n\n\t\tlog.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)\n\t\tnewConf := 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\")\n\n\t\td, _ := yaml.Marshal(c)\n\t\tnewConf.Write(d)\n\t\tnewConf.WriteString(\"\\r\\n\")\n\n\t\tnewConf.WriteString(\"# The original configuration: \\r\\n\")\n\t\toldConf := bufio.NewScanner(bytes.NewReader(pluginConf.Config))\n\t\tfor oldConf.Scan() {\n\t\t\tnewConf.WriteString(\"# \")\n\t\t\tnewConf.WriteString(oldConf.Text())\n\t\t\tnewConf.WriteString(\"\\r\\n\")\n\t\t}\n\n\t\tpluginConf.Config = newConf.Bytes()\n\n\t\tm.db.UpdatePluginConf(pluginConf)\n\t\tinstance.ValidateAndSetConfig(instance.DefaultConfig())\n\t}\n}\n\nfunc (m *Manager) createPluginConf(instance compat.PluginInstance, info compat.Info, userID uint) (*model.PluginConf, error) {\n\tpluginConf := &model.PluginConf{\n\t\tUserID:     userID,\n\t\tModulePath: info.ModulePath,\n\t\tToken:      auth.GenerateNotExistingToken(auth.GeneratePluginToken, m.pluginConfExists),\n\t}\n\tif compat.HasSupport(instance, compat.Configurer) {\n\t\tpluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig())\n\t}\n\tif compat.HasSupport(instance, compat.Messenger) {\n\t\tapp := &model.Application{\n\t\t\tToken:       auth.GenerateNotExistingToken(auth.GenerateApplicationToken, m.applicationExists),\n\t\t\tName:        info.String(),\n\t\t\tUserID:      userID,\n\t\t\tInternal:    true,\n\t\t\tDescription: fmt.Sprintf(\"auto generated application for %s\", info.ModulePath),\n\t\t}\n\t\tif err := m.db.CreateApplication(app); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tpluginConf.ApplicationID = app.ID\n\t}\n\tif err := m.db.CreatePluginConf(pluginConf); err != nil {\n\t\treturn nil, err\n\t}\n\treturn pluginConf, nil\n}\n"
  },
  {
    "path": "plugin/manager_test.go",
    "content": "//go:build linux || darwin\n// +build linux darwin\n\npackage plugin\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin/compat\"\n\t\"github.com/gotify/server/v2/plugin/testing/mock\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nconst (\n\texamplePluginPath  = \"github.com/gotify/server/v2/plugin/example/echo\"\n\tmockPluginPath     = mock.ModulePath\n\tdanglingPluginPath = \"github.com/gotify/server/v2/plugin/testing/removed\"\n)\n\ntype ManagerSuite struct {\n\tsuite.Suite\n\tdb          *testdb.Database\n\tmanager     *Manager\n\te           *gin.Engine\n\tmsgReceiver chan MessageWithUserID\n\n\ttmpDir test.TmpDir\n}\n\nfunc (s *ManagerSuite) Notify(uid uint, message *model.MessageExternal) {\n\ts.msgReceiver <- MessageWithUserID{\n\t\tMessage: *message,\n\t\tUserID:  uid,\n\t}\n}\n\nfunc (s *ManagerSuite) SetupSuite() {\n\ts.tmpDir = test.NewTmpDir(\"gotify_managersuite\")\n\n\ttest.WithWd(path.Join(test.GetProjectDir(), \"./plugin/example/echo\"), func(origWd string) {\n\t\texec.Command(\"go\", \"get\", \"-d\").Run()\n\t\tgoBuildFlags := []string{\"build\", \"-buildmode=plugin\", \"-o=\" + s.tmpDir.Path(\"echo.so\")}\n\n\t\tgoBuildFlags = append(goBuildFlags, extraGoBuildFlags...)\n\n\t\tcmd := exec.Command(\"go\", goBuildFlags...)\n\t\tcmd.Stderr = os.Stderr\n\t\tassert.Nil(s.T(), cmd.Run())\n\t})\n\n\ts.db = testdb.NewDBWithDefaultUser(s.T())\n\ts.makeDanglingPluginConf(1)\n\n\te := gin.New()\n\tmanager, err := NewManager(s.db.GormDatabase, s.tmpDir.Path(), e.Group(\"/plugin/:id/custom/\"), s)\n\ts.e = e\n\tassert.Nil(s.T(), err)\n\n\tp := new(mock.Plugin)\n\tassert.Nil(s.T(), manager.LoadPlugin(p))\n\tassert.Nil(s.T(), manager.initializeSingleUserPlugin(compat.UserContext{\n\t\tID:    1,\n\t\tAdmin: true,\n\t}, p))\n\n\ts.manager = manager\n\ts.msgReceiver = make(chan MessageWithUserID)\n\n\tassert.Contains(s.T(), s.manager.plugins, examplePluginPath)\n\tif pluginConf, err := s.db.GetPluginConfByUserAndPath(1, examplePluginPath); assert.NoError(s.T(), err) {\n\t\tassert.NotNil(s.T(), pluginConf)\n\t}\n}\n\nfunc (s *ManagerSuite) TearDownSuite() {\n\tassert.Nil(s.T(), s.tmpDir.Clean())\n}\n\nfunc (s *ManagerSuite) getConfForExamplePlugin(uid uint) *model.PluginConf {\n\tpluginConf, err := s.db.GetPluginConfByUserAndPath(uid, examplePluginPath)\n\tassert.NoError(s.T(), err)\n\treturn pluginConf\n}\n\nfunc (s *ManagerSuite) getConfForMockPlugin(uid uint) *model.PluginConf {\n\tpluginConf, err := s.db.GetPluginConfByUserAndPath(uid, mockPluginPath)\n\tassert.NoError(s.T(), err)\n\treturn pluginConf\n}\n\nfunc (s *ManagerSuite) getMockPluginInstance(uid uint) *mock.PluginInstance {\n\tpid := s.getConfForMockPlugin(uid).ID\n\treturn s.manager.instances[pid].(*mock.PluginInstance)\n}\n\nfunc (s *ManagerSuite) makeDanglingPluginConf(uid uint) *model.PluginConf {\n\tconf := &model.PluginConf{\n\t\tUserID:     uid,\n\t\tModulePath: danglingPluginPath,\n\t\tToken:      auth.GeneratePluginToken(),\n\t\tEnabled:    true,\n\t}\n\ts.db.CreatePluginConf(conf)\n\treturn conf\n}\n\nfunc (s *ManagerSuite) TestWebhook_blockedIfDisabled() {\n\tconf := s.getConfForExamplePlugin(1)\n\tt := httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/custom/%s/echo\", conf.ID, conf.Token), nil)\n\n\tr := httptest.NewRecorder()\n\ts.e.ServeHTTP(r, t)\n\n\tassert.Equal(s.T(), 400, r.Code)\n}\n\nfunc (s *ManagerSuite) TestWebhook_successIfEnabled() {\n\tconf := s.getConfForExamplePlugin(1)\n\n\tassert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, true))\n\tdefer func() { assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, false)) }()\n\tassert.True(s.T(), s.getConfForExamplePlugin(1).Enabled)\n\n\tt := httptest.NewRequest(\"GET\", fmt.Sprintf(\"/plugin/%d/custom/%s/echo\", conf.ID, conf.Token), nil)\n\n\tr := httptest.NewRecorder()\n\ts.e.ServeHTTP(r, t)\n\n\tassert.Equal(s.T(), 200, r.Code)\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_noOpIfEmpty() {\n\tassert.Nil(s.T(), s.manager.loadPlugins(\"\"))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_noOpIfDotFile() {\n\ttmpDir := test.NewTmpDir(\"gotify_testinitializeplugin_dotfile\")\n\tdefer tmpDir.Clean()\n\tf, err := os.Create(tmpDir.Path(\".test\"))\n\tassert.NoError(s.T(), err)\n\t_, err = f.WriteString(\"dummy\")\n\tassert.NoError(s.T(), err)\n\tassert.NoError(s.T(), f.Close())\n\tassert.Nil(s.T(), s.manager.loadPlugins(tmpDir.Path()))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_noOpIfSubDir() {\n\ttmpDir := test.NewTmpDir(\"gotify_testinitializeplugin_subdir\")\n\tdefer tmpDir.Clean()\n\tos.Mkdir(tmpDir.Path(\"subdir\"), 0o755)\n\tassert.Nil(s.T(), s.manager.loadPlugins(tmpDir.Path()))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_directoryInvalid_expectError() {\n\tassert.Error(s.T(), s.manager.loadPlugins(\"<<\"))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_invalidPlugin_expectError() {\n\tassert.Error(s.T(), s.manager.loadPlugins(test.GetProjectDir()))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_brokenPlugin_expectError() {\n\ttmpDir := test.NewTmpDir(\"gotify_testbrokenplugin\")\n\tdefer tmpDir.Clean()\n\ttest.WithWd(path.Join(test.GetProjectDir(), \"./plugin/testing/broken/nothing\"), func(origWd string) {\n\t\texec.Command(\"go\", \"get\", \"-d\").Run()\n\t\tgoBuildFlags := []string{\"build\", \"-buildmode=plugin\", \"-o=\" + tmpDir.Path(\"empty.so\")}\n\n\t\tgoBuildFlags = append(goBuildFlags, extraGoBuildFlags...)\n\n\t\tcmd := exec.Command(\"go\", goBuildFlags...)\n\t\tcmd.Stderr = os.Stderr\n\t\tassert.Nil(s.T(), cmd.Run())\n\t})\n\tassert.Error(s.T(), s.manager.loadPlugins(tmpDir.Path()))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_alreadyLoaded_expectError() {\n\tassert.Error(s.T(), s.manager.loadPlugins(s.tmpDir.Path()))\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_expectAutoEnable() {\n\ts.db.User(2)\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tUserID:     2,\n\t\tModulePath: mockPluginPath,\n\t\tToken:      \"P1234\",\n\t\tEnabled:    true,\n\t})\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(2))\n\tinst := s.getMockPluginInstance(2)\n\tassert.True(s.T(), inst.Enabled)\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_failedToLoadConfig_disableAutomatically() {\n\ts.db.User(3)\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tUserID:     3,\n\t\tModulePath: mockPluginPath,\n\t\tToken:      \"Ptttt\",\n\t\tEnabled:    true,\n\t\tConfig:     []byte(`invalid: \"\"\"`),\n\t})\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(3))\n\tinst := s.getMockPluginInstance(3)\n\tassert.False(s.T(), inst.Enabled)\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_alreadyEnabled_cannotEnable_disabledAutomatically() {\n\ts.db.NewUserWithName(4, \"enable_fail_2\")\n\tmock.ReturnErrorOnEnableForUser(4, errors.New(\"test error\"))\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tUserID:     4,\n\t\tModulePath: mockPluginPath,\n\t\tToken:      \"P5478\",\n\t\tEnabled:    true,\n\t})\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(4))\n\tinst := s.getMockPluginInstance(4)\n\tassert.False(s.T(), inst.Enabled)\n\tassert.False(s.T(), s.getConfForMockPlugin(4).Enabled)\n}\n\nfunc (s *ManagerSuite) TestInitializePlugin_userIDNotExist_expectError() {\n\tassert.Error(s.T(), s.manager.InitializeForUserID(99))\n}\n\nfunc (s *ManagerSuite) TestSetPluginEnabled() {\n\tpid := s.getConfForMockPlugin(1).ID\n\tassert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true))\n\tassert.Error(s.T(), s.manager.SetPluginEnabled(pid, true))\n\tassert.Nil(s.T(), s.manager.SetPluginEnabled(pid, false))\n}\n\nfunc (s *ManagerSuite) TestSetPluginEnabled_EnableReturnsError_cannotEnable() {\n\ts.db.NewUserWithName(5, \"enable_fail\")\n\terrExpected := errors.New(\"test error\")\n\tmock.ReturnErrorOnEnableForUser(5, errExpected)\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(5))\n\n\tpid := s.getConfForMockPlugin(5).ID\n\tassert.Error(s.T(), s.manager.SetPluginEnabled(pid, false))\n\tassert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, true), errExpected.Error())\n\n\tassert.False(s.T(), s.getConfForMockPlugin(5).Enabled)\n}\n\nfunc (s *ManagerSuite) TestSetPluginEnabled_DisableReturnsError_cannotDisable() {\n\ts.db.NewUserWithName(6, \"disable_fail\")\n\terrExpected := errors.New(\"test error\")\n\tmock.ReturnErrorOnDisableForUser(6, errExpected)\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(6))\n\n\tpid := s.getConfForMockPlugin(6).ID\n\tassert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true))\n\tassert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, false), errExpected.Error())\n\n\tassert.True(s.T(), s.getConfForMockPlugin(6).Enabled)\n}\n\nfunc (s *ManagerSuite) TestAddRemoveNewUser() {\n\ts.db.User(7)\n\ts.makeDanglingPluginConf(7)\n\n\tassert.Nil(s.T(), s.manager.InitializeForUserID(7))\n\tpid := s.getConfForExamplePlugin(7).ID\n\tassert.True(s.T(), s.manager.HasInstance(pid))\n\n\tassert.Nil(s.T(), s.manager.SetPluginEnabled(s.getConfForMockPlugin(7).ID, true))\n\n\tassert.Nil(s.T(), s.manager.RemoveUser(7))\n\tassert.False(s.T(), s.manager.HasInstance(pid))\n}\n\nfunc (s *ManagerSuite) TestRemoveUser_DisableFail_cannotRemove() {\n\ts.manager.initializeForUser(*s.db.NewUserWithName(8, \"disable_fail_2\"))\n\terrExpected := errors.New(\"test error\")\n\tmock.ReturnErrorOnDisableForUser(8, errExpected)\n\ts.manager.SetPluginEnabled(s.getConfForMockPlugin(8).ID, true)\n\n\tassert.EqualError(s.T(), s.manager.RemoveUser(8), errExpected.Error())\n}\n\nfunc (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() {\n\t// make a dangling conf for this instance\n\ts.db.User(9)\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tModulePath: mockPluginPath,\n\t\tEnabled:    true,\n\t\tUserID:     9,\n\t\tToken:      auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists),\n\t})\n\ts.db.CreatePluginConf(&model.PluginConf{\n\t\tModulePath: examplePluginPath,\n\t\tEnabled:    true,\n\t\tUserID:     9,\n\t\tToken:      auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists),\n\t})\n\tassert.Nil(s.T(), s.manager.RemoveUser(9))\n}\n\nfunc (s *ManagerSuite) TestTriggerMessage() {\n\tinst := s.getMockPluginInstance(1)\n\tinst.TriggerMessage()\n\tselect {\n\tcase msg := <-s.msgReceiver:\n\t\tassert.Equal(s.T(), uint(1), msg.UserID)\n\t\tassert.NotEmpty(s.T(), msg.Message.Extras)\n\tcase <-time.After(1 * time.Second):\n\t\tassert.Fail(s.T(), \"read message time out\")\n\t}\n}\n\nfunc (s *ManagerSuite) TestStorage() {\n\tinst := s.getMockPluginInstance(1)\n\n\tassert.Nil(s.T(), inst.SetStorage([]byte(\"test\")))\n\tstorage, err := inst.GetStorage()\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), \"test\", string(storage))\n}\n\nfunc (s *ManagerSuite) TestGetPluginInfo() {\n\tassert.Equal(s.T(), mock.Name, s.manager.PluginInfo(mock.ModulePath).Name)\n}\n\nfunc (s *ManagerSuite) TestGetPluginInfo_notFound_doNotPanic() {\n\tassert.NotPanics(s.T(), func() {\n\t\ts.manager.PluginInfo(\"not/exist\")\n\t})\n}\n\nfunc (s *ManagerSuite) TestSetPluginEnabled_expectNotFound() {\n\tassert.Error(s.T(), s.manager.SetPluginEnabled(99, true))\n}\n\nfunc TestManagerSuite(t *testing.T) {\n\tsuite.Run(t, new(ManagerSuite))\n}\n\nfunc TestNewManager_CannotLoadDirectory_expectError(t *testing.T) {\n\t_, err := NewManager(nil, \"<>\", nil, nil)\n\tassert.Error(t, err)\n}\n\nfunc TestNewManager_NonPluginFile_expectError(t *testing.T) {\n\t_, err := NewManager(nil, path.Join(test.GetProjectDir(), \"test/assets/\"), nil, nil)\n\tassert.Error(t, err)\n}\n\nfunc TestNewManager_InternalApplicationManagement(t *testing.T) {\n\tdb := testdb.NewDBWithDefaultUser(t)\n\n\t{\n\t\t// Application exist, no plugin conf\n\t\tdb.CreateApplication(&model.Application{\n\t\t\tToken:    \"Ainternal_obsolete\",\n\t\t\tInternal: true,\n\t\t\tName:     \"obsolete plugin application\",\n\t\t\tUserID:   1,\n\t\t})\n\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_obsolete\"); assert.NoError(t, err) {\n\t\t\tassert.True(t, app.Internal)\n\t\t}\n\t\t_, err := NewManager(db, \"\", nil, nil)\n\t\tassert.Nil(t, err)\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_obsolete\"); assert.NoError(t, err) {\n\t\t\tassert.False(t, app.Internal)\n\t\t}\n\t}\n\t{\n\t\t// Application exist, conf exist, no compat\n\t\tassert.NoError(t, db.CreateApplication(&model.Application{\n\t\t\tToken:    \"Ainternal_not_loaded\",\n\t\t\tInternal: true,\n\t\t\tName:     \"not loaded plugin application\",\n\t\t\tUserID:   1,\n\t\t}))\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_not_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.NoError(t, db.CreatePluginConf(&model.PluginConf{\n\t\t\t\tApplicationID: app.ID,\n\t\t\t\tUserID:        1,\n\t\t\t\tEnabled:       true,\n\t\t\t\tToken:         auth.GeneratePluginToken(),\n\t\t\t}))\n\t\t}\n\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_not_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.True(t, app.Internal)\n\t\t}\n\t\t_, err := NewManager(db, \"\", nil, nil)\n\t\tassert.Nil(t, err)\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_not_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.False(t, app.Internal)\n\t\t}\n\t}\n\t{\n\t\t// Application exist, conf exist, has compat\n\t\tassert.NoError(t, db.CreateApplication(&model.Application{\n\t\t\tToken:    \"Ainternal_loaded\",\n\t\t\tInternal: false,\n\t\t\tName:     \"not loaded plugin application\",\n\t\t\tUserID:   1,\n\t\t}))\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.NoError(t, db.CreatePluginConf(&model.PluginConf{\n\t\t\t\tApplicationID: app.ID,\n\t\t\t\tUserID:        1,\n\t\t\t\tEnabled:       true,\n\t\t\t\tModulePath:    mock.ModulePath,\n\t\t\t\tToken:         auth.GeneratePluginToken(),\n\t\t\t}))\n\t\t}\n\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.False(t, app.Internal)\n\t\t}\n\t\tmanager, err := NewManager(db, \"\", nil, nil)\n\t\tassert.Nil(t, err)\n\t\tassert.Nil(t, manager.LoadPlugin(new(mock.Plugin)))\n\t\tassert.Nil(t, manager.InitializeForUserID(1))\n\t\tif app, err := db.GetApplicationByToken(\"Ainternal_loaded\"); assert.NoError(t, err) {\n\t\t\tassert.True(t, app.Internal)\n\t\t}\n\t}\n}\n\nfunc TestPluginFileLoadError(t *testing.T) {\n\terr := pluginFileLoadError{Filename: \"test.so\", UnderlyingError: errors.New(\"test error\")}\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"test.so\")\n\tassert.Contains(t, err.Error(), \"test error\")\n}\n"
  },
  {
    "path": "plugin/manager_test_norace.go",
    "content": "//go:build !race\n// +build !race\n\npackage plugin\n\nvar extraGoBuildFlags = []string{}\n"
  },
  {
    "path": "plugin/manager_test_race.go",
    "content": "//go:build race\n// +build race\n\npackage plugin\n\nvar extraGoBuildFlags = []string{\"-race\"}\n"
  },
  {
    "path": "plugin/messagehandler.go",
    "content": "package plugin\n\nimport (\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin/compat\"\n)\n\ntype redirectToChannel struct {\n\tApplicationID uint\n\tUserID        uint\n\tMessages      chan MessageWithUserID\n}\n\n// MessageWithUserID encapsulates a message with a given user ID.\ntype MessageWithUserID struct {\n\tMessage model.MessageExternal\n\tUserID  uint\n}\n\n// SendMessage sends a message to the underlying message channel.\nfunc (c redirectToChannel) SendMessage(msg compat.Message) error {\n\tc.Messages <- MessageWithUserID{\n\t\tMessage: model.MessageExternal{\n\t\t\tApplicationID: c.ApplicationID,\n\t\t\tMessage:       msg.Message,\n\t\t\tTitle:         msg.Title,\n\t\t\tPriority:      &msg.Priority,\n\t\t\tDate:          time.Now(),\n\t\t\tExtras:        msg.Extras,\n\t\t},\n\t\tUserID: c.UserID,\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "plugin/pluginenabled.go",
    "content": "package plugin\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc requirePluginEnabled(id uint, db Database) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tconf, err := db.GetPluginConfByID(id)\n\t\tif err != nil {\n\t\t\tc.AbortWithError(500, err)\n\t\t\treturn\n\t\t}\n\t\tif conf == nil || !conf.Enabled {\n\t\t\tc.AbortWithError(400, errors.New(\"plugin is disabled\"))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "plugin/pluginenabled_test.go",
    "content": "package plugin\n\nimport (\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestRequirePluginEnabled(t *testing.T) {\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tconf := &model.PluginConf{\n\t\tID:      1,\n\t\tUserID:  1,\n\t\tEnabled: true,\n\t}\n\tdb.CreatePluginConf(conf)\n\n\tg := gin.New()\n\n\tmux := g.Group(\"/\", requirePluginEnabled(1, db))\n\n\tmux.GET(\"/\", func(c *gin.Context) {\n\t\tc.Status(200)\n\t})\n\n\tgetCode := func() int {\n\t\tr := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\tw := httptest.NewRecorder()\n\t\tg.ServeHTTP(w, r)\n\t\treturn w.Code\n\t}\n\n\tassert.Equal(t, 200, getCode())\n\n\tconf.Enabled = false\n\tdb.UpdatePluginConf(conf)\n\tassert.Equal(t, 400, getCode())\n}\n"
  },
  {
    "path": "plugin/storagehandler.go",
    "content": "package plugin\n\ntype dbStorageHandler struct {\n\tpluginID uint\n\tdb       Database\n}\n\nfunc (c dbStorageHandler) Save(b []byte) error {\n\tconf, err := c.db.GetPluginConfByID(c.pluginID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconf.Storage = b\n\treturn c.db.UpdatePluginConf(conf)\n}\n\nfunc (c dbStorageHandler) Load() ([]byte, error) {\n\tpluginConf, err := c.db.GetPluginConfByID(c.pluginID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn pluginConf.Storage, nil\n}\n"
  },
  {
    "path": "plugin/testing/broken/cantinstantiate/main.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tModulePath: \"github.com/gotify/server/v2/plugin/testing/broken/noinstance\",\n\t}\n}\n\n// Plugin is plugin instance\ntype Plugin struct{}\n\n// Enable implements plugin.Plugin\nfunc (c *Plugin) Enable() error {\n\treturn errors.New(\"cannot instantiate\")\n}\n\n// Disable implements plugin.Plugin\nfunc (c *Plugin) Disable() error {\n\treturn nil\n}\n\n// NewGotifyPluginInstance creates a plugin instance for a user context.\nfunc NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {\n\treturn &Plugin{}\n}\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/broken/malformedconstructor/main.go",
    "content": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tModulePath: \"github.com/gotify/server/v2/plugin/testing/broken/malformedconstructor\",\n\t}\n}\n\n// Plugin is plugin instance\ntype Plugin struct{}\n\n// Enable implements plugin.Plugin\nfunc (c *Plugin) Enable() error {\n\treturn nil\n}\n\n// Disable implements plugin.Plugin\nfunc (c *Plugin) Disable() error {\n\treturn nil\n}\n\n// NewGotifyPluginInstance creates a plugin instance for a user context.\nfunc NewGotifyPluginInstance(ctx plugin.UserContext) interface{} {\n\treturn &Plugin{}\n}\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/broken/noinstance/main.go",
    "content": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() plugin.Info {\n\treturn plugin.Info{\n\t\tModulePath: \"github.com/gotify/server/v2/plugin/testing/broken/noinstance\",\n\t}\n}\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/broken/nothing/main.go",
    "content": "package main\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/broken/unknowninfo/main.go",
    "content": "package main\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() string {\n\treturn \"github.com/gotify/server/v2/plugin/testing/broken/unknowninfo\"\n}\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/mock/mock.go",
    "content": "package mock\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/gotify/server/v2/plugin/compat\"\n)\n\n// ModulePath is for convenient access of the module path of this mock plugin\nconst ModulePath = \"github.com/gotify/server/v2/plugin/testing/mock\"\n\n// Name is for convenient access of the module path of the name of this mock plugin\nconst Name = \"mock plugin\"\n\n// Plugin is a mock plugin.\ntype Plugin struct {\n\tInstances []PluginInstance\n}\n\n// PluginInfo implements loader.PluginCompat\nfunc (c *Plugin) PluginInfo() compat.Info {\n\treturn compat.Info{\n\t\tModulePath: ModulePath,\n\t\tName:       Name,\n\t}\n}\n\n// NewPluginInstance implements loader.PluginCompat\nfunc (c *Plugin) NewPluginInstance(ctx compat.UserContext) compat.PluginInstance {\n\tinst := PluginInstance{UserCtx: ctx, capabilities: compat.Capabilities{compat.Configurer, compat.Storager, compat.Messenger, compat.Displayer}}\n\tc.Instances = append(c.Instances, inst)\n\treturn &inst\n}\n\n// APIVersion implements loader.PluginCompat\nfunc (c *Plugin) APIVersion() string {\n\treturn \"v1\"\n}\n\n// PluginInstance is a mock plugin instance\ntype PluginInstance struct {\n\tUserCtx        compat.UserContext\n\tEnabled        bool\n\tDisplayString  string\n\tConfig         *PluginConfig\n\tstorageHandler compat.StorageHandler\n\tmessageHandler compat.MessageHandler\n\tcapabilities   compat.Capabilities\n\tBasePath       string\n}\n\n// PluginConfig is a mock plugin config struct\ntype PluginConfig struct {\n\tTestKey    string\n\tIsNotValid bool\n}\n\nvar (\n\tdisableFailUsers = make(map[uint]error)\n\tenableFailUsers  = make(map[uint]error)\n)\n\n// ReturnErrorOnEnableForUser registers a uid which will throw an error on enabling.\nfunc ReturnErrorOnEnableForUser(uid uint, err error) {\n\tenableFailUsers[uid] = err\n}\n\n// ReturnErrorOnDisableForUser registers a uid which will throw an error on disabling.\nfunc ReturnErrorOnDisableForUser(uid uint, err error) {\n\tdisableFailUsers[uid] = err\n}\n\n// Enable implements compat.PluginInstance\nfunc (c *PluginInstance) Enable() error {\n\tif err, ok := enableFailUsers[c.UserCtx.ID]; ok {\n\t\treturn err\n\t}\n\tc.Enabled = true\n\treturn nil\n}\n\n// Disable implements compat.PluginInstance\nfunc (c *PluginInstance) Disable() error {\n\tif err, ok := disableFailUsers[c.UserCtx.ID]; ok {\n\t\treturn err\n\t}\n\tc.Enabled = false\n\treturn nil\n}\n\n// SetMessageHandler implements compat.Messenger\nfunc (c *PluginInstance) SetMessageHandler(h compat.MessageHandler) {\n\tc.messageHandler = h\n}\n\n// SetStorageHandler implements compat.Storager\nfunc (c *PluginInstance) SetStorageHandler(handler compat.StorageHandler) {\n\tc.storageHandler = handler\n}\n\n// SetStorage sets current storage\nfunc (c *PluginInstance) SetStorage(b []byte) error {\n\treturn c.storageHandler.Save(b)\n}\n\n// GetStorage sets current storage\nfunc (c *PluginInstance) GetStorage() ([]byte, error) {\n\treturn c.storageHandler.Load()\n}\n\n// RegisterWebhook implements compat.Webhooker\nfunc (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.RouterGroup) {\n\tc.BasePath = basePath\n}\n\n// SetCapability changes the capability of this plugin\nfunc (c *PluginInstance) SetCapability(p compat.Capability, enable bool) {\n\tif enable {\n\t\tfor _, cap := range c.capabilities {\n\t\t\tif cap == p {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tc.capabilities = append(c.capabilities, p)\n\t} else {\n\t\tnewCap := make(compat.Capabilities, 0)\n\t\tfor _, cap := range c.capabilities {\n\t\t\tif cap == p {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnewCap = append(newCap, cap)\n\t\t}\n\t\tc.capabilities = newCap\n\t}\n}\n\n// Supports implements compat.PluginInstance\nfunc (c *PluginInstance) Supports() compat.Capabilities {\n\treturn c.capabilities\n}\n\n// DefaultConfig implements compat.Configuror\nfunc (c *PluginInstance) DefaultConfig() interface{} {\n\treturn &PluginConfig{\n\t\tTestKey:    \"default\",\n\t\tIsNotValid: false,\n\t}\n}\n\n// ValidateAndSetConfig implements compat.Configuror\nfunc (c *PluginInstance) ValidateAndSetConfig(config interface{}) error {\n\tif (config.(*PluginConfig)).IsNotValid {\n\t\treturn errors.New(\"conf is not valid\")\n\t}\n\tc.Config = config.(*PluginConfig)\n\treturn nil\n}\n\n// GetDisplay implements compat.Displayer\nfunc (c *PluginInstance) GetDisplay(url *url.URL) string {\n\treturn c.DisplayString\n}\n\n// TriggerMessage triggers a test message\nfunc (c *PluginInstance) TriggerMessage() {\n\tc.messageHandler.SendMessage(compat.Message{\n\t\tTitle:    \"test message\",\n\t\tMessage:  \"test\",\n\t\tPriority: 2,\n\t\tExtras: map[string]interface{}{\n\t\t\t\"test::string\": \"test\",\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"config:recommended\",\n    \":semanticCommits\",\n    \":semanticCommitTypeAll(chore)\"\n  ],\n  \"labels\": [\n    \"dependencies\"\n  ],\n  \"reviewersFromCodeOwners\": true,\n  \"enabledManagers\": [\n    \"npm\",\n    \"gomod\",\n    \"github-actions\",\n    \"dockerfile\",\n    \"custom.regex\"\n  ],\n  \"customManagers\": [\n    {\n      \"customType\": \"regex\",\n      \"managerFilePatterns\": [\n        \"/^GO_VERSION$/\"\n      ],\n      \"depTypeTemplate\": \"language\",\n      \"matchStrings\": [\n        \"^(?<currentValue>[0-9.]+)\"\n      ],\n      \"extractVersionTemplate\": \"^(?<version>.+)-linux-amd64$\",\n      \"depNameTemplate\": \"docker.io/gotify/build\",\n      \"autoReplaceStringTemplate\": \"{{{newValue}}}\",\n      \"datasourceTemplate\": \"docker\",\n      \"versioningTemplate\": \"docker\"\n    },\n    {\n      \"customType\": \"regex\",\n      \"managerFilePatterns\": [\n        \"/^go.mod$/\"\n      ],\n      \"depTypeTemplate\": \"language\",\n      \"matchStrings\": [\n        \"toolchain go(?<currentValue>[0-9.]+)\\\\n\"\n      ],\n      \"extractVersionTemplate\": \"^(?<version>.+)-linux-amd64$\",\n      \"depNameTemplate\": \"docker.io/gotify/build\",\n      \"autoReplaceStringTemplate\": \"toolchain go{{{newValue}}}\\n\",\n      \"datasourceTemplate\": \"docker\",\n      \"versioningTemplate\": \"docker\"\n    }\n  ],\n  \"ignoreDeps\": [\n    \"go\"\n  ],\n  \"packageRules\": [\n    {\n      \"matchManagers\": [\n        \"gomod\"\n      ],\n      \"matchUpdateTypes\": [\n        \"minor\",\n        \"patch\"\n      ],\n      \"groupName\": \"Bump Go dependencies\",\n      \"groupSlug\": \"bump-dependencies-go\"\n    },\n    {\n      \"matchManagers\": [\n        \"npm\"\n      ],\n      \"matchUpdateTypes\": [\n        \"minor\",\n        \"patch\"\n      ],\n      \"groupName\": \"Bump npm dependencies\",\n      \"groupSlug\": \"bump-dependencies-npm\"\n    },\n    {\n      \"matchDatasources\": [\"npm\"],\n      \"minimumReleaseAge\": \"3 days\"\n    },\n    {\n      \"matchDepNames\": [\n        \"github.com/gotify/build\"\n      ],\n      \"groupName\": \"Bump gotify/build\",\n      \"groupSlug\": \"bump-gotify-build\"\n    }\n  ],\n  \"postUpdateOptions\": [\n    \"gomodTidy\"\n  ]\n}\n"
  },
  {
    "path": "router/router.go",
    "content": "package router\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/location\"\n\t\"github.com/gotify/server/v2/api\"\n\t\"github.com/gotify/server/v2/api/stream\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/config\"\n\t\"github.com/gotify/server/v2/database\"\n\t\"github.com/gotify/server/v2/docs\"\n\tgerror \"github.com/gotify/server/v2/error\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/plugin\"\n\t\"github.com/gotify/server/v2/ui\"\n)\n\n// Create creates the gin engine with all routes.\nfunc Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Configuration) (*gin.Engine, func()) {\n\tg := gin.New()\n\n\tg.RemoveExtraSlash = true\n\tg.RemoteIPHeaders = []string{\"X-Forwarded-For\"}\n\tg.SetTrustedProxies(conf.Server.TrustedProxies)\n\tg.ForwardedByClientIP = true\n\n\tg.Use(func(ctx *gin.Context) {\n\t\t// Map sockets \"@\" to 127.0.0.1, because gin-gonic can only trust IPs.\n\t\tif ctx.Request.RemoteAddr == \"@\" {\n\t\t\tctx.Request.RemoteAddr = \"127.0.0.1:65535\"\n\t\t}\n\t})\n\n\tg.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default())\n\tg.NoRoute(gerror.NotFound())\n\n\tif conf.Server.SSL.Enabled && conf.Server.SSL.RedirectToHTTPS {\n\t\tg.Use(func(ctx *gin.Context) {\n\t\t\tif ctx.Request.TLS != nil {\n\t\t\t\tctx.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif ctx.Request.Method != http.MethodGet && ctx.Request.Method != http.MethodHead {\n\t\t\t\tctx.Data(http.StatusBadRequest, \"text/plain; charset=utf-8\", []byte(\"Use HTTPS\"))\n\t\t\t\tctx.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t\thost := ctx.Request.Host\n\t\t\tif idx := strings.LastIndex(host, \":\"); idx != -1 {\n\t\t\t\thost = host[:idx]\n\t\t\t}\n\t\t\tif conf.Server.SSL.Port != 443 {\n\t\t\t\thost = fmt.Sprintf(\"%s:%d\", host, conf.Server.SSL.Port)\n\t\t\t}\n\t\t\tctx.Redirect(http.StatusFound, fmt.Sprintf(\"https://%s%s\", host, ctx.Request.RequestURI))\n\t\t\tctx.Abort()\n\t\t})\n\t}\n\tstreamHandler := stream.New(\n\t\ttime.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins)\n\tgo func() {\n\t\tticker := time.NewTicker(5 * time.Minute)\n\t\tfor range ticker.C {\n\t\t\tconnectedTokens := streamHandler.CollectConnectedClientTokens()\n\t\t\tnow := time.Now()\n\t\t\tdb.UpdateClientTokensLastUsed(connectedTokens, &now)\n\t\t}\n\t}()\n\tauthentication := auth.Auth{DB: db}\n\tmessageHandler := api.MessageAPI{Notifier: streamHandler, DB: db}\n\thealthHandler := api.HealthAPI{DB: db}\n\tclientHandler := api.ClientAPI{\n\t\tDB:            db,\n\t\tImageDir:      conf.UploadedImagesDir,\n\t\tNotifyDeleted: streamHandler.NotifyDeletedClient,\n\t}\n\tapplicationHandler := api.ApplicationAPI{\n\t\tDB:       db,\n\t\tImageDir: conf.UploadedImagesDir,\n\t}\n\tuserChangeNotifier := new(api.UserChangeNotifier)\n\tuserHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration}\n\n\tpluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group(\"/plugin/:id/custom/\"), streamHandler)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tpluginHandler := api.PluginAPI{\n\t\tManager:  pluginManager,\n\t\tNotifier: streamHandler,\n\t\tDB:       db,\n\t}\n\n\tuserChangeNotifier.OnUserDeleted(streamHandler.NotifyDeletedUser)\n\tuserChangeNotifier.OnUserDeleted(pluginManager.RemoveUser)\n\tuserChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID)\n\n\tui.Register(g, *vInfo, conf.Registration)\n\n\tg.Match([]string{\"GET\", \"HEAD\"}, \"/health\", healthHandler.Health)\n\tg.GET(\"/swagger\", docs.Serve)\n\tg.StaticFS(\"/image\", &onlyImageFS{inner: gin.Dir(conf.UploadedImagesDir, false)})\n\n\tg.GET(\"/docs\", docs.UI)\n\n\tg.Use(func(ctx *gin.Context) {\n\t\tctx.Header(\"Content-Type\", \"application/json\")\n\t\tfor header, value := range conf.Server.ResponseHeaders {\n\t\t\tctx.Header(header, value)\n\t\t}\n\t})\n\tg.Use(cors.New(auth.CorsConfig(conf)))\n\n\t{\n\t\tg.GET(\"/plugin\", authentication.RequireClient(), pluginHandler.GetPlugins)\n\t\tpluginRoute := g.Group(\"/plugin/\", authentication.RequireClient())\n\t\t{\n\t\t\tpluginRoute.GET(\"/:id/config\", pluginHandler.GetConfig)\n\t\t\tpluginRoute.POST(\"/:id/config\", pluginHandler.UpdateConfig)\n\t\t\tpluginRoute.GET(\"/:id/display\", pluginHandler.GetDisplay)\n\t\t\tpluginRoute.POST(\"/:id/enable\", pluginHandler.EnablePlugin)\n\t\t\tpluginRoute.POST(\"/:id/disable\", pluginHandler.DisablePlugin)\n\t\t}\n\t}\n\n\tg.Group(\"/user\").Use(authentication.Optional()).POST(\"\", userHandler.CreateUser)\n\n\tg.OPTIONS(\"/*any\")\n\n\t// swagger:operation GET /version version getVersion\n\t//\n\t// Get version information.\n\t//\n\t// ---\n\t// produces: [application/json]\n\t// responses:\n\t//   200:\n\t//     description: Ok\n\t//     schema:\n\t//         $ref: \"#/definitions/VersionInfo\"\n\tg.GET(\"version\", func(ctx *gin.Context) {\n\t\tctx.JSON(200, vInfo)\n\t})\n\n\tg.Group(\"/\").Use(authentication.RequireApplicationToken()).POST(\"/message\", messageHandler.CreateMessage)\n\n\tclientAuth := g.Group(\"\")\n\t{\n\t\tclientAuth.Use(authentication.RequireClient())\n\t\tapp := clientAuth.Group(\"/application\")\n\t\t{\n\t\t\tapp.GET(\"\", applicationHandler.GetApplications)\n\n\t\t\tapp.POST(\"\", applicationHandler.CreateApplication)\n\n\t\t\tapp.POST(\"/:id/image\", applicationHandler.UploadApplicationImage)\n\n\t\t\tapp.DELETE(\"/:id/image\", applicationHandler.RemoveApplicationImage)\n\n\t\t\tapp.PUT(\"/:id\", applicationHandler.UpdateApplication)\n\n\t\t\tapp.DELETE(\"/:id\", applicationHandler.DeleteApplication)\n\n\t\t\ttokenMessage := app.Group(\"/:id/message\")\n\t\t\t{\n\t\t\t\ttokenMessage.GET(\"\", messageHandler.GetMessagesWithApplication)\n\n\t\t\t\ttokenMessage.DELETE(\"\", messageHandler.DeleteMessageWithApplication)\n\t\t\t}\n\t\t}\n\n\t\tclient := clientAuth.Group(\"/client\")\n\t\t{\n\t\t\tclient.GET(\"\", clientHandler.GetClients)\n\n\t\t\tclient.POST(\"\", clientHandler.CreateClient)\n\n\t\t\tclient.DELETE(\"/:id\", clientHandler.DeleteClient)\n\n\t\t\tclient.PUT(\"/:id\", clientHandler.UpdateClient)\n\t\t}\n\n\t\tmessage := clientAuth.Group(\"/message\")\n\t\t{\n\t\t\tmessage.GET(\"\", messageHandler.GetMessages)\n\n\t\t\tmessage.DELETE(\"\", messageHandler.DeleteMessages)\n\n\t\t\tmessage.DELETE(\"/:id\", messageHandler.DeleteMessage)\n\t\t}\n\n\t\tclientAuth.GET(\"/stream\", streamHandler.Handle)\n\n\t\tclientAuth.GET(\"current/user\", userHandler.GetCurrentUser)\n\n\t\tclientAuth.POST(\"current/user/password\", userHandler.ChangePassword)\n\t}\n\n\tauthAdmin := g.Group(\"/user\")\n\t{\n\t\tauthAdmin.Use(authentication.RequireAdmin())\n\n\t\tauthAdmin.GET(\"\", userHandler.GetUsers)\n\n\t\tauthAdmin.DELETE(\"/:id\", userHandler.DeleteUserByID)\n\n\t\tauthAdmin.GET(\"/:id\", userHandler.GetUserByID)\n\n\t\tauthAdmin.POST(\"/:id\", userHandler.UpdateUserByID)\n\t}\n\treturn g, streamHandler.Close\n}\n\nvar tokenRegexp = regexp.MustCompile(\"token=[^&]+\")\n\nfunc logFormatter(param gin.LogFormatterParams) string {\n\tif (param.ClientIP == \"127.0.0.1\" || param.ClientIP == \"::1\") && param.Path == \"/health\" {\n\t\treturn \"\"\n\t}\n\n\tvar statusColor, methodColor, resetColor string\n\tif param.IsOutputColor() {\n\t\tstatusColor = param.StatusCodeColor()\n\t\tmethodColor = param.MethodColor()\n\t\tresetColor = param.ResetColor()\n\t}\n\n\tif param.Latency > time.Minute {\n\t\tparam.Latency = param.Latency - param.Latency%time.Second\n\t}\n\tpath := tokenRegexp.ReplaceAllString(param.Path, \"token=[masked]\")\n\treturn fmt.Sprintf(\"%v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\\n%s\",\n\t\tparam.TimeStamp.Format(time.RFC3339),\n\t\tstatusColor, param.StatusCode, resetColor,\n\t\tparam.Latency,\n\t\tparam.ClientIP,\n\t\tmethodColor, param.Method, resetColor,\n\t\tpath,\n\t\tparam.ErrorMessage,\n\t)\n}\n\ntype onlyImageFS struct {\n\tinner http.FileSystem\n}\n\nfunc (fs *onlyImageFS) Open(name string) (http.File, error) {\n\text := filepath.Ext(name)\n\tif !api.ValidApplicationImageExt(ext) {\n\t\treturn nil, fmt.Errorf(\"invalid file\")\n\t}\n\treturn fs.inner.Open(name)\n}\n"
  },
  {
    "path": "router/router_test.go",
    "content": "package router\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/config\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nvar (\n\tclient        = &http.Client{}\n\tforbiddenJSON = `{\"error\":\"Forbidden\", \"errorCode\":403, \"errorDescription\":\"you are not allowed to access this api\"}`\n)\n\nfunc TestIntegrationSuite(t *testing.T) {\n\tsuite.Run(t, new(IntegrationSuite))\n}\n\ntype IntegrationSuite struct {\n\tsuite.Suite\n\tdb       *testdb.Database\n\tserver   *httptest.Server\n\tclosable func()\n}\n\nfunc (s *IntegrationSuite) BeforeTest(string, string) {\n\tmode.Set(mode.TestDev)\n\tvar err error\n\ts.db = testdb.NewDBWithDefaultUser(s.T())\n\tassert.Nil(s.T(), err)\n\n\tg, closable := Create(s.db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config.Configuration{PassStrength: 5},\n\t)\n\ts.closable = closable\n\ts.server = httptest.NewServer(g)\n}\n\nfunc (s *IntegrationSuite) AfterTest(string, string) {\n\ts.closable()\n\ts.db.Close()\n\ts.server.Close()\n}\n\nfunc (s *IntegrationSuite) TestVersionInfo() {\n\treq := s.newRequest(\"GET\", \"version\", \"\")\n\n\tdoRequestAndExpect(s.T(), req, 200, `{\"version\":\"1.0.0\", \"commit\":\"asdasds\", \"buildDate\":\"2018-02-20-17:30:47\"}`)\n}\n\nfunc (s *IntegrationSuite) TestHeaderInDev() {\n\tmode.Set(mode.TestDev)\n\treq := s.newRequest(\"GET\", \"version\", \"\")\n\t// Needs an origin to indicate that it is a CORS request\n\treq.Header.Add(\"Origin\", \"some-origin\")\n\n\tres, err := client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.NotEmpty(s.T(), res.Header.Get(\"Access-Control-Allow-Origin\"))\n}\n\nfunc (s *IntegrationSuite) TestHeaderInProd() {\n\tmode.Set(mode.Prod)\n\treq := s.newRequest(\"GET\", \"version\", \"\")\n\n\tres, err := client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Empty(s.T(), res.Header.Get(\"Access-Control-Allow-Origin\"))\n}\n\nfunc TestHeadersFromConfiguration(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.ResponseHeaders = map[string]string{\n\t\t\"New-Cool-Header\":             \"Nice\",\n\t\t\"Access-Control-Allow-Origin\": \"http://test1.com\",\n\t}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"http://test1.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, \"Nice\", res.Header.Get(\"New-Cool-Header\"))\n}\n\nfunc TestHeadersFromCORSConfig(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.Cors.AllowOrigins = []string{\"---\", \"http://test.com\"}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\treq.Header.Add(\"Origin\", \"http://test.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"http://test.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n}\n\nfunc TestInvalidOrigin(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.Cors.AllowOrigins = []string{\"---\", \"http://test.com\"}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Origin\", \"http://test1.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, http.StatusForbidden, res.StatusCode)\n}\n\nfunc TestAllowedOriginFromResponseHeaders(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.ResponseHeaders = map[string]string{\n\t\t\"Access-Control-Allow-Origin\":  \"http://test1.com\",\n\t\t\"Access-Control-Allow-Methods\": \"GET,POST\",\n\t}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Origin\", \"http://test1.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"http://test1.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, http.StatusOK, res.StatusCode)\n\n\treq.Header.Set(\"Origin\", \"http://example.com\")\n\tres, err = client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"http://test1.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, http.StatusForbidden, res.StatusCode)\n}\n\nfunc TestAllowedWildcardOriginInHeader(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.ResponseHeaders = map[string]string{\n\t\t\"Access-Control-Allow-Origin\":  \"*\",\n\t\t\"Access-Control-Allow-Methods\": \"GET,POST\",\n\t}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Origin\", \"http://test1.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"*\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, http.StatusOK, res.StatusCode)\n}\n\nfunc TestCORSHeaderRegex(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.Cors.AllowOrigins = []string{\"---\", \"^http://test\\\\d{3}.com$\"}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\treq.Header.Add(\"Origin\", \"http://test123.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, \"http://test123.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n}\n\n// We want headers in cors config to override the responseheaders config.\nfunc TestCORSConfigOverride(t *testing.T) {\n\tmode.Set(mode.Prod)\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tdefer db.Close()\n\n\tconfig := config.Configuration{PassStrength: 5}\n\tconfig.Server.ResponseHeaders = map[string]string{\n\t\t\"New-Cool-Header\":              \"Nice\",\n\t\t\"Access-Control-Allow-Origin\":  \"http://example.com/\",\n\t\t\"Access-Control-Allow-Methods\": \"321test\",\n\t\t\"Access-Control-Allow-Headers\": \"some-headers\",\n\t}\n\tconfig.Server.Cors.AllowOrigins = []string{\"http://test123.com\", \"aaa\"}\n\tconfig.Server.Cors.AllowMethods = []string{\"GET\", \"OPTIONS\"}\n\tconfig.Server.Cors.AllowHeaders = []string{\"Content-Type\"}\n\n\tg, closable := Create(db.GormDatabase,\n\t\t&model.VersionInfo{Version: \"1.0.0\", BuildDate: \"2018-02-20-17:30:47\", Commit: \"asdasds\"},\n\t\t&config,\n\t)\n\tserver := httptest.NewServer(g)\n\n\tdefer func() {\n\t\tclosable()\n\t\tserver.Close()\n\t}()\n\n\treq, err := http.NewRequest(\"OPTIONS\", fmt.Sprintf(\"%s/%s\", server.URL, \"version\"), nil)\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\treq.Header.Add(\"Origin\", \"http://test123.com\")\n\tassert.Nil(t, err)\n\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusNoContent, res.StatusCode)\n\tassert.Equal(t, \"Nice\", res.Header.Get(\"New-Cool-Header\"))\n\tassert.Equal(t, \"http://test123.com\", res.Header.Get(\"Access-Control-Allow-Origin\"))\n\tassert.Equal(t, \"GET,OPTIONS\", res.Header.Get(\"Access-Control-Allow-Methods\"))\n\tassert.Equal(t, \"Content-Type\", res.Header.Get(\"Access-Control-Allow-Headers\"))\n\n\treq.Header.Set(\"Origin\", \"http://example.com\")\n\tres, err = client.Do(req)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusForbidden, res.StatusCode)\n}\n\nfunc (s *IntegrationSuite) TestOptionsRequest() {\n\treq := s.newRequest(\"OPTIONS\", \"version\", \"\")\n\n\tres, err := client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), res.StatusCode, 200)\n}\n\nfunc (s *IntegrationSuite) TestSendMessage() {\n\treq := s.newRequest(\"POST\", \"application\", `{\"name\": \"backup-server\"}`)\n\treq.SetBasicAuth(\"admin\", \"pw\")\n\tres, err := client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), 200, res.StatusCode)\n\ttoken := &model.Application{}\n\tjson.NewDecoder(res.Body).Decode(token)\n\tassert.Equal(s.T(), \"backup-server\", token.Name)\n\n\treq = s.newRequest(\"POST\", \"message\", `{\"message\": \"backup done\", \"title\": \"backup done\"}`)\n\treq.Header.Add(\"X-Gotify-Key\", token.Token)\n\tres, err = client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), 200, res.StatusCode)\n\n\treq = s.newRequest(\"GET\", \"message\", \"\")\n\treq.SetBasicAuth(\"admin\", \"pw\")\n\tres, err = client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), 200, res.StatusCode)\n\tmsgs := &model.PagedMessages{}\n\tjson.NewDecoder(res.Body).Decode(&msgs)\n\tassert.Len(s.T(), msgs.Messages, 1)\n\n\tmsg := msgs.Messages[0]\n\tassert.Equal(s.T(), \"backup done\", msg.Message)\n\tassert.Equal(s.T(), \"backup done\", msg.Title)\n\tassert.Equal(s.T(), uint(1), msg.ID)\n\tassert.Equal(s.T(), token.ID, msg.ApplicationID)\n}\n\nfunc (s *IntegrationSuite) TestPluginLoadFail_expectPanic() {\n\tdb := testdb.NewDBWithDefaultUser(s.T())\n\tdefer db.Close()\n\n\tassert.Panics(s.T(), func() {\n\t\tCreate(db.GormDatabase, new(model.VersionInfo), &config.Configuration{\n\t\t\tPluginsDir: \"<THIS_PATH_IS_MALFORMED>\",\n\t\t})\n\t})\n}\n\nfunc (s *IntegrationSuite) TestAuthentication() {\n\treq := s.newRequest(\"GET\", \"current/user\", \"\")\n\treq.SetBasicAuth(\"admin\", \"pw\")\n\tdoRequestAndExpect(s.T(), req, 200, `{\"id\": 1, \"name\": \"admin\", \"admin\": true}`)\n\n\treq = s.newRequest(\"GET\", \"current/user\", \"\")\n\treq.SetBasicAuth(\"jmattheis\", \"pw\")\n\tdoRequestAndExpect(s.T(), req, 401, `{\"error\":\"Unauthorized\", \"errorCode\":401, \"errorDescription\":\"you need to provide a valid access token or user credentials to access this api\"}`)\n\n\treq = s.newRequest(\"POST\", \"user\", `{\"name\": \"normal\", \"pass\": \"secret\"}`)\n\treq.SetBasicAuth(\"admin\", \"pw\")\n\tdoRequestAndExpect(s.T(), req, 200, `{\"id\": 2, \"name\": \"normal\", \"admin\": false}`)\n\n\treq = s.newRequest(\"POST\", \"user\", `{\"name\": \"normal2\", \"pass\": \"secret\"}`)\n\treq.SetBasicAuth(\"normal\", \"secret\")\n\tdoRequestAndExpect(s.T(), req, 403, forbiddenJSON)\n\n\treq = s.newRequest(\"POST\", \"message\", `{\"message\": \"backup done\", \"title\": \"backup\"}`)\n\treq.SetBasicAuth(\"normal\", \"secret\")\n\tdoRequestAndExpect(s.T(), req, 403, forbiddenJSON)\n\n\treq = s.newRequest(\"GET\", \"current/user\", \"\")\n\treq.SetBasicAuth(\"normal\", \"secret\")\n\tdoRequestAndExpect(s.T(), req, 200, `{\"id\": 2, \"name\": \"normal\", \"admin\": false}`)\n\n\treq = s.newRequest(\"POST\", \"client\", `{\"name\": \"android-client\"}`)\n\treq.SetBasicAuth(\"normal\", \"secret\")\n\tres, err := client.Do(req)\n\tassert.Nil(s.T(), err)\n\tassert.Equal(s.T(), 200, res.StatusCode)\n\ttoken := &model.Application{}\n\tjson.NewDecoder(res.Body).Decode(token)\n\tassert.Equal(s.T(), \"android-client\", token.Name)\n}\n\nfunc (s *IntegrationSuite) newRequest(method, url, body string) *http.Request {\n\treq, err := http.NewRequest(method, fmt.Sprintf(\"%s/%s\", s.server.URL, url), strings.NewReader(body))\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\tassert.Nil(s.T(), err)\n\treturn req\n}\n\nfunc doRequestAndExpect(t *testing.T, req *http.Request, code int, json string) {\n\tres, err := client.Do(req)\n\tassert.Nil(t, err)\n\tbuf := new(bytes.Buffer)\n\tbuf.ReadFrom(res.Body)\n\n\tassert.Equal(t, code, res.StatusCode)\n\tassert.JSONEq(t, json, buf.String())\n}\n"
  },
  {
    "path": "runner/runner.go",
    "content": "package runner\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/config\"\n\t\"golang.org/x/crypto/acme\"\n\t\"golang.org/x/crypto/acme/autocert\"\n)\n\n// Run starts the http server and if configured a https server.\nfunc Run(router http.Handler, conf *config.Configuration) error {\n\tshutdown := make(chan error)\n\tgo doShutdownOnSignal(shutdown)\n\n\thttpListener, err := startListening(\"plain connection\", conf.Server.ListenAddr, conf.Server.Port, conf.Server.KeepAlivePeriodSeconds)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer httpListener.Close()\n\n\ts := &http.Server{Handler: router}\n\tif conf.Server.SSL.Enabled {\n\t\tif conf.Server.SSL.LetsEncrypt.Enabled {\n\t\t\tapplyLetsEncrypt(s, conf)\n\t\t} else if conf.Server.SSL.CertFile == \"\" || conf.Server.SSL.CertKey == \"\" {\n\t\t\tlog.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\")\n\t\t}\n\n\t\thttpsListener, err := startListening(\"TLS connection\", conf.Server.SSL.ListenAddr, conf.Server.SSL.Port, conf.Server.KeepAlivePeriodSeconds)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer httpsListener.Close()\n\n\t\tgo func() {\n\t\t\terr := s.ServeTLS(httpsListener, conf.Server.SSL.CertFile, conf.Server.SSL.CertKey)\n\t\t\tdoShutdown(shutdown, err)\n\t\t}()\n\t}\n\tgo func() {\n\t\terr := s.Serve(httpListener)\n\t\tdoShutdown(shutdown, err)\n\t}()\n\n\terr = <-shutdown\n\tfmt.Println(\"Shutting down:\", err)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\treturn s.Shutdown(ctx)\n}\n\nfunc doShutdownOnSignal(shutdown chan<- error) {\n\tonSignal := make(chan os.Signal, 1)\n\tsignal.Notify(onSignal, os.Interrupt, syscall.SIGTERM)\n\tsig := <-onSignal\n\tdoShutdown(shutdown, fmt.Errorf(\"received signal %s\", sig))\n}\n\nfunc doShutdown(shutdown chan<- error, err error) {\n\tselect {\n\tcase shutdown <- err:\n\tdefault:\n\t\t// If there is no one listening on the shutdown channel, then the\n\t\t// shutdown is already initiated and we can ignore these errors.\n\t}\n}\n\nfunc startListening(connectionType, listenAddr string, port, keepAlive int) (net.Listener, error) {\n\tnetwork, addr := getNetworkAndAddr(listenAddr, port)\n\tlc := net.ListenConfig{KeepAlive: time.Duration(keepAlive) * time.Second}\n\n\toldMask := umask(0)\n\tdefer umask(oldMask)\n\n\tl, err := lc.Listen(context.Background(), network, addr)\n\tif err == nil {\n\t\tfmt.Println(\"Started listening for\", connectionType, \"on\", l.Addr().Network(), l.Addr().String())\n\t}\n\treturn l, err\n}\n\nfunc getNetworkAndAddr(listenAddr string, port int) (string, string) {\n\tif strings.HasPrefix(listenAddr, \"unix:\") {\n\t\treturn \"unix\", strings.TrimPrefix(listenAddr, \"unix:\")\n\t}\n\treturn \"tcp\", fmt.Sprintf(\"%s:%d\", listenAddr, port)\n}\n\ntype LoggingRoundTripper struct {\n\tName         string\n\tRoundTripper http.RoundTripper\n}\n\nfunc (l *LoggingRoundTripper) RoundTrip(r *http.Request) (resp *http.Response, err error) {\n\tresp, err = l.RoundTripper.RoundTrip(r)\n\tif resp.StatusCode == 429 {\n\t\tlog.Printf(\"%s Rate Limited: Retry-After %s on %s %s\\n\", l.Name, resp.Header.Get(\"Retry-After\"), r.Method, r.URL.String())\n\t} else if resp.StatusCode >= 400 {\n\t\tlog.Printf(\"%s Request Failed: Unexpected status code %d on %s %s\\n\", l.Name, resp.StatusCode, r.Method, r.URL.String())\n\t} else if err != nil {\n\t\tlog.Printf(\"%s Request Failed: %s on %s %s\\n\", l.Name, err.Error(), r.Method, r.URL.String())\n\t}\n\treturn resp, err\n}\n\nfunc applyLetsEncrypt(s *http.Server, conf *config.Configuration) {\n\thttpClient := &http.Client{\n\t\tTransport: &LoggingRoundTripper{Name: \"Let's Encrypt\", RoundTripper: http.DefaultTransport},\n\t\tTimeout:   60 * time.Second,\n\t}\n\n\tacmeClient := &acme.Client{\n\t\tHTTPClient:   httpClient,\n\t\tDirectoryURL: conf.Server.SSL.LetsEncrypt.DirectoryURL,\n\t}\n\tcertManager := autocert.Manager{\n\t\tClient: acmeClient,\n\t\tPrompt: func(tosURL string) bool {\n\t\t\tif !conf.Server.SSL.LetsEncrypt.AcceptTOS {\n\t\t\t\tlog.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)\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\t\tHostPolicy: autocert.HostWhitelist(conf.Server.SSL.LetsEncrypt.Hosts...),\n\t\tCache:      autocert.DirCache(conf.Server.SSL.LetsEncrypt.Cache),\n\t}\n\ts.Handler = certManager.HTTPHandler(s.Handler)\n\ts.TLSConfig = certManager.TLSConfig()\n}\n"
  },
  {
    "path": "runner/umask.go",
    "content": "//go:build unix\n\npackage runner\n\nimport \"syscall\"\n\nvar umask = syscall.Umask\n"
  },
  {
    "path": "runner/umask_fallback.go",
    "content": "//go:build !unix\n\npackage runner\n\nfunc umask(_ int) int {\n\treturn 0\n}\n"
  },
  {
    "path": "test/asserts.go",
    "content": "package test\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http/httptest\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// BodyEquals asserts the content from the response recorder with the encoded json of the provided instance.\nfunc BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) {\n\tbytes, err := io.ReadAll(recorder.Body)\n\tassert.Nil(t, err)\n\tactual := string(bytes)\n\n\tJSONEquals(t, obj, actual)\n}\n\n// JSONEquals asserts the content of the string with the encoded json of the provided instance.\nfunc JSONEquals(t assert.TestingT, obj interface{}, expected string) {\n\tbytes, err := json.Marshal(obj)\n\tassert.Nil(t, err)\n\tobjJSON := string(bytes)\n\n\tassert.JSONEq(t, expected, objJSON)\n}\n\ntype unreadableReader struct{}\n\nfunc (c unreadableReader) Read([]byte) (int, error) {\n\treturn 0, errors.New(\"this reader cannot be read\")\n}\n\n// UnreadableReader returns an unreadable reader, used to mock IO issues.\nfunc UnreadableReader() io.Reader {\n\treturn unreadableReader{}\n}\n"
  },
  {
    "path": "test/asserts_test.go",
    "content": "package test_test\n\nimport (\n\t\"io\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype obj struct {\n\tTest string\n\tID   int\n}\n\ntype fakeTesting struct {\n\thasErrors bool\n}\n\nfunc (t *fakeTesting) Errorf(format string, args ...interface{}) {\n\tt.hasErrors = true\n}\n\nfunc Test_BodyEquals(t *testing.T) {\n\trecorder := httptest.NewRecorder()\n\trecorder.WriteString(`{\"ID\": 2, \"Test\": \"asd\"}`)\n\n\tfakeTesting := &fakeTesting{}\n\n\ttest.BodyEquals(fakeTesting, &obj{ID: 2, Test: \"asd\"}, recorder)\n\tassert.False(t, fakeTesting.hasErrors)\n}\n\nfunc Test_BodyEquals_failing(t *testing.T) {\n\trecorder := httptest.NewRecorder()\n\trecorder.WriteString(`{\"ID\": 3, \"Test\": \"asd\"}`)\n\n\tfakeTesting := &fakeTesting{}\n\n\ttest.BodyEquals(fakeTesting, &obj{ID: 2, Test: \"asd\"}, recorder)\n\tassert.True(t, fakeTesting.hasErrors)\n}\n\nfunc Test_UnreaableReader(t *testing.T) {\n\t_, err := io.ReadAll(test.UnreadableReader())\n\tassert.Error(t, err)\n}\n"
  },
  {
    "path": "test/assets/text.txt",
    "content": ""
  },
  {
    "path": "test/auth.go",
    "content": "package test\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// WithUser fake an authentication for testing.\nfunc WithUser(ctx *gin.Context, userID uint) {\n\tctx.Set(\"user\", &model.User{ID: userID})\n\tctx.Set(\"userid\", userID)\n}\n"
  },
  {
    "path": "test/auth_test.go",
    "content": "package test_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/auth\"\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/test\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFakeAuth(t *testing.T) {\n\tmode.Set(mode.TestDev)\n\n\tctx, _ := gin.CreateTestContext(nil)\n\ttest.WithUser(ctx, 5)\n\tassert.Equal(t, uint(5), auth.GetUserID(ctx))\n}\n"
  },
  {
    "path": "test/filepath.go",
    "content": "package test\n\nimport (\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"runtime\"\n)\n\n// GetProjectDir returns the correct absolute path of this project.\nfunc GetProjectDir() string {\n\t_, f, _, _ := runtime.Caller(0)\n\tprojectDir, _ := filepath.Abs(path.Join(filepath.Dir(f), \"../\"))\n\treturn projectDir\n}\n\n// WithWd executes a function with the specified working directory.\nfunc WithWd(chDir string, f func(origWd string)) {\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tif err := os.Chdir(chDir); err != nil {\n\t\tpanic(err)\n\t}\n\tdefer os.Chdir(wd)\n\tf(wd)\n}\n"
  },
  {
    "path": "test/filepath_test.go",
    "content": "package test\n\nimport (\n\t\"os\"\n\t\"path\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestProjectPath(t *testing.T) {\n\t_, err := os.Stat(path.Join(GetProjectDir(), \"./README.md\"))\n\tassert.Nil(t, err)\n}\n\nfunc TestWithWd(t *testing.T) {\n\twd1, _ := os.Getwd()\n\ttmpDir := NewTmpDir(\"gotify_withwd\")\n\tdefer tmpDir.Clean()\n\tvar wd2 string\n\tWithWd(tmpDir.Path(), func(origWd string) {\n\t\tassert.Equal(t, wd1, origWd)\n\t\twd2, _ = os.Getwd()\n\t})\n\twd3, _ := os.Getwd()\n\tassert.Equal(t, wd1, wd3)\n\tassert.Equal(t, tmpDir.Path(), wd2)\n\tassert.Nil(t, os.RemoveAll(tmpDir.Path()))\n\n\tassert.Panics(t, func() {\n\t\tWithWd(\"non_exist\", func(string) {})\n\t})\n\n\tassert.Nil(t, os.Mkdir(tmpDir.Path(), 0o644))\n\tif os.Getuid() != 0 { // root is not subject to this check\n\t\tassert.Panics(t, func() {\n\t\t\tWithWd(tmpDir.Path(), func(string) {})\n\t\t})\n\t}\n\tassert.Nil(t, os.Remove(tmpDir.Path()))\n\n\tassert.Nil(t, os.Mkdir(tmpDir.Path(), 0o755))\n\tassert.Panics(t, func() {\n\t\tWithWd(tmpDir.Path(), func(string) {\n\t\t\tassert.Nil(t, os.RemoveAll(tmpDir.Path()))\n\t\t\tWithWd(\".\", func(string) {})\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "test/testdb/database.go",
    "content": "package testdb\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gotify/server/v2/database\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// Database is the wrapper for the gorm database with sleek helper methods.\ntype Database struct {\n\t*database.GormDatabase\n\tt *testing.T\n}\n\n// AppClientBuilder has helper methods to create applications and clients.\ntype AppClientBuilder struct {\n\tuserID uint\n\tdb     *Database\n}\n\n// MessageBuilder has helper methods to create messages.\ntype MessageBuilder struct {\n\tappID uint\n\tdb    *Database\n}\n\n// NewDBWithDefaultUser creates a new test db instance with the default user.\nfunc NewDBWithDefaultUser(t *testing.T) *Database {\n\tdb, err := database.New(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=memory&cache=shared\", fmt.Sprint(time.Now().UnixNano())), \"admin\", \"pw\", 5, true)\n\tassert.Nil(t, err)\n\tassert.NotNil(t, db)\n\treturn &Database{GormDatabase: db, t: t}\n}\n\n// NewDB creates a new test db instance.\nfunc NewDB(t *testing.T) *Database {\n\tdb, err := database.New(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=memory&cache=shared\", fmt.Sprint(time.Now().UnixNano())), \"admin\", \"pw\", 5, false)\n\tassert.Nil(t, err)\n\tassert.NotNil(t, db)\n\treturn &Database{GormDatabase: db, t: t}\n}\n\n// User creates a user and returns a builder for applications and clients.\nfunc (d *Database) User(id uint) *AppClientBuilder {\n\td.NewUser(id)\n\treturn &AppClientBuilder{db: d, userID: id}\n}\n\n// NewUser creates a user and returns the user.\nfunc (d *Database) NewUser(id uint) *model.User {\n\treturn d.NewUserWithName(id, \"user\"+fmt.Sprint(id))\n}\n\n// NewUserWithName creates a user with a name and returns the user.\nfunc (d *Database) NewUserWithName(id uint, name string) *model.User {\n\tuser := &model.User{ID: id, Name: name}\n\td.CreateUser(user)\n\treturn user\n}\n\n// App creates an application and returns a message builder.\nfunc (ab *AppClientBuilder) App(id uint) *MessageBuilder {\n\treturn ab.app(id, false)\n}\n\n// InternalApp creates an internal application and returns a message builder.\nfunc (ab *AppClientBuilder) InternalApp(id uint) *MessageBuilder {\n\treturn ab.app(id, true)\n}\n\nfunc (ab *AppClientBuilder) app(id uint, internal bool) *MessageBuilder {\n\treturn ab.appWithToken(id, \"app\"+fmt.Sprint(id), internal)\n}\n\n// AppWithToken creates an application with a token and returns a message builder.\nfunc (ab *AppClientBuilder) AppWithToken(id uint, token string) *MessageBuilder {\n\treturn ab.appWithToken(id, token, false)\n}\n\n// InternalAppWithToken creates an internal application with a token and returns a message builder.\nfunc (ab *AppClientBuilder) InternalAppWithToken(id uint, token string) *MessageBuilder {\n\treturn ab.appWithToken(id, token, true)\n}\n\nfunc (ab *AppClientBuilder) appWithToken(id uint, token string, internal bool) *MessageBuilder {\n\tab.newAppWithToken(id, token, internal)\n\treturn &MessageBuilder{db: ab.db, appID: id}\n}\n\n// NewAppWithToken creates an application with a token and returns the app.\nfunc (ab *AppClientBuilder) NewAppWithToken(id uint, token string) *model.Application {\n\treturn ab.newAppWithToken(id, token, false)\n}\n\n// NewInternalAppWithToken creates an internal application with a token and returns the app.\nfunc (ab *AppClientBuilder) NewInternalAppWithToken(id uint, token string) *model.Application {\n\treturn ab.newAppWithToken(id, token, true)\n}\n\nfunc (ab *AppClientBuilder) newAppWithToken(id uint, token string, internal bool) *model.Application {\n\tapplication := &model.Application{ID: id, UserID: ab.userID, Token: token, Internal: internal}\n\tab.db.CreateApplication(application)\n\treturn application\n}\n\n// AppWithTokenAndName creates an application with a token and name and returns a message builder.\nfunc (ab *AppClientBuilder) AppWithTokenAndName(id uint, token, name string) *MessageBuilder {\n\treturn ab.appWithTokenAndName(id, token, name, false)\n}\n\n// InternalAppWithTokenAndName creates an internal application with a token and name and returns a message builder.\nfunc (ab *AppClientBuilder) InternalAppWithTokenAndName(id uint, token, name string) *MessageBuilder {\n\treturn ab.appWithTokenAndName(id, token, name, true)\n}\n\nfunc (ab *AppClientBuilder) appWithTokenAndName(id uint, token, name string, internal bool) *MessageBuilder {\n\tab.newAppWithTokenAndName(id, token, name, internal)\n\treturn &MessageBuilder{db: ab.db, appID: id}\n}\n\n// NewAppWithTokenAndName creates an application with a token and name and returns the app.\nfunc (ab *AppClientBuilder) NewAppWithTokenAndName(id uint, token, name string) *model.Application {\n\treturn ab.newAppWithTokenAndName(id, token, name, false)\n}\n\n// NewInternalAppWithTokenAndName creates an internal application with a token and name and returns the app.\nfunc (ab *AppClientBuilder) NewInternalAppWithTokenAndName(id uint, token, name string) *model.Application {\n\treturn ab.newAppWithTokenAndName(id, token, name, true)\n}\n\nfunc (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, name string, internal bool) *model.Application {\n\tapplication := &model.Application{ID: id, UserID: ab.userID, Token: token, Name: name, Internal: internal}\n\tab.db.CreateApplication(application)\n\treturn application\n}\n\n// AppWithTokenAndDefaultPriority creates an application with a token and defaultPriority and returns a message builder.\nfunc (ab *AppClientBuilder) AppWithTokenAndDefaultPriority(id uint, token string, defaultPriority int) *MessageBuilder {\n\tapplication := &model.Application{ID: id, UserID: ab.userID, Token: token, DefaultPriority: defaultPriority}\n\tab.db.CreateApplication(application)\n\treturn &MessageBuilder{db: ab.db, appID: id}\n}\n\n// Client creates a client and returns itself.\nfunc (ab *AppClientBuilder) Client(id uint) *AppClientBuilder {\n\treturn ab.ClientWithToken(id, \"client\"+fmt.Sprint(id))\n}\n\n// ClientWithToken creates a client with a token and returns itself.\nfunc (ab *AppClientBuilder) ClientWithToken(id uint, token string) *AppClientBuilder {\n\tab.NewClientWithToken(id, token)\n\treturn ab\n}\n\n// NewClientWithToken creates a client with a token and returns the client.\nfunc (ab *AppClientBuilder) NewClientWithToken(id uint, token string) *model.Client {\n\tclient := &model.Client{ID: id, Token: token, UserID: ab.userID}\n\tab.db.CreateClient(client)\n\treturn client\n}\n\n// Message creates a message and returns itself.\nfunc (mb *MessageBuilder) Message(id uint) *MessageBuilder {\n\tmb.NewMessage(id)\n\treturn mb\n}\n\n// NewMessage creates a message and returns the message.\nfunc (mb *MessageBuilder) NewMessage(id uint) model.Message {\n\tmessage := model.Message{ID: id, ApplicationID: mb.appID}\n\tmb.db.CreateMessage(&message)\n\treturn message\n}\n\n// AssertAppNotExist asserts that the app does not exist.\nfunc (d *Database) AssertAppNotExist(id uint) {\n\tif app, err := d.GetApplicationByID(id); assert.NoError(d.t, err) {\n\t\tassert.True(d.t, app == nil, \"app %d must not exist\", id)\n\t}\n}\n\n// AssertUserNotExist asserts that the user does not exist.\nfunc (d *Database) AssertUserNotExist(id uint) {\n\tif user, err := d.GetUserByID(id); assert.NoError(d.t, err) {\n\t\tassert.True(d.t, user == nil, \"user %d must not exist\", id)\n\t}\n}\n\n// AssertUsernameNotExist asserts that the user does not exist.\nfunc (d *Database) AssertUsernameNotExist(name string) {\n\tif user, err := d.GetUserByName(name); assert.NoError(d.t, err) {\n\t\tassert.True(d.t, user == nil, \"user %d must not exist\", name)\n\t}\n}\n\n// AssertClientNotExist asserts that the client does not exist.\nfunc (d *Database) AssertClientNotExist(id uint) {\n\tif client, err := d.GetClientByID(id); assert.NoError(d.t, err) {\n\t\tassert.True(d.t, client == nil, \"client %d must not exist\", id)\n\t}\n}\n\n// AssertMessageNotExist asserts that the messages does not exist.\nfunc (d *Database) AssertMessageNotExist(ids ...uint) {\n\tfor _, id := range ids {\n\t\tif msg, err := d.GetMessageByID(id); assert.NoError(d.t, err) {\n\t\t\tassert.True(d.t, msg == nil, \"message %d must not exist\", id)\n\t\t}\n\t}\n}\n\n// AssertAppExist asserts that the app does exist.\nfunc (d *Database) AssertAppExist(id uint) {\n\tif app, err := d.GetApplicationByID(id); assert.NoError(d.t, err) {\n\t\tassert.False(d.t, app == nil, \"app %d must exist\", id)\n\t}\n}\n\n// AssertUserExist asserts that the user does exist.\nfunc (d *Database) AssertUserExist(id uint) {\n\tif user, err := d.GetUserByID(id); assert.NoError(d.t, err) {\n\t\tassert.False(d.t, user == nil, \"user %d must exist\", id)\n\t}\n}\n\n// AssertClientExist asserts that the client does exist.\nfunc (d *Database) AssertClientExist(id uint) {\n\tif client, err := d.GetClientByID(id); assert.NoError(d.t, err) {\n\t\tassert.False(d.t, client == nil, \"client %d must exist\", id)\n\t}\n}\n\n// AssertMessageExist asserts that the message does exist.\nfunc (d *Database) AssertMessageExist(id uint) {\n\tif msg, err := d.GetMessageByID(id); assert.NoError(d.t, err) {\n\t\tassert.False(d.t, msg == nil, \"message %d must exist\", id)\n\t}\n}\n"
  },
  {
    "path": "test/testdb/database_test.go",
    "content": "package testdb_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gotify/server/v2/mode\"\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/gotify/server/v2/test/testdb\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nfunc Test_WithDefault(t *testing.T) {\n\tdb := testdb.NewDBWithDefaultUser(t)\n\tif user, err := db.GetUserByName(\"admin\"); assert.NoError(t, err) {\n\t\tassert.NotNil(t, user)\n\t}\n\tdb.Close()\n}\n\nfunc TestDatabaseSuite(t *testing.T) {\n\tsuite.Run(t, new(DatabaseSuite))\n}\n\ntype DatabaseSuite struct {\n\tsuite.Suite\n\tdb *testdb.Database\n}\n\nfunc (s *DatabaseSuite) BeforeTest(suiteName, testName string) {\n\tmode.Set(mode.TestDev)\n\ts.db = testdb.NewDB(s.T())\n}\n\nfunc (s *DatabaseSuite) AfterTest(suiteName, testName string) {\n\ts.db.Close()\n}\n\nfunc (s *DatabaseSuite) Test_Users() {\n\ts.db.User(1)\n\tnewUserActual := s.db.NewUser(2)\n\ts.db.NewUserWithName(3, \"tom\")\n\n\tnewUserExpected := &model.User{ID: 2, Name: \"user2\"}\n\n\tassert.Equal(s.T(), newUserExpected, newUserActual)\n\n\tusers := []*model.User{{ID: 1, Name: \"user1\"}, {ID: 2, Name: \"user2\"}, {ID: 3, Name: \"tom\"}}\n\n\tif usersActual, err := s.db.GetUsers(); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), users, usersActual)\n\t}\n\ts.db.AssertUserExist(1)\n\ts.db.AssertUserExist(2)\n\ts.db.AssertUserExist(3)\n\ts.db.AssertUserNotExist(4)\n\n\ts.db.DeleteUserByID(2)\n\n\ts.db.AssertUserNotExist(2)\n}\n\nfunc (s *DatabaseSuite) Test_Clients() {\n\tuserBuilder := s.db.User(1)\n\tuserBuilder.Client(1)\n\tnewClientActual := userBuilder.NewClientWithToken(2, \"asdf\")\n\n\ts.db.User(2).Client(5)\n\n\tnewClientExpected := &model.Client{ID: 2, Token: \"asdf\", UserID: 1}\n\n\tassert.Equal(s.T(), newClientExpected, newClientActual)\n\n\tuserOneExpected := []*model.Client{{ID: 1, Token: \"client1\", UserID: 1}, {ID: 2, Token: \"asdf\", UserID: 1}}\n\tif clients, err := s.db.GetClientsByUser(1); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userOneExpected, clients)\n\t}\n\tuserTwoExpected := []*model.Client{{ID: 5, Token: \"client5\", UserID: 2}}\n\tif clients, err := s.db.GetClientsByUser(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userTwoExpected, clients)\n\t}\n\n\ts.db.AssertClientExist(1)\n\ts.db.AssertClientExist(2)\n\ts.db.AssertClientNotExist(3)\n\ts.db.AssertClientNotExist(4)\n\ts.db.AssertClientExist(5)\n\ts.db.AssertClientNotExist(6)\n\n\ts.db.DeleteClientByID(2)\n\n\ts.db.AssertClientNotExist(2)\n}\n\nfunc (s *DatabaseSuite) Test_Apps() {\n\tuserBuilder := s.db.User(1)\n\tuserBuilder.App(1)\n\tnewAppActual := userBuilder.NewAppWithToken(2, \"asdf\")\n\tnewInternalAppActual := userBuilder.NewInternalAppWithToken(3, \"qwer\")\n\n\ts.db.User(2).InternalApp(5)\n\n\tnewAppExpected := &model.Application{ID: 2, Token: \"asdf\", UserID: 1, SortKey: \"a1\"}\n\tnewInternalAppExpected := &model.Application{ID: 3, Token: \"qwer\", UserID: 1, Internal: true, SortKey: \"a2\"}\n\n\tassert.Equal(s.T(), newAppExpected, newAppActual)\n\tassert.Equal(s.T(), newInternalAppExpected, newInternalAppActual)\n\n\tuserOneExpected := []*model.Application{\n\t\t{ID: 1, Token: \"app1\", UserID: 1, SortKey: \"a0\"},\n\t\t{ID: 2, Token: \"asdf\", UserID: 1, SortKey: \"a1\"},\n\t\t{ID: 3, Token: \"qwer\", UserID: 1, Internal: true, SortKey: \"a2\"},\n\t}\n\tif app, err := s.db.GetApplicationsByUser(1); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userOneExpected, app)\n\t}\n\tuserTwoExpected := []*model.Application{{ID: 5, Token: \"app5\", UserID: 2, Internal: true, SortKey: \"a0\"}}\n\tif app, err := s.db.GetApplicationsByUser(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userTwoExpected, app)\n\t}\n\n\tnewAppWithName := userBuilder.NewAppWithTokenAndName(7, \"test-token\", \"app name\")\n\tnewAppWithNameExpected := &model.Application{ID: 7, Token: \"test-token\", UserID: 1, Name: \"app name\", SortKey: \"a3\"}\n\tassert.Equal(s.T(), newAppWithNameExpected, newAppWithName)\n\n\tnewInternalAppWithName := userBuilder.NewInternalAppWithTokenAndName(8, \"test-tokeni\", \"app name\")\n\tnewInternalAppWithNameExpected := &model.Application{ID: 8, Token: \"test-tokeni\", UserID: 1, Name: \"app name\", Internal: true, SortKey: \"a4\"}\n\tassert.Equal(s.T(), newInternalAppWithNameExpected, newInternalAppWithName)\n\n\tuserBuilder.AppWithTokenAndName(9, \"test-token-2\", \"app name\")\n\tuserBuilder.InternalAppWithTokenAndName(10, \"test-tokeni-2\", \"app name\")\n\tuserBuilder.AppWithToken(11, \"test-token-3\")\n\tuserBuilder.InternalAppWithToken(12, \"test-tokeni-3\")\n\tuserBuilder.AppWithTokenAndDefaultPriority(13, \"test-tokeni-4\", 4)\n\n\ts.db.AssertAppExist(1)\n\ts.db.AssertAppExist(2)\n\ts.db.AssertAppExist(3)\n\ts.db.AssertAppNotExist(4)\n\ts.db.AssertAppExist(5)\n\ts.db.AssertAppNotExist(6)\n\ts.db.AssertAppExist(7)\n\ts.db.AssertAppExist(8)\n\ts.db.AssertAppExist(9)\n\ts.db.AssertAppExist(10)\n\ts.db.AssertAppExist(11)\n\ts.db.AssertAppExist(12)\n\ts.db.AssertAppExist(13)\n\n\ts.db.DeleteApplicationByID(2)\n\n\ts.db.AssertAppNotExist(2)\n}\n\nfunc (s *DatabaseSuite) Test_Messages() {\n\ts.db.User(1).App(1).Message(1).Message(2)\n\ts.db.User(2).App(2).Message(4).Message(5)\n\n\tuserOneExpected := []*model.Message{{ID: 2, ApplicationID: 1}, {ID: 1, ApplicationID: 1}}\n\tif msgs, err := s.db.GetMessagesByUser(1); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userOneExpected, msgs)\n\t}\n\tuserTwoExpected := []*model.Message{{ID: 5, ApplicationID: 2}, {ID: 4, ApplicationID: 2}}\n\tif msgs, err := s.db.GetMessagesByUser(2); assert.NoError(s.T(), err) {\n\t\tassert.Equal(s.T(), userTwoExpected, msgs)\n\t}\n\n\ts.db.AssertMessageExist(1)\n\ts.db.AssertMessageExist(2)\n\ts.db.AssertMessageExist(4)\n\ts.db.AssertMessageExist(5)\n\n\ts.db.AssertMessageNotExist(3, 6, 7, 8)\n\n\ts.db.DeleteMessageByID(2)\n\n\ts.db.AssertMessageNotExist(2)\n}\n"
  },
  {
    "path": "test/tmpdir.go",
    "content": "package test\n\nimport (\n\t\"os\"\n\t\"path\"\n)\n\n// TmpDir is a handler to temporary directory.\ntype TmpDir struct {\n\tpath string\n}\n\n// Path returns the path to the temporary directory joined by the elements provided.\nfunc (c TmpDir) Path(elem ...string) string {\n\treturn path.Join(append([]string{c.path}, elem...)...)\n}\n\n// Clean removes the TmpDir.\nfunc (c TmpDir) Clean() error {\n\treturn os.RemoveAll(c.path)\n}\n\n// NewTmpDir returns a new handle to a tmp dir.\nfunc NewTmpDir(prefix string) TmpDir {\n\tdir, _ := os.MkdirTemp(\"\", prefix)\n\treturn TmpDir{dir}\n}\n"
  },
  {
    "path": "test/tmpdir_test.go",
    "content": "package test\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTmpDir(t *testing.T) {\n\tdir := NewTmpDir(\"test_prefix\")\n\tassert.NotEmpty(t, dir)\n\n\tassert.Contains(t, dir.Path(), \"test_prefix\")\n\ttestFilePath := dir.Path(\"testfile.txt\")\n\tassert.Contains(t, testFilePath, \"test_prefix\")\n\tassert.Contains(t, testFilePath, \"testfile.txt\")\n\tassert.True(t, strings.HasPrefix(testFilePath, dir.Path()))\n}\n"
  },
  {
    "path": "test/token.go",
    "content": "package test\n\nimport \"sync\"\n\n// Tokens returns a token generation function with takes a series of tokens and output them in order.\nfunc Tokens(tokens ...string) func() string {\n\tvar i int\n\tlock := sync.Mutex{}\n\treturn func() string {\n\t\tlock.Lock()\n\t\tdefer lock.Unlock()\n\t\tres := tokens[i%len(tokens)]\n\t\ti++\n\t\treturn res\n\t}\n}\n"
  },
  {
    "path": "test/token_test.go",
    "content": "package test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTokenGeneration(t *testing.T) {\n\tmockTokenFunc := Tokens(\"a\", \"b\", \"c\")\n\n\tfor _, expected := range []string{\"a\", \"b\", \"c\", \"a\", \"b\", \"c\"} {\n\t\tassert.Equal(t, expected, mockTokenFunc())\n\t}\n}\n"
  },
  {
    "path": "ui/.gitignore",
    "content": "# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "ui/.prettierrc",
    "content": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 4,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"bracketSpacing\": false,\n  \"bracketSameLine\": true,\n  \"arrowParens\": \"always\",\n  \"parser\": \"typescript\"\n}\n"
  },
  {
    "path": "ui/.yarnrc",
    "content": "enableTelemetry \"0\"\n"
  },
  {
    "path": "ui/eslint.config.mjs",
    "content": "// @ts-check\n\nimport eslint from '@eslint/js';\nimport tseslint from 'typescript-eslint';\n\nexport default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended);\n"
  },
  {
    "path": "ui/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n    <meta name=\"theme-color\" content=\"#3f51b5\">\n    <link rel=\"manifest\" href=\"manifest.json\">\n    <title>Gotify</title>\n\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"57x57\" href=\"static/apple-touch-icon-57x57.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"114x114\" href=\"static/apple-touch-icon-114x114.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"72x72\" href=\"static/apple-touch-icon-72x72.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"144x144\" href=\"static/apple-touch-icon-144x144.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"60x60\" href=\"static/apple-touch-icon-60x60.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"120x120\" href=\"static/apple-touch-icon-120x120.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"76x76\" href=\"static/apple-touch-icon-76x76.png\" />\n    <link rel=\"apple-touch-icon-precomposed\" sizes=\"152x152\" href=\"static/apple-touch-icon-152x152.png\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon-196x196.png\" sizes=\"196x196\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon-96x96.png\" sizes=\"96x96\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon-32x32.png\" sizes=\"32x32\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon-16x16.png\" sizes=\"16x16\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon-128.png\" sizes=\"128x128\" />\n    <link rel=\"icon\" href=\"static/favicon.ico\">\n    <meta name=\"application-name\" content=\"Gotify\"/>\n    <meta name=\"msapplication-TileColor\" content=\"#FFFFFF\" />\n    <meta name=\"msapplication-TileImage\" content=\"static/mstile-144x144.png\" />\n    <meta name=\"msapplication-square70x70logo\" content=\"static/mstile-70x70.png\" />\n    <meta name=\"msapplication-square150x150logo\" content=\"static/mstile-150x150.png\" />\n    <meta name=\"msapplication-wide310x150logo\" content=\"static/mstile-310x150.png\" />\n    <meta name=\"msapplication-square310x310logo\" content=\"static/mstile-310x310.png\" />\n\n</head>\n<body>\n<noscript>\n    Gotify requires JavaScript.\n</noscript>\n<div id=\"root\"></div>\n<script>window.config = %CONFIG%;</script>\n<script type=\"module\" src=\"/src/index.tsx\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "ui/package.json",
    "content": "{\n  \"name\": \"gotify-ui\",\n  \"version\": \"0.2.0\",\n  \"private\": true,\n  \"homepage\": \".\",\n  \"proxy\": \"http://localhost:80\",\n  \"dependencies\": {\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@emotion/react\": \"^11.14.0\",\n    \"@emotion/styled\": \"^11.14.1\",\n    \"@mui/icons-material\": \"^7.2.0\",\n    \"@mui/material\": \"^7.2.0\",\n    \"@uiw/codemirror-theme-material\": \"^4.24.2\",\n    \"@uiw/react-codemirror\": \"^4.24.2\",\n    \"@vitejs/plugin-react\": \"^5.0.0\",\n    \"axios\": \"^1.11.0\",\n    \"detect-browser\": \"^5.3.0\",\n    \"fractional-indexing\": \"^3.2.0\",\n    \"mobx\": \"^6.13.7\",\n    \"mobx-react-lite\": \"^4.1.0\",\n    \"mobx-utils\": \"^6.1.1\",\n    \"notifyjs\": \"^3.0.0\",\n    \"notistack\": \"^3.0.2\",\n    \"react\": \"^19.1.1\",\n    \"react-dom\": \"^19.1.1\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-router\": \"^7.12.0\",\n    \"react-router-dom\": \"^7.12.0\",\n    \"react-timeago\": \"^8.3.0\",\n    \"react-virtuoso\": \"^4.13.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"remove-markdown\": \"^0.6.2\",\n    \"tss-react\": \"^4.9.19\",\n    \"typeface-roboto\": \"1.1.13\",\n    \"vite\": \"^7.0.6\",\n    \"vitest\": \"^4.0.0\"\n  },\n  \"scripts\": {\n    \"start\": \"vite\",\n    \"prebuild\": \"tsc\",\n    \"build\": \"vite build\",\n    \"test\": \"vitest --disable-console-intercept --no-file-parallelism\",\n    \"lint\": \"eslint \\\"src/**/*.{ts,tsx}\\\"\",\n    \"format\": \"prettier \\\"src/**/*.{ts,tsx}\\\" --write\",\n    \"testformat\": \"prettier \\\"src/**/*.{ts,tsx}\\\" --list-different\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.32.0\",\n    \"@types/notifyjs\": \"^3.0.5\",\n    \"@types/react\": \"^19.1.9\",\n    \"@types/react-dom\": \"^19.1.7\",\n    \"@types/react-router-dom\": \"^5.3.3\",\n    \"@types/remove-markdown\": \"^0.3.4\",\n    \"eslint\": \"^9.32.0\",\n    \"get-port\": \"^7.1.0\",\n    \"prettier\": \"^3.6.2\",\n    \"puppeteer\": \"^24.15.0\",\n    \"rimraf\": \"^6.0.1\",\n    \"tree-kill\": \"^1.2.0\",\n    \"typescript\": \"^5.9.2\",\n    \"typescript-eslint\": \"^8.38.0\",\n    \"wait-on\": \"^9.0.0\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "ui/public/manifest.json",
    "content": "{\n  \"short_name\": \"Gotify\",\n  \"name\": \"Gotify WebApp\",\n  \"start_url\": \"./index.html\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#3f51b5\",\n  \"background_color\": \"#303030\"\n}\n"
  },
  {
    "path": "ui/serve.go",
    "content": "package ui\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n//go:embed build/*\nvar box embed.FS\n\ntype uiConfig struct {\n\tRegister bool              `json:\"register\"`\n\tVersion  model.VersionInfo `json:\"version\"`\n}\n\n// Register registers the ui on the root path.\nfunc Register(r *gin.Engine, version model.VersionInfo, register bool) {\n\tuiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treplaceConfig := func(content string) string {\n\t\treturn strings.Replace(content, \"%CONFIG%\", string(uiConfigBytes), 1)\n\t}\n\n\tui := r.Group(\"/\", gzip.Gzip(gzip.DefaultCompression))\n\tui.GET(\"/\", serveFile(\"index.html\", \"text/html\", replaceConfig))\n\tui.GET(\"/index.html\", serveFile(\"index.html\", \"text/html\", replaceConfig))\n\tui.GET(\"/manifest.json\", serveFile(\"manifest.json\", \"application/json\", noop))\n\n\tsubBox, err := fs.Sub(box, \"build\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tui.GET(\"/static/*any\", gin.WrapH(http.FileServer(http.FS(subBox))))\n}\n\nfunc noop(s string) string {\n\treturn s\n}\n\nfunc serveFile(name, contentType string, convert func(string) string) gin.HandlerFunc {\n\tcontent, err := box.ReadFile(\"build/\" + name)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tconverted := convert(string(content))\n\treturn func(ctx *gin.Context) {\n\t\tctx.Header(\"Content-Type\", contentType)\n\t\tctx.String(200, converted)\n\t}\n}\n"
  },
  {
    "path": "ui/src/CurrentUser.ts",
    "content": "import axios, {AxiosError, AxiosResponse} from 'axios';\nimport * as config from './config';\nimport {detect} from 'detect-browser';\nimport {SnackReporter} from './snack/SnackManager';\nimport {observable, runInAction, action} from 'mobx';\nimport {IClient, IUser} from './types';\n\nconst tokenKey = 'gotify-login-key';\n\nexport class CurrentUser {\n    private tokenCache: string | null = null;\n    private reconnectTimeoutId: number | null = null;\n    private reconnectTime = 7500;\n    @observable accessor loggedIn = false;\n    @observable accessor refreshKey = 0;\n    @observable accessor authenticating = true;\n    @observable accessor user: IUser = {name: 'unknown', admin: false, id: -1};\n    @observable accessor connectionErrorMessage: string | null = null;\n\n    public constructor(private readonly snack: SnackReporter) {}\n\n    public token = (): string => {\n        if (this.tokenCache !== null) {\n            return this.tokenCache;\n        }\n\n        const localStorageToken = window.localStorage.getItem(tokenKey);\n        if (localStorageToken) {\n            this.tokenCache = localStorageToken;\n            return localStorageToken;\n        }\n\n        return '';\n    };\n\n    private readonly setToken = (token: string) => {\n        this.tokenCache = token;\n        window.localStorage.setItem(tokenKey, token);\n    };\n\n    public register = async (name: string, pass: string): Promise<boolean> =>\n        axios\n            .create()\n            .post(config.get('url') + 'user', {name, pass})\n            .then(() => {\n                this.snack('User Created. Logging in...');\n                this.login(name, pass);\n                return true;\n            })\n            .catch((error: AxiosError<{error?: string; errorDescription?: string}>) => {\n                if (!error || !error.response) {\n                    this.snack('No network connection or server unavailable.');\n                    return false;\n                }\n                const {data} = error.response;\n\n                this.snack(\n                    `Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`\n                );\n                return false;\n            });\n\n    public login = async (username: string, password: string) => {\n        runInAction(() => {\n            this.loggedIn = false;\n            this.authenticating = true;\n        });\n        const browser = detect();\n        const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser';\n        axios\n            .create()\n            .request({\n                url: config.get('url') + 'client',\n                method: 'POST',\n                data: {name},\n                headers: {Authorization: 'Basic ' + btoa(username + ':' + password)},\n            })\n            .then((resp: AxiosResponse<IClient>) => {\n                this.snack(`A client named '${name}' was created for your session.`);\n                this.setToken(resp.data.token);\n                this.tryAuthenticate().catch(() => {\n                    console.log(\n                        'create client succeeded, but authenticated with given token failed'\n                    );\n                });\n            })\n            .catch(\n                action(() => {\n                    this.authenticating = false;\n                    return this.snack('Login failed');\n                })\n            );\n    };\n\n    public tryAuthenticate = async (): Promise<AxiosResponse<IUser>> => {\n        if (this.token() === '') {\n            runInAction(() => {\n                this.authenticating = false;\n            });\n            return Promise.reject();\n        }\n\n        return axios\n            .create()\n            .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}})\n            .then(\n                action((passThrough) => {\n                    this.user = passThrough.data;\n                    this.loggedIn = true;\n                    this.authenticating = false;\n                    this.connectionErrorMessage = null;\n                    this.reconnectTime = 7500;\n                    return passThrough;\n                })\n            )\n            .catch(\n                action((error: AxiosError) => {\n                    this.authenticating = false;\n                    if (!error || !error.response) {\n                        this.connectionError('No network connection or server unavailable.');\n                        return Promise.reject(error);\n                    }\n\n                    if (error.response.status >= 500) {\n                        this.connectionError(\n                            `${error.response.statusText} (code: ${error.response.status}).`\n                        );\n                        return Promise.reject(error);\n                    }\n\n                    this.connectionErrorMessage = null;\n\n                    if (error.response.status >= 400 && error.response.status < 500) {\n                        this.logout();\n                    }\n                    return Promise.reject(error);\n                })\n            );\n    };\n\n    public logout = async () => {\n        await axios\n            .get(config.get('url') + 'client')\n            .then((resp: AxiosResponse<IClient[]>) => {\n                resp.data\n                    .filter((client) => client.token === this.tokenCache)\n                    .forEach((client) => axios.delete(config.get('url') + 'client/' + client.id));\n            })\n            .catch(() => Promise.resolve());\n        window.localStorage.removeItem(tokenKey);\n        this.tokenCache = null;\n        runInAction(() => {\n            this.loggedIn = false;\n        });\n    };\n\n    public changePassword = (pass: string) => {\n        axios\n            .post(config.get('url') + 'current/user/password', {pass})\n            .then(() => this.snack('Password changed'));\n    };\n\n    public tryReconnect = (quiet = false) => {\n        this.tryAuthenticate().catch(() => {\n            if (!quiet) {\n                this.snack('Reconnect failed');\n            }\n        });\n    };\n\n    private readonly connectionError = (message: string) => {\n        this.connectionErrorMessage = message;\n        if (this.reconnectTimeoutId !== null) {\n            window.clearTimeout(this.reconnectTimeoutId);\n        }\n        this.reconnectTimeoutId = window.setTimeout(\n            () => this.tryReconnect(true),\n            this.reconnectTime\n        );\n        this.reconnectTime = Math.min(this.reconnectTime * 2, 120000);\n    };\n}\n"
  },
  {
    "path": "ui/src/apiAuth.ts",
    "content": "import axios from 'axios';\nimport {CurrentUser} from './CurrentUser';\nimport {SnackReporter} from './snack/SnackManager';\n\nexport const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => {\n    axios.interceptors.request.use((config) => {\n        if (!config.headers.has('x-gotify-key')) {\n            config.headers['x-gotify-key'] = currentUser.token();\n        }\n        return config;\n    });\n\n    axios.interceptors.response.use(undefined, (error) => {\n        if (!error.response) {\n            snack('Gotify server is not reachable, try refreshing the page.');\n            return Promise.reject(error);\n        }\n\n        const status = error.response.status;\n\n        if (status === 401) {\n            currentUser.tryAuthenticate().then(() => snack('Could not complete request.'));\n        }\n\n        if (status === 400 || status === 403 || status === 500) {\n            snack(error.response.data.error + ': ' + error.response.data.errorDescription);\n        }\n\n        return Promise.reject(error);\n    });\n};\n"
  },
  {
    "path": "ui/src/application/AddApplicationDialog.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogContentText from '@mui/material/DialogContentText';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport {NumberField} from '../common/NumberField';\nimport React, {useState} from 'react';\n\ninterface IProps {\n    fClose: VoidFunction;\n    fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;\n}\n\nexport const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {\n    const [name, setName] = useState('');\n    const [description, setDescription] = useState('');\n    const [defaultPriority, setDefaultPriority] = useState(0);\n\n    const submitEnabled = name.length !== 0;\n    const submitAndClose = async () => {\n        await fOnSubmit(name, description, defaultPriority);\n        fClose();\n    };\n\n    return (\n        <Dialog open={true} onClose={fClose} aria-labelledby=\"form-dialog-title\" id=\"app-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Create an application</DialogTitle>\n            <DialogContent>\n                <DialogContentText>An application is allowed to send messages.</DialogContentText>\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Name *\"\n                    type=\"text\"\n                    value={name}\n                    onChange={(e) => setName(e.target.value)}\n                    fullWidth\n                />\n                <TextField\n                    margin=\"dense\"\n                    className=\"description\"\n                    label=\"Short Description\"\n                    value={description}\n                    onChange={(e) => setDescription(e.target.value)}\n                    fullWidth\n                    multiline\n                />\n                <NumberField\n                    margin=\"dense\"\n                    className=\"priority\"\n                    label=\"Default Priority\"\n                    value={defaultPriority}\n                    onChange={(value) => setDefaultPriority(value)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip title={submitEnabled ? '' : 'name is required'}>\n                    <div>\n                        <Button\n                            className=\"create\"\n                            disabled={!submitEnabled}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Create\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\n"
  },
  {
    "path": "ui/src/application/AppStore.ts",
    "content": "import axios from 'axios';\nimport {generateKeyBetween} from 'fractional-indexing';\nimport {action, runInAction} from 'mobx';\nimport {BaseStore} from '../common/BaseStore';\nimport * as config from '../config';\nimport {SnackReporter} from '../snack/SnackManager';\nimport {IApplication} from '../types';\nimport {arrayMove} from '@dnd-kit/sortable';\n\nexport class AppStore extends BaseStore<IApplication> {\n    public onDelete: () => void = () => {};\n\n    public constructor(private readonly snack: SnackReporter) {\n        super();\n    }\n\n    protected requestItems = (): Promise<IApplication[]> =>\n        axios\n            .get<IApplication[]>(`${config.get('url')}application`)\n            .then((response) => response.data);\n\n    protected requestDelete = (id: number): Promise<void> =>\n        axios.delete(`${config.get('url')}application/${id}`).then(() => {\n            this.onDelete();\n            return this.snack('Application deleted');\n        });\n\n    @action\n    public uploadImage = async (id: number, file: Blob): Promise<void> => {\n        const formData = new FormData();\n        formData.append('file', file);\n        await axios.post(`${config.get('url')}application/${id}/image`, formData, {\n            headers: {'content-type': 'multipart/form-data'},\n        });\n        await this.refresh();\n        this.snack('Application image updated');\n    };\n\n    public async deleteImage(id: number): Promise<void> {\n        try {\n            await axios.delete(`${config.get('url')}application/${id}/image`);\n            await this.refresh();\n            this.snack('Application image deleted');\n        } catch (error) {\n            console.error('Error deleting application image:', error);\n            throw error;\n        }\n    }\n\n    @action\n    public reorder = async (fromId: number, toId: number): Promise<void> => {\n        const fromIndex = this.items.findIndex((app) => app.id === fromId);\n        const toIndex = this.items.findIndex((app) => app.id === toId);\n        if (fromIndex === -1 || toIndex === -1) {\n            throw Error('unknown apps');\n        }\n\n        const toUpdate = this.items[fromIndex];\n\n        const normalizedIndex =\n            toUpdate.sortKey > this.items[toIndex].sortKey ? toIndex - 1 : toIndex;\n\n        const newSortKey = generateKeyBetween(\n            this.items[normalizedIndex]?.sortKey,\n            this.items[normalizedIndex + 1]?.sortKey\n        );\n\n        runInAction(() => (this.items = arrayMove(this.items, fromIndex, toIndex)));\n\n        await this.update({...toUpdate, sortKey: newSortKey});\n    };\n\n    @action\n    public update = async ({\n        id,\n        ...app\n    }: Pick<\n        IApplication,\n        'id' | 'name' | 'description' | 'defaultPriority' | 'sortKey'\n    >): Promise<void> => {\n        await axios.put(`${config.get('url')}application/${id}`, app);\n        await this.refresh();\n        this.snack('Application updated');\n    };\n\n    @action\n    public create = async (\n        name: string,\n        description: string,\n        defaultPriority: number\n    ): Promise<void> => {\n        await axios.post(`${config.get('url')}application`, {\n            name,\n            description,\n            defaultPriority,\n        });\n        await this.refresh();\n        this.snack('Application created');\n    };\n\n    public getName = (id: number): string => {\n        const app = this.getByIDOrUndefined(id);\n        return id === -1 ? 'All Messages' : app !== undefined ? app.name : 'unknown';\n    };\n}\n"
  },
  {
    "path": "ui/src/application/Applications.tsx",
    "content": "import React, {ChangeEvent, useEffect, useRef, useState} from 'react';\nimport Grid from '@mui/material/Grid';\nimport IconButton from '@mui/material/IconButton';\nimport Paper from '@mui/material/Paper';\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableCell from '@mui/material/TableCell';\nimport TableHead from '@mui/material/TableHead';\nimport TableRow from '@mui/material/TableRow';\nimport Delete from '@mui/icons-material/Delete';\nimport Edit from '@mui/icons-material/Edit';\nimport CloudUpload from '@mui/icons-material/CloudUpload';\nimport DragIndicator from '@mui/icons-material/DragIndicator';\nimport Button from '@mui/material/Button';\nimport {\n    DndContext,\n    closestCenter,\n    KeyboardSensor,\n    PointerSensor,\n    useSensor,\n    useSensors,\n    DragEndEvent,\n} from '@dnd-kit/core';\nimport {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';\nimport {CSS} from '@dnd-kit/utilities';\n\nimport ConfirmDialog from '../common/ConfirmDialog';\nimport DefaultPage from '../common/DefaultPage';\nimport CopyableSecret from '../common/CopyableSecret';\nimport {AddApplicationDialog} from './AddApplicationDialog';\nimport * as config from '../config';\nimport {UpdateApplicationDialog} from './UpdateApplicationDialog';\nimport {IApplication} from '../types';\nimport {LastUsedCell} from '../common/LastUsedCell';\nimport {useStores} from '../stores';\nimport {observer} from 'mobx-react-lite';\nimport {makeStyles} from 'tss-react/mui';\nimport {ButtonBase, Tooltip} from '@mui/material';\n\nconst useStyles = makeStyles()((theme) => ({\n    imageContainer: {\n        '&::after': {\n            content: '\"×\"',\n            position: 'absolute',\n            top: 0,\n            left: 0,\n            width: 40,\n            height: 40,\n            background: theme.palette.error.main,\n            color: theme.palette.getContrastText(theme.palette.error.main),\n            fontSize: 40,\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n            opacity: 0,\n        },\n        '&:hover::after': {opacity: 1},\n    },\n}));\n\nconst Applications = observer(() => {\n    const {appStore} = useStores();\n    const apps = appStore.getItems();\n    const [toDeleteApp, setToDeleteApp] = useState<IApplication>();\n    const [toDeleteImage, setToDeleteImage] = useState<IApplication>();\n    const [toUpdateApp, setToUpdateApp] = useState<IApplication>();\n    const [createDialog, setCreateDialog] = useState<boolean>(false);\n\n    const fileInputRef = useRef<HTMLInputElement>(null);\n    const uploadId = useRef(-1);\n\n    const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor));\n\n    useEffect(() => void appStore.refresh(), []);\n\n    const validExtensions = ['.gif', '.png', '.jpg', '.jpeg'];\n\n    const handleImageUploadClick = (id: number) => {\n        uploadId.current = id;\n        if (fileInputRef.current) {\n            fileInputRef.current.click();\n        }\n    };\n\n    const onUploadImage = (e: ChangeEvent<HTMLInputElement>) => {\n        const file = e.target.files?.[0];\n        if (!file) {\n            return;\n        }\n        appStore.uploadImage(uploadId.current, file);\n    };\n\n    const handleDragEnd = (event: DragEndEvent) => {\n        const {active, over} = event;\n\n        if (over && active.id !== over.id) {\n            appStore.reorder(active.id as number, over.id as number);\n        }\n    };\n\n    return (\n        <DefaultPage\n            title=\"Applications\"\n            rightControl={\n                <Button\n                    id=\"create-app\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                    onClick={() => setCreateDialog(true)}>\n                    Create Application\n                </Button>\n            }\n            maxWidth={1000}>\n            <Grid size={12}>\n                <Paper elevation={6} style={{overflowX: 'auto'}}>\n                    <DndContext\n                        sensors={sensors}\n                        collisionDetection={closestCenter}\n                        onDragEnd={handleDragEnd}>\n                        <Table id=\"app-table\">\n                            <TableHead>\n                                <TableRow>\n                                    <TableCell padding=\"none\" style={{width: 0}} />\n                                    <TableCell padding=\"checkbox\" style={{width: 80}} />\n                                    <TableCell>Name</TableCell>\n                                    <TableCell>Token</TableCell>\n                                    <TableCell>Description</TableCell>\n                                    <TableCell>Priority</TableCell>\n                                    <TableCell>Last Used</TableCell>\n                                    <TableCell />\n                                    <TableCell />\n                                </TableRow>\n                            </TableHead>\n                            <SortableContext items={apps} strategy={verticalListSortingStrategy}>\n                                <TableBody>\n                                    {apps.map((app: IApplication) => (\n                                        <Row\n                                            key={app.id}\n                                            app={app}\n                                            fUpload={() => handleImageUploadClick(app.id)}\n                                            fDeleteImage={() => setToDeleteImage(app)}\n                                            fDelete={() => setToDeleteApp(app)}\n                                            fEdit={() => setToUpdateApp(app)}\n                                        />\n                                    ))}\n                                </TableBody>\n                            </SortableContext>\n                        </Table>\n                    </DndContext>\n                    <input\n                        ref={fileInputRef}\n                        type=\"file\"\n                        accept={validExtensions.join(',')}\n                        style={{display: 'none'}}\n                        onChange={onUploadImage}\n                    />\n                </Paper>\n            </Grid>\n            {createDialog && (\n                <AddApplicationDialog\n                    fClose={() => setCreateDialog(false)}\n                    fOnSubmit={appStore.create}\n                />\n            )}\n            {toUpdateApp != null && (\n                <UpdateApplicationDialog\n                    fClose={() => setToUpdateApp(undefined)}\n                    fOnSubmit={(name, description, defaultPriority) =>\n                        appStore.update({...toUpdateApp, name, description, defaultPriority})\n                    }\n                    initialDescription={toUpdateApp?.description}\n                    initialName={toUpdateApp?.name}\n                    initialDefaultPriority={toUpdateApp?.defaultPriority}\n                />\n            )}\n            {toDeleteApp != null && (\n                <ConfirmDialog\n                    title=\"Confirm Delete\"\n                    text={'Delete ' + toDeleteApp.name + '?'}\n                    fClose={() => setToDeleteApp(undefined)}\n                    fOnSubmit={() => appStore.remove(toDeleteApp.id)}\n                />\n            )}\n            {toDeleteImage != null && (\n                <ConfirmDialog\n                    title=\"Confirm Delete Image\"\n                    text={'Delete image for ' + toDeleteImage.name + '?'}\n                    fClose={() => setToDeleteImage(undefined)}\n                    fOnSubmit={() => appStore.deleteImage(toDeleteImage.id)}\n                />\n            )}\n        </DefaultPage>\n    );\n});\n\ninterface IRowProps {\n    app: IApplication;\n    fUpload: VoidFunction;\n    fDeleteImage: VoidFunction;\n    fDelete: VoidFunction;\n    fEdit: VoidFunction;\n}\n\nconst Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => {\n    const {classes} = useStyles();\n    const isDefaultImage = app.image === 'static/defaultapp.png';\n\n    const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({\n        id: app.id,\n    });\n\n    const style = {\n        transform: CSS.Transform.toString(transform),\n        transition,\n        opacity: isDragging ? 0.5 : 1,\n        backgroundColor: isDragging ? '#f5f5f5' : 'transparent',\n    };\n\n    return (\n        <TableRow ref={setNodeRef} style={style}>\n            <TableCell padding=\"none\" style={{paddingLeft: 5}}>\n                <div\n                    {...attributes}\n                    {...listeners}\n                    style={{\n                        cursor: 'grab',\n                        display: 'flex',\n                        alignItems: 'center',\n                        touchAction: 'none',\n                    }}>\n                    <DragIndicator style={{color: '#999'}} />\n                </div>\n            </TableCell>\n            <TableCell padding=\"normal\">\n                <div style={{display: 'flex'}}>\n                    <Tooltip title=\"Delete image\" placement=\"top\" arrow>\n                        <ButtonBase\n                            className={classes.imageContainer}\n                            onClick={fDeleteImage}\n                            disabled={isDefaultImage}>\n                            <img\n                                src={config.get('url') + app.image}\n                                alt=\"app logo\"\n                                width=\"40\"\n                                height=\"40\"\n                            />\n                        </ButtonBase>\n                    </Tooltip>\n                    <IconButton onClick={fUpload} style={{height: 40}}>\n                        <CloudUpload />\n                    </IconButton>\n                </div>\n            </TableCell>\n            <TableCell>{app.name}</TableCell>\n            <TableCell>\n                <CopyableSecret value={app.token} style={{display: 'flex', alignItems: 'center'}} />\n            </TableCell>\n            <TableCell>{app.description}</TableCell>\n            <TableCell>{app.defaultPriority}</TableCell>\n            <TableCell>\n                <LastUsedCell lastUsed={app.lastUsed} />\n            </TableCell>\n            <TableCell align=\"right\" padding=\"none\">\n                <IconButton onClick={fEdit} className=\"edit\">\n                    <Edit />\n                </IconButton>\n            </TableCell>\n            <TableCell align=\"right\" padding=\"none\">\n                <IconButton onClick={fDelete} className=\"delete\" disabled={app.internal}>\n                    <Delete />\n                </IconButton>\n            </TableCell>\n        </TableRow>\n    );\n};\n\nexport default Applications;\n"
  },
  {
    "path": "ui/src/application/UpdateApplicationDialog.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogContentText from '@mui/material/DialogContentText';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport {NumberField} from '../common/NumberField';\nimport React, {useState} from 'react';\n\ninterface IProps {\n    fClose: VoidFunction;\n    fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;\n    initialName: string;\n    initialDescription: string;\n    initialDefaultPriority: number;\n}\n\nexport const UpdateApplicationDialog = ({\n    initialName,\n    initialDescription,\n    initialDefaultPriority,\n    fClose,\n    fOnSubmit,\n}: IProps) => {\n    const [name, setName] = useState(initialName);\n    const [description, setDescription] = useState(initialDescription);\n    const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority);\n\n    const submitEnabled = name.length !== 0;\n    const submitAndClose = async () => {\n        await fOnSubmit(name, description, defaultPriority);\n        fClose();\n    };\n\n    return (\n        <Dialog open={true} onClose={fClose} aria-labelledby=\"form-dialog-title\" id=\"app-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Update an application</DialogTitle>\n            <DialogContent>\n                <DialogContentText>An application is allowed to send messages.</DialogContentText>\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Name *\"\n                    type=\"text\"\n                    value={name}\n                    onChange={(e) => setName(e.target.value)}\n                    fullWidth\n                />\n                <TextField\n                    margin=\"dense\"\n                    className=\"description\"\n                    label=\"Short Description\"\n                    value={description}\n                    onChange={(e) => setDescription(e.target.value)}\n                    fullWidth\n                    multiline\n                />\n                <NumberField\n                    margin=\"dense\"\n                    className=\"priority\"\n                    label=\"Default Priority\"\n                    value={defaultPriority}\n                    onChange={(e) => setDefaultPriority(e)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip title={submitEnabled ? '' : 'name is required'}>\n                    <div>\n                        <Button\n                            className=\"update\"\n                            disabled={!submitEnabled}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Update\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\n"
  },
  {
    "path": "ui/src/client/AddClientDialog.tsx",
    "content": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\n\ninterface IProps {\n    fClose: VoidFunction;\n    fOnSubmit: (name: string) => Promise<void>;\n}\n\nconst AddClientDialog = ({fClose, fOnSubmit}: IProps) => {\n    const [name, setName] = useState('');\n\n    const submitEnabled = name.length !== 0;\n    const submitAndClose = async () => {\n        await fOnSubmit(name);\n        fClose();\n    };\n\n    return (\n        <Dialog open={true} onClose={fClose} aria-labelledby=\"form-dialog-title\" id=\"client-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Create a client</DialogTitle>\n            <DialogContent>\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Name *\"\n                    type=\"email\"\n                    value={name}\n                    onChange={(e) => setName(e.target.value)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip placement={'bottom-start'} title={submitEnabled ? '' : 'name is required'}>\n                    <div>\n                        <Button\n                            className=\"create\"\n                            disabled={!submitEnabled}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Create\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\n\nexport default AddClientDialog;\n"
  },
  {
    "path": "ui/src/client/ClientStore.ts",
    "content": "import {BaseStore} from '../common/BaseStore';\nimport axios from 'axios';\nimport * as config from '../config';\nimport {action} from 'mobx';\nimport {SnackReporter} from '../snack/SnackManager';\nimport {IClient} from '../types';\n\nexport class ClientStore extends BaseStore<IClient> {\n    public constructor(private readonly snack: SnackReporter) {\n        super();\n    }\n\n    protected requestItems = (): Promise<IClient[]> =>\n        axios.get<IClient[]>(`${config.get('url')}client`).then((response) => response.data);\n\n    protected requestDelete(id: number): Promise<void> {\n        return axios\n            .delete(`${config.get('url')}client/${id}`)\n            .then(() => this.snack('Client deleted'));\n    }\n\n    @action\n    public update = async (id: number, name: string): Promise<void> => {\n        await axios.put(`${config.get('url')}client/${id}`, {name});\n        await this.refresh();\n        this.snack('Client updated');\n    };\n\n    @action\n    public createNoNotifcation = async (name: string): Promise<IClient> => {\n        const client = await axios.post(`${config.get('url')}client`, {name});\n        await this.refresh();\n        return client.data;\n    };\n\n    @action\n    public create = async (name: string): Promise<void> => {\n        await this.createNoNotifcation(name);\n        this.snack('Client added');\n    };\n}\n"
  },
  {
    "path": "ui/src/client/Clients.tsx",
    "content": "import React, {useEffect, useState} from 'react';\nimport Grid from '@mui/material/Grid';\nimport IconButton from '@mui/material/IconButton';\nimport Paper from '@mui/material/Paper';\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableCell from '@mui/material/TableCell';\nimport TableHead from '@mui/material/TableHead';\nimport TableRow from '@mui/material/TableRow';\nimport Delete from '@mui/icons-material/Delete';\nimport Edit from '@mui/icons-material/Edit';\nimport Button from '@mui/material/Button';\nimport ConfirmDialog from '../common/ConfirmDialog';\nimport DefaultPage from '../common/DefaultPage';\nimport AddClientDialog from './AddClientDialog';\nimport UpdateClientDialog from './UpdateClientDialog';\nimport {IClient} from '../types';\nimport CopyableSecret from '../common/CopyableSecret';\nimport {LastUsedCell} from '../common/LastUsedCell';\nimport {observer} from 'mobx-react-lite';\nimport {useStores} from '../stores';\n\nconst Clients = observer(() => {\n    const {clientStore} = useStores();\n    const [toDeleteClient, setToDeleteClient] = useState<IClient>();\n    const [toUpdateClient, setToUpdateClient] = useState<IClient>();\n    const [createDialog, setCreateDialog] = useState<boolean>(false);\n    const clients = clientStore.getItems();\n\n    useEffect(() => void clientStore.refresh(), []);\n\n    return (\n        <DefaultPage\n            title=\"Clients\"\n            rightControl={\n                <Button\n                    id=\"create-client\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                    onClick={() => setCreateDialog(true)}>\n                    Create Client\n                </Button>\n            }>\n            <Grid size={12}>\n                <Paper elevation={6} style={{overflowX: 'auto'}}>\n                    <Table id=\"client-table\">\n                        <TableHead>\n                            <TableRow style={{textAlign: 'center'}}>\n                                <TableCell>Name</TableCell>\n                                <TableCell style={{width: 200}}>Token</TableCell>\n                                <TableCell>Last Used</TableCell>\n                                <TableCell />\n                                <TableCell />\n                            </TableRow>\n                        </TableHead>\n                        <TableBody>\n                            {clients.map((client: IClient) => (\n                                <Row\n                                    key={client.id}\n                                    name={client.name}\n                                    value={client.token}\n                                    lastUsed={client.lastUsed}\n                                    fEdit={() => setToUpdateClient(client)}\n                                    fDelete={() => setToDeleteClient(client)}\n                                />\n                            ))}\n                        </TableBody>\n                    </Table>\n                </Paper>\n            </Grid>\n            {createDialog && (\n                <AddClientDialog\n                    fClose={() => setCreateDialog(false)}\n                    fOnSubmit={clientStore.create}\n                />\n            )}\n            {toUpdateClient != null && (\n                <UpdateClientDialog\n                    fClose={() => setToUpdateClient(undefined)}\n                    fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)}\n                    initialName={toUpdateClient.name}\n                />\n            )}\n            {toDeleteClient != null && (\n                <ConfirmDialog\n                    title=\"Confirm Delete\"\n                    text={'Delete ' + toDeleteClient.name + '?'}\n                    fClose={() => setToDeleteClient(undefined)}\n                    fOnSubmit={() => clientStore.remove(toDeleteClient.id)}\n                />\n            )}\n        </DefaultPage>\n    );\n});\n\ninterface IRowProps {\n    name: string;\n    value: string;\n    lastUsed: string | null;\n    fEdit: VoidFunction;\n    fDelete: VoidFunction;\n}\n\nconst Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => (\n    <TableRow>\n        <TableCell>{name}</TableCell>\n        <TableCell>\n            <CopyableSecret\n                value={value}\n                style={{display: 'flex', alignItems: 'center', width: 250}}\n            />\n        </TableCell>\n        <TableCell>\n            <LastUsedCell lastUsed={lastUsed} />\n        </TableCell>\n        <TableCell align=\"right\" padding=\"none\">\n            <IconButton onClick={fEdit} className=\"edit\">\n                <Edit />\n            </IconButton>\n        </TableCell>\n        <TableCell align=\"right\" padding=\"none\">\n            <IconButton onClick={fDelete} className=\"delete\">\n                <Delete />\n            </IconButton>\n        </TableCell>\n    </TableRow>\n);\n\nexport default Clients;\n"
  },
  {
    "path": "ui/src/client/UpdateClientDialog.tsx",
    "content": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogContentText from '@mui/material/DialogContentText';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\n\ninterface IProps {\n    fClose: VoidFunction;\n    fOnSubmit: (name: string) => Promise<void>;\n    initialName: string;\n}\n\nconst UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => {\n    const [name, setName] = useState(initialName);\n\n    const submitEnabled = name.length !== 0;\n    const submitAndClose = async () => {\n        await fOnSubmit(name);\n        fClose();\n    };\n\n    return (\n        <Dialog open={true} onClose={fClose} aria-labelledby=\"form-dialog-title\" id=\"client-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Update a Client</DialogTitle>\n            <DialogContent>\n                <DialogContentText>\n                    A client manages messages, clients, applications and users (with admin\n                    permissions).\n                </DialogContentText>\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Name *\"\n                    type=\"text\"\n                    value={name}\n                    onChange={(e) => setName(e.target.value)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip title={submitEnabled ? '' : 'name is required'}>\n                    <div>\n                        <Button\n                            className=\"update\"\n                            disabled={!submitEnabled}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Update\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\n\nexport default UpdateClientDialog;\n"
  },
  {
    "path": "ui/src/common/BaseStore.ts",
    "content": "import {action, observable} from 'mobx';\n\ninterface HasID {\n    id: number;\n}\n\nexport interface IClearable {\n    clear(): void;\n}\n\n/**\n * Base implementation for handling items with ids.\n */\nexport abstract class BaseStore<T extends HasID> implements IClearable {\n    @observable protected accessor items: T[] = [];\n\n    protected abstract requestItems(): Promise<T[]>;\n\n    protected abstract requestDelete(id: number): Promise<void>;\n\n    @action\n    public remove = async (id: number): Promise<void> => {\n        await this.requestDelete(id);\n        await this.refresh();\n    };\n\n    @action\n    public refresh = (): Promise<void> =>\n        this.requestItems().then(\n            action((items) => {\n                this.items = items || [];\n            })\n        );\n\n    @action\n    public refreshIfMissing = async (id: number): Promise<void> => {\n        if (this.getByIDOrUndefined(id) === undefined) {\n            await this.refresh();\n        }\n    };\n\n    public getByID = (id: number): T => {\n        const item = this.getByIDOrUndefined(id);\n        if (item === undefined) {\n            throw new Error('cannot find item with id ' + id);\n        }\n        return item;\n    };\n\n    public getByIDOrUndefined = (id: number): T | undefined =>\n        this.items.find((hasId: HasID) => hasId.id === id);\n\n    public getItems = (): T[] => this.items;\n\n    @action\n    public clear = (): void => {\n        this.items = [];\n    };\n}\n"
  },
  {
    "path": "ui/src/common/ConfirmDialog.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogContentText from '@mui/material/DialogContentText';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport React from 'react';\n\ninterface IProps {\n    title: string;\n    text: string;\n    fClose: VoidFunction;\n    fOnSubmit: VoidFunction;\n}\n\nexport default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) {\n    const submitAndClose = () => {\n        fOnSubmit();\n        fClose();\n    };\n    return (\n        <Dialog\n            open={true}\n            onClose={fClose}\n            aria-labelledby=\"form-dialog-title\"\n            className=\"confirm-dialog\">\n            <DialogTitle id=\"form-dialog-title\">{title}</DialogTitle>\n            <DialogContent>\n                <DialogContentText>{text}</DialogContentText>\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose} className=\"cancel\">\n                    No\n                </Button>\n                <Button\n                    onClick={submitAndClose}\n                    autoFocus\n                    color=\"primary\"\n                    variant=\"contained\"\n                    className=\"confirm\">\n                    Yes\n                </Button>\n            </DialogActions>\n        </Dialog>\n    );\n}\n"
  },
  {
    "path": "ui/src/common/ConnectionErrorBanner.tsx",
    "content": "import React from 'react';\nimport Button from '@mui/material/Button';\nimport Typography from '@mui/material/Typography';\n\ninterface ConnectionErrorBannerProps {\n    height: number;\n    retry: () => void;\n    message: string;\n}\n\nexport const ConnectionErrorBanner = ({height, retry, message}: ConnectionErrorBannerProps) => (\n    <div\n        style={{\n            backgroundColor: '#e74c3c',\n            height,\n            width: '100%',\n            zIndex: 1300,\n            position: 'relative',\n        }}>\n        <Typography align=\"center\" variant=\"h6\" style={{lineHeight: `${height}px`}}>\n            {message}{' '}\n            <Button variant=\"outlined\" onClick={retry}>\n                Retry\n            </Button>\n        </Typography>\n    </div>\n);\n"
  },
  {
    "path": "ui/src/common/Container.tsx",
    "content": "import Paper from '@mui/material/Paper';\nimport {makeStyles} from 'tss-react/mui';\nimport * as React from 'react';\n\nconst useStyles = makeStyles()(() => ({\n    paper: {\n        padding: 16,\n    },\n}));\n\ninterface IProps {\n    style?: React.CSSProperties;\n}\n\nconst Container: React.FC<React.PropsWithChildren<IProps>> = ({children, style}) => {\n    const {classes} = useStyles();\n    return (\n        <Paper elevation={6} className={classes.paper} style={style}>\n            {children}\n        </Paper>\n    );\n};\n\nexport default Container;\n"
  },
  {
    "path": "ui/src/common/CopyableSecret.tsx",
    "content": "import IconButton from '@mui/material/IconButton';\nimport Typography from '@mui/material/Typography';\nimport Visibility from '@mui/icons-material/Visibility';\nimport Copy from '@mui/icons-material/FileCopyOutlined';\nimport VisibilityOff from '@mui/icons-material/VisibilityOff';\nimport React, {CSSProperties} from 'react';\nimport {useStores} from '../stores';\n\ninterface IProps {\n    value: string;\n    style?: CSSProperties;\n}\n\nconst CopyableSecret = ({value, style}: IProps) => {\n    const [visible, setVisible] = React.useState(false);\n    const text = visible ? value : '•••••••••••••••';\n    const {snackManager} = useStores();\n    const toggleVisibility = () => setVisible((b) => !b);\n    const copyToClipboard = async () => {\n        try {\n            await navigator.clipboard.writeText(value);\n            snackManager.snack('Copied to clipboard');\n        } catch (error) {\n            console.error('Failed to copy to clipboard:', error);\n            snackManager.snack('Failed to copy to clipboard');\n        }\n    };\n    return (\n        <div style={style}>\n            <IconButton onClick={copyToClipboard} title=\"Copy to clipboard\" size=\"large\">\n                <Copy />\n            </IconButton>\n            <IconButton onClick={toggleVisibility} className=\"toggle-visibility\" size=\"large\">\n                {visible ? <VisibilityOff /> : <Visibility />}\n            </IconButton>\n            <Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>\n        </div>\n    );\n};\n\nexport default CopyableSecret;\n"
  },
  {
    "path": "ui/src/common/DefaultPage.tsx",
    "content": "import Grid from '@mui/material/Grid';\nimport Typography from '@mui/material/Typography';\nimport React, {FC} from 'react';\n\ninterface IProps {\n    title: string;\n    rightControl?: React.ReactNode;\n    maxWidth?: number;\n}\n\nconst DefaultPage: FC<React.PropsWithChildren<IProps>> = ({\n    title,\n    rightControl,\n    maxWidth = 700,\n    children,\n}) => (\n    <main style={{margin: '0 auto', maxWidth}}>\n        <Grid container spacing={4}>\n            <Grid size={{xs: 12}} style={{display: 'flex', flexWrap: 'wrap'}}>\n                <Typography variant=\"h4\" style={{flex: 1, minWidth: 300}}>\n                    {title}\n                </Typography>\n                {rightControl}\n            </Grid>\n            {children}\n        </Grid>\n    </main>\n);\nexport default DefaultPage;\n"
  },
  {
    "path": "ui/src/common/LastUsedCell.tsx",
    "content": "import {Typography} from '@mui/material';\nimport React from 'react';\nimport TimeAgo from 'react-timeago';\nimport {TimeAgoFormatter} from './TimeAgoFormatter';\n\nexport const LastUsedCell: React.FC<{lastUsed: string | null}> = ({lastUsed}) => {\n    if (lastUsed === null) {\n        return <Typography>Never</Typography>;\n    }\n\n    if (+new Date(lastUsed) + 300000 > Date.now()) {\n        return <Typography title={lastUsed}>Recently</Typography>;\n    }\n\n    return <TimeAgo date={lastUsed} formatter={TimeAgoFormatter.long} />;\n};\n"
  },
  {
    "path": "ui/src/common/LoadingSpinner.tsx",
    "content": "import CircularProgress from '@mui/material/CircularProgress';\nimport Grid from '@mui/material/Grid';\nimport React from 'react';\nimport DefaultPage from './DefaultPage';\n\nexport default function LoadingSpinner() {\n    return (\n        <DefaultPage title=\"\" maxWidth={250}>\n            <Grid size={{xs: 12}} style={{textAlign: 'center'}}>\n                <CircularProgress size={40} />\n            </Grid>\n        </DefaultPage>\n    );\n}\n"
  },
  {
    "path": "ui/src/common/Markdown.tsx",
    "content": "import React from 'react';\nimport ReactMarkdown, {defaultUrlTransform} from 'react-markdown';\nimport type {UrlTransform} from 'react-markdown';\nimport gfm from 'remark-gfm';\n\n// Copy from mlflow/server/js/src/shared/web-shared/genai-markdown-renderer/GenAIMarkdownRenderer.tsx\n// Related PR: https://github.com/mlflow/mlflow/pull/16761\nconst urlTransform: UrlTransform = (value) => {\n    if (\n        value.startsWith('data:image/png;') ||\n        value.startsWith('data:image/jpeg;') ||\n        value.startsWith('data:image/gif;')\n    ) {\n        return value;\n    }\n    return defaultUrlTransform(value);\n};\n\nexport const Markdown = ({\n    children,\n    onImageLoaded = () => {},\n}: {\n    children: string;\n    onImageLoaded?: () => void;\n}) => (\n    <ReactMarkdown\n        components={{img: ({...props}) => <img onLoad={onImageLoaded} {...props} />}}\n        remarkPlugins={[gfm]}\n        urlTransform={urlTransform}>\n        {children}\n    </ReactMarkdown>\n);\n"
  },
  {
    "path": "ui/src/common/NumberField.tsx",
    "content": "import {TextField, TextFieldProps} from '@mui/material';\nimport React from 'react';\n\nexport interface NumberFieldProps {\n    value: number;\n    onChange: (value: number) => void;\n}\n\nexport const NumberField = ({\n    value,\n    onChange,\n    ...props\n}: NumberFieldProps & Omit<TextFieldProps, 'value' | 'onChange'>) => {\n    const [stringValue, setStringValue] = React.useState<string>(value.toString());\n    const [error, setError] = React.useState('');\n\n    return (\n        <TextField\n            value={stringValue}\n            type=\"number\"\n            helperText={error}\n            error={error !== ''}\n            onChange={(event) => {\n                setStringValue(event.target.value);\n                const i = parseInt(event.target.value, 10);\n                if (!Number.isNaN(i)) {\n                    onChange(i);\n                    setError('');\n                } else {\n                    setError('Invalid number');\n                }\n            }}\n            {...props}\n        />\n    );\n};\n"
  },
  {
    "path": "ui/src/common/ScrollUpButton.tsx",
    "content": "import Fab from '@mui/material/Fab';\nimport KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';\nimport React from 'react';\n\nconst ScrollUpButton = () => {\n    const [state, setState] = React.useState({display: 'none', opacity: 0});\n    React.useEffect(() => {\n        const scrollHandler = () => {\n            const currentScrollPos = Math.max(window.pageYOffset - 1000, 0);\n            const opacity = Math.min(currentScrollPos / 1000, 1);\n            const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity};\n            if (state.display !== nextState.display || state.opacity !== nextState.opacity) {\n                setState(nextState);\n            }\n        };\n        window.addEventListener('scroll', scrollHandler);\n        return () => window.removeEventListener('scroll', scrollHandler);\n    }, []);\n\n    return (\n        <Fab\n            color=\"primary\"\n            style={{\n                position: 'fixed',\n                bottom: '30px',\n                right: '30px',\n                zIndex: 100000,\n                display: state.display,\n                opacity: state.opacity,\n            }}\n            onClick={() => window.scrollTo(0, 0)}>\n            <KeyboardArrowUp />\n        </Fab>\n    );\n};\n\nexport default ScrollUpButton;\n"
  },
  {
    "path": "ui/src/common/SettingsDialog.tsx",
    "content": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport {observer} from 'mobx-react-lite';\nimport {useStores} from '../stores';\n\ninterface IProps {\n    fClose: VoidFunction;\n}\n\nconst SettingsDialog = observer(({fClose}: IProps) => {\n    const [pass, setPass] = useState('');\n    const {currentUser} = useStores();\n\n    const submitAndClose = async () => {\n        currentUser.changePassword(pass);\n        fClose();\n    };\n\n    return (\n        <Dialog\n            open={true}\n            onClose={fClose}\n            aria-labelledby=\"form-dialog-title\"\n            id=\"changepw-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Change Password</DialogTitle>\n            <DialogContent>\n                <TextField\n                    className=\"newpass\"\n                    autoFocus\n                    margin=\"dense\"\n                    type=\"password\"\n                    label=\"New Password *\"\n                    value={pass}\n                    onChange={(e) => setPass(e.target.value)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>\n                    <div>\n                        <Button\n                            className=\"change\"\n                            disabled={pass.length === 0}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Change\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n});\n\nexport default SettingsDialog;\n"
  },
  {
    "path": "ui/src/common/TimeAgoFormatter.ts",
    "content": "import {Formatter} from 'react-timeago';\nimport {makeIntlFormatter} from 'react-timeago/defaultFormatter';\n\nexport const TimeAgoFormatter: Record<'long' | 'narrow', Formatter> = {\n    long: makeIntlFormatter({style: 'long', locale: 'en'}),\n    narrow: makeIntlFormatter({style: 'narrow', locale: 'en'}),\n};\n"
  },
  {
    "path": "ui/src/config.ts",
    "content": "import {IVersion} from './types';\n\nexport interface IConfig {\n    url: string;\n    register: boolean;\n    version: IVersion;\n}\n\ndeclare global {\n    interface Window {\n        config?: Partial<IConfig>;\n    }\n}\n\nconst config: IConfig = {\n    url: 'unset',\n    register: false,\n    version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'},\n    ...window.config,\n};\n\nexport function set<Key extends keyof IConfig>(key: Key, value: IConfig[Key]): void {\n    config[key] = value;\n}\n\nexport function get<K extends keyof IConfig>(key: K): IConfig[K] {\n    return config[key];\n}\n"
  },
  {
    "path": "ui/src/index.tsx",
    "content": "import * as React from 'react';\nimport {createRoot} from 'react-dom/client';\nimport 'typeface-roboto';\nimport {initAxios} from './apiAuth';\nimport * as config from './config';\nimport Layout from './layout/Layout';\nimport {unregister} from './registerServiceWorker';\nimport {CurrentUser} from './CurrentUser';\nimport {AppStore} from './application/AppStore';\nimport {WebSocketStore} from './message/WebSocketStore';\nimport {SnackManager} from './snack/SnackManager';\nimport {UserStore} from './user/UserStore';\nimport {MessagesStore} from './message/MessagesStore';\nimport {ClientStore} from './client/ClientStore';\nimport {PluginStore} from './plugin/PluginStore';\nimport {registerReactions} from './reactions';\nimport {StoreContext, StoreMapping} from './stores';\n\nconst {port, hostname, protocol, pathname} = window.location;\nconst slashes = protocol.concat('//');\nconst path = pathname.endsWith('/') ? pathname : pathname.substring(0, pathname.lastIndexOf('/'));\nconst url = slashes.concat(port ? hostname.concat(':', port) : hostname) + path;\nconst urlWithSlash = url.endsWith('/') ? url : url.concat('/');\n\nconst prodUrl = urlWithSlash;\n\nconst initStores = (): StoreMapping => {\n    const snackManager = new SnackManager();\n    const appStore = new AppStore(snackManager.snack);\n    const userStore = new UserStore(snackManager.snack);\n    const messagesStore = new MessagesStore(appStore, snackManager.snack);\n    const currentUser = new CurrentUser(snackManager.snack);\n    const clientStore = new ClientStore(snackManager.snack);\n    const wsStore = new WebSocketStore(snackManager.snack, currentUser);\n    const pluginStore = new PluginStore(snackManager.snack);\n    appStore.onDelete = () => messagesStore.clearAll();\n\n    return {\n        appStore,\n        snackManager,\n        userStore,\n        messagesStore,\n        currentUser,\n        clientStore,\n        wsStore,\n        pluginStore,\n    };\n};\n\n(function clientJS() {\n    config.set('url', prodUrl);\n    const stores = initStores();\n    initAxios(stores.currentUser, stores.snackManager.snack);\n\n    registerReactions(stores);\n\n    stores.currentUser.tryAuthenticate().catch(() => {});\n\n    window.onbeforeunload = () => {\n        stores.wsStore.close();\n    };\n\n    createRoot(document.getElementById('root')!).render(\n        <StoreContext.Provider value={stores}>\n            <Layout />\n        </StoreContext.Provider>\n    );\n    unregister();\n})();\n"
  },
  {
    "path": "ui/src/layout/Header.tsx",
    "content": "import AppBar from '@mui/material/AppBar';\nimport Button, {ButtonProps} from '@mui/material/Button';\nimport IconButton from '@mui/material/IconButton';\nimport {Theme} from '@mui/material/styles';\nimport {makeStyles} from 'tss-react/mui';\nimport Toolbar from '@mui/material/Toolbar';\nimport Typography from '@mui/material/Typography';\nimport AccountCircle from '@mui/icons-material/AccountCircle';\nimport Chat from '@mui/icons-material/Chat';\nimport DevicesOther from '@mui/icons-material/DevicesOther';\nimport ExitToApp from '@mui/icons-material/ExitToApp';\nimport Brightness4 from '@mui/icons-material/Brightness4';\nimport Brightness7 from '@mui/icons-material/Brightness7';\nimport BrightnessAuto from '@mui/icons-material/BrightnessAuto';\nimport GitHubIcon from '@mui/icons-material/GitHub';\nimport MenuIcon from '@mui/icons-material/Menu';\nimport Apps from '@mui/icons-material/Apps';\nimport SupervisorAccount from '@mui/icons-material/SupervisorAccount';\nimport React, {CSSProperties} from 'react';\nimport {Link} from 'react-router-dom';\nimport {useMediaQuery} from '@mui/material';\nimport {ThemeKey} from './theme';\n\nconst themeIcons: Record<ThemeKey, React.ReactElement> = {\n    dark: <Brightness4 />,\n    light: <Brightness7 />,\n    system: <BrightnessAuto />,\n};\n\nconst useStyles = makeStyles()((theme: Theme) => ({\n    appBar: {\n        zIndex: theme.zIndex.drawer + 1,\n        [theme.breakpoints.down('sm')]: {\n            paddingBottom: 10,\n        },\n    },\n    toolbar: {\n        justifyContent: 'space-between',\n        [theme.breakpoints.down('sm')]: {\n            flexWrap: 'wrap',\n        },\n    },\n    menuButtons: {\n        display: 'flex',\n        [theme.breakpoints.down('md')]: {\n            flex: 1,\n        },\n        justifyContent: 'center',\n        [theme.breakpoints.down('sm')]: {\n            flexBasis: '100%',\n            marginTop: 5,\n            order: 1,\n            height: 50,\n            justifyContent: 'space-between',\n            alignItems: 'center',\n        },\n    },\n    title: {\n        [theme.breakpoints.up('md')]: {\n            flex: 1,\n        },\n        display: 'flex',\n        alignItems: 'center',\n    },\n    titleName: {\n        paddingRight: 10,\n    },\n    link: {\n        color: 'inherit',\n        textDecoration: 'none',\n    },\n}));\n\ninterface IProps {\n    loggedIn: boolean;\n    name: string;\n    admin: boolean;\n    version: string;\n    themeMode: ThemeKey;\n    toggleTheme: VoidFunction;\n    showSettings: VoidFunction;\n    logout: VoidFunction;\n    style: CSSProperties;\n    setNavOpen: (open: boolean) => void;\n}\n\nconst Header = ({\n    version,\n    name,\n    loggedIn,\n    admin,\n    toggleTheme,\n    logout,\n    style,\n    setNavOpen,\n    showSettings,\n    themeMode,\n}: IProps) => {\n    const {classes} = useStyles();\n    const themeLabel = `Toggle theme (current: ${themeMode})`;\n    const themeIcon = themeIcons[themeMode];\n    return (\n        <AppBar\n            sx={{position: {xs: 'sticky', sm: 'fixed'}}}\n            style={style}\n            className={classes.appBar}>\n            <Toolbar className={classes.toolbar}>\n                <div className={classes.title}>\n                    <Link to=\"/\" className={classes.link}>\n                        <Typography variant=\"h5\" className={classes.titleName} color=\"inherit\">\n                            Gotify\n                        </Typography>\n                    </Link>\n                    <a\n                        href={'https://github.com/gotify/server/releases/tag/v' + version}\n                        className={classes.link}>\n                        <Typography variant=\"button\" color=\"inherit\">\n                            @{version}\n                        </Typography>\n                    </a>\n                </div>\n                {loggedIn && (\n                    <Buttons\n                        admin={admin}\n                        name={name}\n                        logout={logout}\n                        setNavOpen={setNavOpen}\n                        showSettings={showSettings}\n                    />\n                )}\n                <div>\n                    <IconButton\n                        onClick={toggleTheme}\n                        color=\"inherit\"\n                        size=\"large\"\n                        title={themeLabel}\n                        aria-label={themeLabel}>\n                        {themeIcon}\n                    </IconButton>\n\n                    <a\n                        href=\"https://github.com/gotify/server\"\n                        className={classes.link}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\">\n                        <IconButton color=\"inherit\" size=\"large\">\n                            <GitHubIcon />\n                        </IconButton>\n                    </a>\n                </div>\n            </Toolbar>\n        </AppBar>\n    );\n};\n\nconst Buttons = ({\n    showSettings,\n    name,\n    admin,\n    logout,\n    setNavOpen,\n}: {\n    name: string;\n    admin: boolean;\n    logout: VoidFunction;\n    setNavOpen: (open: boolean) => void;\n    showSettings: VoidFunction;\n}) => {\n    const {classes} = useStyles();\n\n    return (\n        <div className={classes.menuButtons}>\n            <ResponsiveButton\n                sx={{display: {sm: 'none', xs: 'block'}}}\n                icon={<MenuIcon />}\n                onClick={() => setNavOpen(true)}\n                label=\"menu\"\n                color=\"inherit\"\n            />\n            {admin && (\n                <Link className={classes.link} to=\"/users\" id=\"navigate-users\">\n                    <ResponsiveButton icon={<SupervisorAccount />} label=\"users\" color=\"inherit\" />\n                </Link>\n            )}\n            <Link className={classes.link} to=\"/applications\" id=\"navigate-apps\">\n                <ResponsiveButton icon={<Chat />} label=\"apps\" color=\"inherit\" />\n            </Link>\n            <Link className={classes.link} to=\"/clients\" id=\"navigate-clients\">\n                <ResponsiveButton icon={<DevicesOther />} label=\"clients\" color=\"inherit\" />\n            </Link>\n            <Link className={classes.link} to=\"/plugins\" id=\"navigate-plugins\">\n                <ResponsiveButton icon={<Apps />} label=\"plugins\" color=\"inherit\" />\n            </Link>\n            <ResponsiveButton\n                icon={<AccountCircle />}\n                label={name}\n                onClick={showSettings}\n                id=\"changepw\"\n                color=\"inherit\"\n            />\n            <ResponsiveButton\n                icon={<ExitToApp />}\n                label=\"Logout\"\n                onClick={logout}\n                id=\"logout\"\n                color=\"inherit\"\n            />\n        </div>\n    );\n};\n\nconst ResponsiveButton: React.FC<{\n    color: 'inherit';\n    sx?: ButtonProps['sx'];\n    label: string;\n    id?: string;\n    onClick?: () => void;\n    icon: React.ReactNode;\n}> = ({icon, label, ...rest}) => {\n    const matches = useMediaQuery('(max-width:1000px)');\n    if (matches) {\n        return (\n            <IconButton {...rest} size=\"large\">\n                {icon}\n            </IconButton>\n        );\n    }\n    return (\n        <Button startIcon={icon} {...rest}>\n            {label}\n        </Button>\n    );\n};\n\nexport default Header;\n"
  },
  {
    "path": "ui/src/layout/Layout.tsx",
    "content": "import {\n    createTheme,\n    ThemeProvider,\n    StyledEngineProvider,\n    Theme,\n    useMediaQuery,\n} from '@mui/material';\nimport {makeStyles} from 'tss-react/mui';\nimport CssBaseline from '@mui/material/CssBaseline';\nimport * as React from 'react';\nimport {HashRouter, Navigate, Route, Routes} from 'react-router-dom';\nimport Header from './Header';\nimport Navigation from './Navigation';\nimport ScrollUpButton from '../common/ScrollUpButton';\nimport SettingsDialog from '../common/SettingsDialog';\nimport * as config from '../config';\nimport Applications from '../application/Applications';\nimport Clients from '../client/Clients';\nimport Plugins from '../plugin/Plugins';\nimport Login from '../user/Login';\nimport Messages from '../message/Messages';\nimport Users from '../user/Users';\nimport {observer} from 'mobx-react-lite';\nimport {ConnectionErrorBanner} from '../common/ConnectionErrorBanner';\nimport {useStores} from '../stores';\nimport {SnackbarProvider} from 'notistack';\nimport LoadingSpinner from '../common/LoadingSpinner';\nimport {isThemeKey, ThemeKey} from './theme';\n\nconst useStyles = makeStyles()((theme: Theme) => ({\n    content: {\n        margin: '0 auto',\n        marginTop: 64,\n        padding: theme.spacing(3),\n        width: '100%',\n        [theme.breakpoints.down('sm')]: {\n            marginTop: 0,\n            padding: theme.spacing(1),\n        },\n    },\n}));\n\nconst localStorageThemeKey = 'gotify-theme';\n\nconst Layout = observer(() => {\n    const {\n        currentUser: {\n            loggedIn,\n            authenticating,\n            user: {name, admin},\n            logout,\n            tryReconnect,\n            connectionErrorMessage,\n            refreshKey,\n        },\n    } = useStores();\n    const {classes} = useStyles();\n    const [currentTheme, setCurrentTheme] = React.useState<ThemeKey>(() => {\n        const stored = window.localStorage.getItem(localStorageThemeKey);\n        return isThemeKey(stored) ? stored : 'system';\n    });\n    const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');\n    const paletteMode = currentTheme === 'system' ? (prefersDark ? 'dark' : 'light') : currentTheme;\n    const theme = React.useMemo(\n        () =>\n            createTheme({\n                palette: {\n                    mode: paletteMode,\n                },\n            }),\n        [paletteMode]\n    );\n    const {version} = config.get('version');\n    const [navOpen, setNavOpen] = React.useState(false);\n    const [showSettings, setShowSettings] = React.useState(false);\n\n    const toggleTheme = () => {\n        const nextMap: Record<ThemeKey, ThemeKey> = {\n            dark: 'light',\n            light: 'system',\n            system: 'dark',\n        };\n        const next = nextMap[currentTheme];\n        setCurrentTheme(next);\n        localStorage.setItem(localStorageThemeKey, next);\n    };\n\n    const authed = (children: React.ReactNode) => (\n        <RequireAuth loggedIn={loggedIn} authenticating={authenticating}>\n            {children}\n        </RequireAuth>\n    );\n\n    return (\n        <StyledEngineProvider injectFirst>\n            <ThemeProvider theme={theme}>\n                <HashRouter>\n                    {/* This forces all components to fully rerender including useEffects.\n                        The refreshKey is updated when store data was cleaned and pages should refetch their data. */}\n                    <div key={refreshKey}>\n                        {!connectionErrorMessage ? null : (\n                            <ConnectionErrorBanner\n                                height={64}\n                                retry={() => tryReconnect()}\n                                message={connectionErrorMessage}\n                            />\n                        )}\n                        <div style={{display: 'flex', flexDirection: 'column'}}>\n                            <CssBaseline />\n                            <Header\n                                admin={admin}\n                                name={name}\n                                style={{top: !connectionErrorMessage ? 0 : 64}}\n                                version={version}\n                                loggedIn={loggedIn}\n                                themeMode={currentTheme}\n                                toggleTheme={toggleTheme}\n                                showSettings={() => setShowSettings(true)}\n                                logout={logout}\n                                setNavOpen={setNavOpen}\n                            />\n                            <div style={{display: 'flex'}}>\n                                <Navigation\n                                    loggedIn={loggedIn}\n                                    navOpen={navOpen}\n                                    setNavOpen={setNavOpen}\n                                />\n                                <main className={classes.content}>\n                                    <Routes>\n                                        <Route path=\"/login\" element={<Login />} />\n                                        <Route path=\"/\" element={authed(<Messages />)} />\n                                        <Route\n                                            path=\"/messages/:id\"\n                                            element={authed(<Messages />)}\n                                        />\n                                        <Route\n                                            path=\"/applications\"\n                                            element={authed(<Applications />)}\n                                        />\n                                        <Route path=\"/clients\" element={authed(<Clients />)} />\n                                        <Route path=\"/users\" element={authed(<Users />)} />\n                                        <Route path=\"/plugins\" element={authed(<Plugins />)} />\n                                        <Route\n                                            path=\"/plugins/:id\"\n                                            element={authed(\n                                                <Lazy\n                                                    component={() =>\n                                                        import('../plugin/PluginDetailView')\n                                                    }\n                                                />\n                                            )}\n                                        />\n                                    </Routes>\n                                </main>\n                            </div>\n                            {showSettings && (\n                                <SettingsDialog fClose={() => setShowSettings(false)} />\n                            )}\n                            <ScrollUpButton />\n                            <SnackbarProvider />\n                        </div>\n                    </div>\n                </HashRouter>\n            </ThemeProvider>\n        </StyledEngineProvider>\n    );\n});\n\n// eslint-disable-next-line\nconst Lazy = ({component}: {component: () => Promise<{default: React.ComponentType<any>}>}) => {\n    const Component = React.lazy(component);\n\n    return (\n        <React.Suspense fallback={<LoadingSpinner />}>\n            <Component />\n        </React.Suspense>\n    );\n};\n\nconst RequireAuth: React.FC<\n    React.PropsWithChildren<{loggedIn: boolean; authenticating: boolean}>\n> = ({children, authenticating, loggedIn}) => {\n    if (authenticating) {\n        return <LoadingSpinner />;\n    }\n    if (!loggedIn) {\n        return <Navigate replace={true} to=\"/login\" />;\n    }\n    return <>{children}</>;\n};\n\nexport default Layout;\n"
  },
  {
    "path": "ui/src/layout/Navigation.tsx",
    "content": "import Divider from '@mui/material/Divider';\nimport Drawer from '@mui/material/Drawer';\nimport {Theme} from '@mui/material/styles';\nimport React from 'react';\nimport {Link} from 'react-router-dom';\nimport {observer} from 'mobx-react-lite';\nimport {mayAllowPermission, requestPermission} from '../snack/browserNotification';\nimport {\n    Button,\n    IconButton,\n    Typography,\n    ListItemText,\n    ListItemAvatar,\n    Avatar,\n    ListItemButton,\n} from '@mui/material';\nimport {DrawerProps} from '@mui/material/Drawer/Drawer';\nimport CloseIcon from '@mui/icons-material/Close';\nimport {makeStyles} from 'tss-react/mui';\nimport {useStores} from '../stores';\n\nconst useStyles = makeStyles()((theme: Theme) => ({\n    root: {\n        height: '100%',\n    },\n    drawerPaper: {\n        position: 'relative',\n        width: 250,\n        minHeight: '100%',\n        height: '100vh',\n    },\n    // eslint-disable-next-line\n    toolbar: theme.mixins.toolbar as any,\n    link: {\n        color: 'inherit',\n        textDecoration: 'none',\n    },\n}));\n\ninterface IProps {\n    loggedIn: boolean;\n    navOpen: boolean;\n    setNavOpen: (open: boolean) => void;\n}\n\nconst Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => {\n    const [showRequestNotification, setShowRequestNotification] =\n        React.useState(mayAllowPermission);\n    const {classes} = useStyles();\n    const {appStore} = useStores();\n    const apps = appStore.getItems();\n\n    const userApps =\n        apps.length === 0\n            ? null\n            : apps.map((app) => (\n                  <Link\n                      onClick={() => setNavOpen(false)}\n                      className={`${classes.link} item`}\n                      to={'/messages/' + app.id}\n                      key={app.id}>\n                      <ListItemButton>\n                          <ListItemAvatar style={{minWidth: 42}}>\n                              <Avatar\n                                  style={{width: 32, height: 32}}\n                                  src={app.image}\n                                  variant=\"square\"\n                              />\n                          </ListItemAvatar>\n                          <ListItemText primary={app.name} />\n                      </ListItemButton>\n                  </Link>\n              ));\n\n    const placeholderItems = [\n        <ListItemButton disabled key={-1}>\n            <ListItemText primary=\"Some Server\" />\n        </ListItemButton>,\n        <ListItemButton disabled key={-2}>\n            <ListItemText primary=\"A Raspberry PI\" />\n        </ListItemButton>,\n    ];\n\n    return (\n        <ResponsiveDrawer\n            classes={{root: classes.root, paper: classes.drawerPaper}}\n            navOpen={navOpen}\n            setNavOpen={setNavOpen}\n            id=\"message-navigation\">\n            <div className={classes.toolbar} />\n            <Link className={classes.link} to=\"/\" onClick={() => setNavOpen(false)}>\n                <ListItemButton disabled={!loggedIn} className=\"all\">\n                    <ListItemText primary=\"All Messages\" />\n                </ListItemButton>\n            </Link>\n            <Divider />\n            <div>{loggedIn ? userApps : placeholderItems}</div>\n            <Divider />\n            <Typography align=\"center\" style={{marginTop: 10}}>\n                {showRequestNotification ? (\n                    <Button\n                        onClick={() => {\n                            requestPermission();\n                            setShowRequestNotification(false);\n                        }}>\n                        Enable Notifications\n                    </Button>\n                ) : null}\n            </Typography>\n        </ResponsiveDrawer>\n    );\n});\n\nconst ResponsiveDrawer: React.FC<\n    DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void}\n> = ({navOpen, setNavOpen, children, ...rest}) => (\n    <>\n        <Drawer\n            sx={{display: {sm: 'none', xs: 'block'}}}\n            variant=\"temporary\"\n            open={navOpen}\n            {...rest}>\n            <IconButton onClick={() => setNavOpen(false)} size=\"large\">\n                <CloseIcon />\n            </IconButton>\n            {children}\n        </Drawer>\n        <Drawer sx={{display: {xs: 'none', sm: 'block'}}} variant=\"permanent\" {...rest}>\n            {children}\n        </Drawer>\n    </>\n);\n\nexport default Navigation;\n"
  },
  {
    "path": "ui/src/layout/theme.ts",
    "content": "export type ThemeKey = 'dark' | 'light' | 'system';\n\nexport const isThemeKey = (value: string | null): value is ThemeKey =>\n    value === 'light' || value === 'dark' || value === 'system';\n"
  },
  {
    "path": "ui/src/message/Message.tsx",
    "content": "import {Button, Theme, useMediaQuery, useTheme} from '@mui/material';\nimport IconButton from '@mui/material/IconButton';\nimport {makeStyles} from 'tss-react/mui';\nimport Typography from '@mui/material/Typography';\nimport {ExpandLess, ExpandMore} from '@mui/icons-material';\nimport Delete from '@mui/icons-material/Delete';\nimport React from 'react';\nimport TimeAgo from 'react-timeago';\nimport Container from '../common/Container';\nimport {Markdown} from '../common/Markdown';\nimport * as config from '../config';\nimport {IMessageExtras} from '../types';\nimport {contentType, RenderMode} from './extras';\nimport {TimeAgoFormatter} from '../common/TimeAgoFormatter';\n\nconst PREVIEW_LENGTH = 500;\n\nconst useStyles = makeStyles()((theme: Theme) => ({\n    header: {\n        display: 'flex',\n        width: '100%',\n        alignItems: 'start',\n        alignContent: 'center',\n        paddingBottom: 5,\n        wordBreak: 'break-all',\n    },\n    headerTitle: {\n        flex: 1,\n    },\n    trash: {\n        marginTop: -15,\n        marginRight: -15,\n    },\n    wrapperPadding: {\n        marginBottom: theme.spacing(2),\n        [theme.breakpoints.down('sm')]: {\n            marginBottom: theme.spacing(1),\n        },\n    },\n    messageContentWrapper: {\n        minWidth: 200,\n        width: '100%',\n    },\n    image: {\n        width: 50,\n        height: 50,\n        [theme.breakpoints.down('md')]: {\n            width: 30,\n            height: 30,\n        },\n    },\n    date: {\n        [theme.breakpoints.down('md')]: {\n            order: 1,\n            flexBasis: '100%',\n            opacity: 0.7,\n        },\n    },\n    imageWrapper: {\n        marginRight: 15,\n        width: 50,\n        height: 50,\n    },\n    plainContent: {\n        whiteSpace: 'pre-wrap',\n    },\n    content: {\n        maxHeight: PREVIEW_LENGTH,\n        wordBreak: 'break-all',\n        overflowY: 'hidden',\n        '&.expanded': {\n            maxHeight: 'none',\n        },\n        '& p': {\n            margin: 0,\n            wordBreak: 'break-word',\n        },\n        '& a': {\n            color: '#ff7f50',\n        },\n        '& pre': {\n            overflow: 'auto',\n            borderRadius: '0.25em',\n            backgroundColor:\n                theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',\n            padding: theme.spacing(1),\n        },\n        '& img': {\n            maxWidth: '100%',\n        },\n    },\n}));\n\ninterface IProps {\n    title: string;\n    image?: string;\n    date: string;\n    content: string;\n    priority: number;\n    appName: string;\n    fDelete: VoidFunction;\n    extras?: IMessageExtras;\n    expanded: boolean;\n    onExpand: (expand: boolean) => void;\n}\n\nconst priorityColor = (priority: number) => {\n    if (priority >= 4 && priority <= 7) {\n        return 'rgba(230, 126, 34, 0.7)';\n    } else if (priority > 7) {\n        return '#e74c3c';\n    } else {\n        return 'transparent';\n    }\n};\n\nconst Message = ({\n    fDelete,\n    title,\n    date,\n    image,\n    priority,\n    content,\n    extras,\n    appName,\n    onExpand,\n    expanded: initialExpanded,\n}: IProps) => {\n    const theme = useTheme();\n    const contentRef = React.useRef<HTMLDivElement | null>(null);\n    const {classes} = useStyles();\n    const [expanded, setExpanded] = React.useState(initialExpanded);\n    const [isOverflowing, setOverflowing] = React.useState(false);\n    const smallHeader = useMediaQuery(theme.breakpoints.down('md'));\n\n    const refreshOverflowing = React.useCallback(() => {\n        const ref = contentRef.current;\n        if (!ref) {\n            return;\n        }\n        setOverflowing((overflowing) => overflowing || ref.scrollHeight > ref.clientHeight);\n    }, [contentRef, setOverflowing]);\n\n    const onContentRef = React.useCallback(\n        (ref: HTMLDivElement | null) => {\n            contentRef.current = ref;\n            refreshOverflowing();\n        },\n        [contentRef, refreshOverflowing]\n    );\n\n    React.useEffect(() => void onExpand(expanded), [expanded]);\n\n    const togglePreviewHeight = () => setExpanded((b) => !b);\n\n    const renderContent = () => {\n        switch (contentType(extras)) {\n            case RenderMode.Markdown:\n                return <Markdown onImageLoaded={refreshOverflowing}>{content}</Markdown>;\n            case RenderMode.Plain:\n            default:\n                return <span className={classes.plainContent}>{content}</span>;\n        }\n    };\n    return (\n        <div className={`${classes.wrapperPadding} message`}>\n            <Container\n                style={{\n                    display: 'flex',\n                    flexWrap: 'wrap',\n                    borderLeftColor: priorityColor(priority),\n                    borderLeftWidth: 6,\n                    borderLeftStyle: 'solid',\n                }}>\n                {smallHeader ? (\n                    <HeaderSmall\n                        fDelete={fDelete}\n                        title={title}\n                        appName={appName}\n                        image={image}\n                        date={date}\n                    />\n                ) : (\n                    <HeaderWide\n                        fDelete={fDelete}\n                        title={title}\n                        appName={appName}\n                        image={image}\n                        date={date}\n                    />\n                )}\n\n                <div className={classes.messageContentWrapper}>\n                    <Typography\n                        component=\"div\"\n                        ref={onContentRef}\n                        className={`${classes.content} content ${\n                            isOverflowing && expanded ? 'expanded' : ''\n                        }`}>\n                        {renderContent()}\n                    </Typography>\n                </div>\n                {isOverflowing && (\n                    <Button\n                        style={{marginTop: 16}}\n                        onClick={togglePreviewHeight}\n                        variant=\"contained\"\n                        color=\"primary\"\n                        size=\"large\"\n                        fullWidth={true}\n                        startIcon={expanded ? <ExpandLess /> : <ExpandMore />}>\n                        {expanded ? 'Read Less' : 'Read More'}\n                    </Button>\n                )}\n            </Container>\n        </div>\n    );\n};\n\nconst HeaderWide = ({\n    appName,\n    image,\n    date,\n    fDelete,\n    title,\n}: Pick<IProps, 'appName' | 'image' | 'fDelete' | 'date' | 'title'>) => {\n    const {classes} = useStyles();\n\n    return (\n        <div className={classes.header}>\n            <div className={classes.imageWrapper}>\n                {image !== null ? (\n                    <img\n                        src={config.get('url') + image}\n                        alt={`${appName} logo`}\n                        width=\"50\"\n                        height=\"50\"\n                        className={classes.image}\n                    />\n                ) : null}\n            </div>\n            <div className={classes.headerTitle}>\n                <Typography className=\"title\" variant=\"h5\" lineHeight={1.2}>\n                    {title}\n                </Typography>\n                <Typography variant=\"subtitle1\" fontSize={12} style={{opacity: 0.7}}>\n                    {appName}\n                </Typography>\n            </div>\n            <Typography variant=\"body1\" className={classes.date}>\n                <TimeAgo date={date} formatter={TimeAgoFormatter.narrow} />\n            </Typography>\n            <IconButton\n                onClick={fDelete}\n                style={{padding: 14}}\n                className={`${classes.trash} delete`}\n                size=\"large\">\n                <Delete />\n            </IconButton>\n        </div>\n    );\n};\nconst HeaderSmall = ({\n    appName,\n    image,\n    date,\n    fDelete,\n    title,\n}: Pick<IProps, 'appName' | 'image' | 'fDelete' | 'date' | 'title'>) => {\n    const {classes} = useStyles();\n\n    return (\n        <div className={classes.header}>\n            <div className={classes.headerTitle}>\n                <Typography className=\"title\" variant=\"h5\" lineHeight={1.2}>\n                    {title}\n                </Typography>\n                <Typography variant=\"subtitle1\" fontSize={12} style={{opacity: 0.7}}>\n                    {appName}\n                </Typography>\n                <Typography variant=\"body1\" className={classes.date}>\n                    <TimeAgo date={date} formatter={TimeAgoFormatter.long} />\n                </Typography>\n            </div>\n            <div style={{display: 'flex', alignItems: 'end', flexDirection: 'column'}}>\n                <IconButton\n                    onClick={fDelete}\n                    style={{padding: 14}}\n                    className={`${classes.trash} delete`}\n                    size=\"large\">\n                    <Delete />\n                </IconButton>\n                <div style={{width: 30, height: 30}}>\n                    {image !== null ? (\n                        <img\n                            src={config.get('url') + image}\n                            alt={`${appName} logo`}\n                            className={classes.image}\n                        />\n                    ) : null}\n                </div>\n            </div>\n        </div>\n    );\n};\n\nexport default Message;\n"
  },
  {
    "path": "ui/src/message/Messages.tsx",
    "content": "import Grid from '@mui/material/Grid';\nimport Typography from '@mui/material/Typography';\nimport React from 'react';\nimport {useParams} from 'react-router';\nimport DefaultPage from '../common/DefaultPage';\nimport Button from '@mui/material/Button';\nimport Message from './Message';\nimport {observer} from 'mobx-react-lite';\nimport {IMessage} from '../types';\nimport ConfirmDialog from '../common/ConfirmDialog';\nimport LoadingSpinner from '../common/LoadingSpinner';\nimport {useStores} from '../stores';\nimport {Virtuoso} from 'react-virtuoso';\nimport {PushMessageDialog} from './PushMessageDialog';\nimport {enqueueSnackbar} from 'notistack';\n\nconst UndoAutoHideMs = 5000;\n\nconst Messages = observer(() => {\n    const {id} = useParams<{id: string}>();\n    const appId = id == null ? -1 : parseInt(id as string, 10);\n\n    const [deleteAll, setDeleteAll] = React.useState(false);\n    const [pushMessageOpen, setPushMessageOpen] = React.useState(false);\n    const [isLoadingMore, setLoadingMore] = React.useState(false);\n    const {messagesStore, appStore} = useStores();\n    const messages = messagesStore.get(appId);\n    const hasMore = messagesStore.canLoadMore(appId);\n    const name = appStore.getName(appId);\n    const hasMessages = messages.length !== 0;\n    const expandedState = React.useRef<Record<number, boolean>>({});\n    const app = appId === -1 ? undefined : appStore.getByIDOrUndefined(appId);\n\n    const deleteMessage = (message: IMessage) => {\n        const key = enqueueSnackbar({\n            message: 'Message deleted',\n            variant: 'info',\n            action: () => (\n                <Button\n                    color=\"inherit\"\n                    size=\"small\"\n                    onClick={() => messagesStore.cancelPendingDelete(message)}>\n                    Undo\n                </Button>\n            ),\n            disableWindowBlurListener: true,\n            transitionDuration: {enter: 0, exit: 0},\n            autoHideDuration: UndoAutoHideMs,\n            onExited: () => messagesStore.removeSingle(message),\n        });\n        messagesStore.addPendingDelete({message, key});\n    };\n\n    React.useEffect(() => {\n        if (!messagesStore.loaded(appId)) {\n            messagesStore.loadMore(appId);\n        }\n    }, [appId]);\n\n    const renderMessage = (_index: number, message: IMessage) => (\n        <Message\n            key={message.id}\n            fDelete={() => deleteMessage(message)}\n            onExpand={(expanded) => (expandedState.current[message.id] = expanded)}\n            title={message.title}\n            date={message.date}\n            appName={appStore.getName(message.appid)}\n            expanded={expandedState.current[message.id] ?? false}\n            content={message.message}\n            image={message.image}\n            extras={message.extras}\n            priority={message.priority}\n        />\n    );\n\n    const checkIfLoadMore = () => {\n        if (!isLoadingMore && messagesStore.canLoadMore(appId)) {\n            setLoadingMore(true);\n            messagesStore.loadMore(appId).then(() => setLoadingMore(false));\n        }\n    };\n\n    const messageFooter = () => {\n        if (hasMore) {\n            return <LoadingSpinner />;\n        }\n        if (hasMessages) {\n            return label(\"You've reached the end\");\n        }\n        return null;\n    };\n\n    const renderMessages = () => (\n        <Virtuoso\n            id=\"messages\"\n            style={{width: '100%'}}\n            useWindowScroll\n            totalCount={messages.length}\n            endReached={checkIfLoadMore}\n            data={messages}\n            itemContent={renderMessage}\n            components={{\n                Footer: messageFooter,\n                EmptyPlaceholder: () => label('No messages'),\n            }}\n        />\n    );\n    const label = (text: string) => (\n        <Grid size={{xs: 12}}>\n            <Typography variant=\"caption\" component=\"div\" gutterBottom align=\"center\">\n                {text}\n            </Typography>\n        </Grid>\n    );\n    return (\n        <DefaultPage\n            title={name}\n            rightControl={\n                <div>\n                    {app && (\n                        <Button\n                            id=\"push-message\"\n                            variant=\"contained\"\n                            color=\"primary\"\n                            onClick={() => setPushMessageOpen(true)}\n                            style={{marginRight: 5}}>\n                            Push Message\n                        </Button>\n                    )}\n                    <Button\n                        id=\"refresh-all\"\n                        variant=\"contained\"\n                        color=\"primary\"\n                        onClick={() => messagesStore.refreshByApp(appId)}\n                        style={{marginRight: 5}}>\n                        Refresh\n                    </Button>\n                    <Button\n                        id=\"delete-all\"\n                        variant=\"contained\"\n                        disabled={!hasMessages}\n                        color=\"primary\"\n                        onClick={() => {\n                            setDeleteAll(true);\n                        }}>\n                        Delete All\n                    </Button>\n                </div>\n            }>\n            {!messagesStore.loaded(appId) ? <LoadingSpinner /> : renderMessages()}\n\n            {deleteAll && (\n                <ConfirmDialog\n                    title=\"Confirm Delete\"\n                    text={'Delete all messages?'}\n                    fClose={() => setDeleteAll(false)}\n                    fOnSubmit={() => messagesStore.removeByApp(appId)}\n                />\n            )}\n            {pushMessageOpen && app && (\n                <PushMessageDialog\n                    appName={app.name}\n                    defaultPriority={app.defaultPriority}\n                    fClose={() => setPushMessageOpen(false)}\n                    fOnSubmit={(message, title, priority) =>\n                        messagesStore.sendMessage(app.id, message, title, priority)\n                    }\n                />\n            )}\n        </DefaultPage>\n    );\n});\n\nexport default Messages;\n"
  },
  {
    "path": "ui/src/message/MessagesStore.ts",
    "content": "import {BaseStore} from '../common/BaseStore';\nimport {action, IObservableArray, observable, reaction, runInAction} from 'mobx';\nimport axios, {AxiosResponse} from 'axios';\nimport * as config from '../config';\nimport {createTransformer} from 'mobx-utils';\nimport {SnackReporter} from '../snack/SnackManager';\nimport {IApplication, IMessage, IPagedMessages} from '../types';\nimport {closeSnackbar, SnackbarKey} from 'notistack';\n\nconst AllMessages = -1;\n\ninterface MessagesState {\n    messages: IObservableArray<IMessage>;\n    hasMore: boolean;\n    nextSince: number;\n    loaded: boolean;\n}\n\ninterface PendingDelete {\n    key: SnackbarKey;\n    message: IMessage;\n}\n\nexport class MessagesStore {\n    @observable private accessor state: Record<string, MessagesState> = {};\n    @observable private accessor pendingDeletes: Map<number, PendingDelete> = observable.map();\n\n    private loading = false;\n\n    public constructor(\n        private readonly appStore: BaseStore<IApplication>,\n        private readonly snack: SnackReporter\n    ) {\n        reaction(() => appStore.getItems(), this.createEmptyStatesForApps);\n    }\n\n    private stateOf = (appId: number, create = true) => {\n        if (!this.state[appId] && create) {\n            this.state[appId] = this.emptyState();\n        }\n        return this.state[appId] || this.emptyState();\n    };\n\n    public loaded = (appId: number) => this.stateOf(appId, /*create*/ false).loaded;\n\n    public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore;\n\n    @action\n    public loadMore = async (appId: number) => {\n        const state = this.stateOf(appId);\n        if (!state.hasMore || this.loading) {\n            return Promise.resolve();\n        }\n        this.loading = true;\n\n        try {\n            const pagedResult = await this.fetchMessages(appId, state.nextSince).then(\n                (resp) => resp.data\n            );\n            runInAction(() => {\n                state.messages.replace([...state.messages, ...pagedResult.messages]);\n                state.nextSince = pagedResult.paging.since ?? 0;\n                state.hasMore = 'next' in pagedResult.paging;\n                state.loaded = true;\n            });\n        } finally {\n            this.loading = false;\n        }\n\n        return Promise.resolve();\n    };\n\n    @action\n    public publishSingleMessage = (message: IMessage) => {\n        if (this.exists(AllMessages)) {\n            this.stateOf(AllMessages).messages.unshift(message);\n        }\n        if (this.exists(message.appid)) {\n            this.stateOf(message.appid).messages.unshift(message);\n        }\n    };\n\n    @action\n    public removeByApp = async (appId: number) => {\n        if (appId === AllMessages) {\n            await axios.delete(config.get('url') + 'message');\n            this.snack('Deleted all messages');\n            this.clearAll();\n        } else {\n            await axios.delete(config.get('url') + 'application/' + appId + '/message');\n            this.snack(`Deleted all messages from ${this.appStore.getByID(appId).name}`);\n            this.clear(AllMessages);\n            this.clear(appId);\n        }\n        await this.loadMore(appId);\n    };\n\n    @action\n    public addPendingDelete = (pending: PendingDelete) =>\n        this.pendingDeletes.set(pending.message.id, pending);\n\n    @action\n    public cancelPendingDelete = (message: IMessage): boolean => {\n        const pending = this.pendingDeletes.get(message.id);\n        if (pending) {\n            this.pendingDeletes.delete(message.id);\n            closeSnackbar(pending.key);\n        }\n        return !!pending;\n    };\n\n    @action\n    public executePendingDeletes = () =>\n        Array.from(this.pendingDeletes.values()).forEach(({message}) => this.removeSingle(message));\n\n    public visible = (message: number): boolean => !this.pendingDeletes.has(message);\n\n    @action\n    public removeSingle = async (message: IMessage) => {\n        if (!this.pendingDeletes.has(message.id)) {\n            return;\n        }\n\n        await axios.delete(config.get('url') + 'message/' + message.id, {\n            adapter: 'fetch',\n            fetchOptions: {keepalive: true},\n        });\n        if (this.exists(AllMessages)) {\n            this.removeFromList(this.state[AllMessages].messages, message);\n        }\n        if (this.exists(message.appid)) {\n            this.removeFromList(this.state[message.appid].messages, message);\n        }\n        this.cancelPendingDelete(message);\n    };\n\n    public sendMessage = async (\n        appId: number,\n        message: string,\n        title: string,\n        priority: number\n    ): Promise<void> => {\n        const app = this.appStore.getByID(appId);\n        const payload: Pick<IMessage, 'title' | 'message' | 'priority'> = {\n            message,\n            priority,\n            title,\n        };\n\n        await axios.post(`${config.get('url')}message`, payload, {\n            headers: {'X-Gotify-Key': app.token},\n        });\n        this.snack(`Message sent to ${app.name}`);\n    };\n\n    @action\n    public clearAll = () => {\n        this.state = {};\n        this.createEmptyStatesForApps(this.appStore.getItems());\n    };\n\n    @action\n    public refreshByApp = async (appId: number) => {\n        this.clearAll();\n        this.loadMore(appId);\n    };\n\n    public exists = (id: number) => this.stateOf(id).loaded;\n\n    @action\n    private removeFromList(messages: IMessage[], messageToDelete: IMessage): false | number {\n        if (messages) {\n            const index = messages.findIndex((message) => message.id === messageToDelete.id);\n            if (index !== -1) {\n                messages.splice(index, 1);\n                return index;\n            }\n        }\n        return false;\n    }\n\n    @action\n    private clear = (appId: number) => (this.state[appId] = this.emptyState());\n\n    private fetchMessages = (\n        appId: number,\n        since: number\n    ): Promise<AxiosResponse<IPagedMessages>> => {\n        if (appId === AllMessages) {\n            return axios.get(config.get('url') + 'message?since=' + since);\n        } else {\n            return axios.get(\n                config.get('url') + 'application/' + appId + '/message?since=' + since\n            );\n        }\n    };\n\n    private getUnCached = (appId: number): Array<IMessage> => {\n        const appToImage: Partial<Record<string, string>> = this.appStore\n            .getItems()\n            .reduce((all, app) => ({...all, [app.id]: app.image}), {});\n\n        return this.stateOf(appId, false)\n            .messages.filter((message) => !this.pendingDeletes.has(message.id))\n            .map((message: IMessage): IMessage => ({...message, image: appToImage[message.appid]}));\n    };\n\n    public get = createTransformer(this.getUnCached);\n\n    private clearCache = () => (this.get = createTransformer(this.getUnCached));\n\n    private createEmptyStatesForApps = (apps: IApplication[]) => {\n        apps.map((app) => app.id).forEach((id) => this.stateOf(id, /*create*/ true));\n        this.clearCache();\n    };\n\n    private emptyState = (): MessagesState => ({\n        messages: observable.array(),\n        hasMore: true,\n        nextSince: 0,\n        loaded: false,\n    });\n}\n"
  },
  {
    "path": "ui/src/message/PushMessageDialog.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogContentText from '@mui/material/DialogContentText';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport React, {useState} from 'react';\nimport {NumberField} from '../common/NumberField';\n\ninterface IProps {\n    appName: string;\n    defaultPriority: number;\n    fClose: VoidFunction;\n    fOnSubmit: (message: string, title: string, priority: number) => Promise<void>;\n}\n\nexport const PushMessageDialog = ({appName, defaultPriority, fClose, fOnSubmit}: IProps) => {\n    const [title, setTitle] = useState('');\n    const [message, setMessage] = useState('');\n    const [priority, setPriority] = useState(defaultPriority);\n\n    const submitEnabled = message.trim().length !== 0;\n    const submitAndClose = async () => {\n        await fOnSubmit(message, title, priority);\n        fClose();\n    };\n\n    return (\n        <Dialog\n            open={true}\n            onClose={fClose}\n            aria-labelledby=\"push-message-title\"\n            id=\"push-message-dialog\">\n            <DialogTitle id=\"push-message-title\">Push message</DialogTitle>\n            <DialogContent>\n                <DialogContentText>\n                    Send a push message via {appName}. Leave the title empty to use the application\n                    name.\n                </DialogContentText>\n                <TextField\n                    margin=\"dense\"\n                    className=\"title\"\n                    label=\"Title\"\n                    type=\"text\"\n                    value={title}\n                    onChange={(e) => setTitle(e.target.value)}\n                    fullWidth\n                />\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"message\"\n                    label=\"Message *\"\n                    type=\"text\"\n                    value={message}\n                    onChange={(e) => setMessage(e.target.value)}\n                    fullWidth\n                    multiline\n                    minRows={4}\n                />\n                <NumberField\n                    margin=\"dense\"\n                    className=\"priority\"\n                    label=\"Priority\"\n                    value={priority}\n                    onChange={(value) => setPriority(value)}\n                    fullWidth\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip title={submitEnabled ? '' : 'message is required'}>\n                    <div>\n                        <Button\n                            className=\"send\"\n                            disabled={!submitEnabled}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Send\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\n"
  },
  {
    "path": "ui/src/message/WebSocketStore.ts",
    "content": "import {SnackReporter} from '../snack/SnackManager';\nimport {CurrentUser} from '../CurrentUser';\nimport * as config from '../config';\nimport {AxiosError} from 'axios';\nimport {IMessage} from '../types';\n\nexport class WebSocketStore {\n    private wsActive = false;\n    private ws: WebSocket | null = null;\n\n    public constructor(\n        private readonly snack: SnackReporter,\n        private readonly currentUser: CurrentUser\n    ) {}\n\n    public listen = (callback: (msg: IMessage) => void) => {\n        if (!this.currentUser.token() || this.wsActive) {\n            return;\n        }\n        this.wsActive = true;\n\n        const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss');\n        const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token());\n\n        ws.onerror = (e) => {\n            this.wsActive = false;\n            console.log('WebSocket connection errored', e);\n        };\n\n        ws.onmessage = (data) => callback(JSON.parse(data.data));\n\n        ws.onclose = () => {\n            this.wsActive = false;\n            this.currentUser\n                .tryAuthenticate()\n                .then(() => {\n                    this.snack('WebSocket connection closed, trying again in 30 seconds.');\n                    setTimeout(() => this.listen(callback), 30000);\n                })\n                .catch((error: AxiosError) => {\n                    if (error?.response?.status === 401) {\n                        this.snack('Could not authenticate with client token, logging out.');\n                    }\n                });\n        };\n\n        this.ws = ws;\n    };\n\n    public close = () => this.ws?.close(1000, 'WebSocketStore#close');\n}\n"
  },
  {
    "path": "ui/src/message/extras.ts",
    "content": "import {IMessageExtras} from '../types';\n\nexport enum RenderMode {\n    Markdown = 'text/markdown',\n    Plain = 'text/plain',\n}\n\nexport const contentType = (extras?: IMessageExtras): RenderMode => {\n    const type = extract(extras, 'client::display', 'contentType');\n    const valid = Object.values(RenderMode).includes(type);\n    return valid ? type : RenderMode.Plain;\n};\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst extract = (extras: IMessageExtras | undefined, key: string, path: string): any => {\n    if (!extras) {\n        return null;\n    }\n\n    if (!extras[key]) {\n        return null;\n    }\n\n    if (!extras[key][path]) {\n        return null;\n    }\n\n    return extras[key][path];\n};\n"
  },
  {
    "path": "ui/src/plugin/PluginDetailView.tsx",
    "content": "import React from 'react';\nimport {useParams} from 'react-router';\nimport {Markdown} from '../common/Markdown';\nimport {material} from '@uiw/codemirror-theme-material';\nimport CodeMirror from '@uiw/react-codemirror';\nimport Info from '@mui/icons-material/Info';\nimport Build from '@mui/icons-material/Build';\nimport Subject from '@mui/icons-material/Subject';\nimport Refresh from '@mui/icons-material/Refresh';\nimport Button from '@mui/material/Button';\nimport Typography from '@mui/material/Typography';\nimport DefaultPage from '../common/DefaultPage';\nimport * as config from '../config';\nimport Container from '../common/Container';\nimport {IPlugin} from '../types';\nimport LoadingSpinner from '../common/LoadingSpinner';\nimport {useStores} from '../stores';\n\nconst PluginDetailView = () => {\n    const {id} = useParams<{id: string}>();\n    const pluginID = parseInt(id as string, 10);\n    const {pluginStore} = useStores();\n    const [currentConfig, setCurrentConfig] = React.useState<string>();\n    const [displayText, setDisplayText] = React.useState<string>();\n\n    const pluginInfo = pluginStore.getByIDOrUndefined(pluginID);\n\n    const refreshFeatures = async () => {\n        await pluginStore.refreshIfMissing(pluginID);\n        await Promise.all([refreshConfigurer(), refreshDisplayer()]);\n    };\n\n    React.useEffect(() => void refreshFeatures(), [pluginID]);\n\n    const refreshConfigurer = async () => {\n        if (pluginInfo?.capabilities.indexOf('configurer') !== -1) {\n            setCurrentConfig(await pluginStore.requestConfig(pluginID));\n        }\n    };\n\n    const refreshDisplayer = async () => {\n        if (pluginInfo?.capabilities.indexOf('displayer') !== -1) {\n            setDisplayText(await pluginStore.requestDisplay(pluginID));\n        }\n    };\n\n    if (pluginInfo == null) {\n        return <LoadingSpinner />;\n    }\n\n    const handleSaveConfig = async (newConfig: string) => {\n        await pluginStore.changeConfig(pluginID, newConfig);\n        await refreshFeatures();\n    };\n\n    return (\n        <DefaultPage title={pluginInfo.name} maxWidth={1000}>\n            <PanelWrapper name={'Plugin Info'} icon={Info}>\n                <PluginInfo pluginInfo={pluginInfo} />\n            </PanelWrapper>\n            {pluginInfo.capabilities.indexOf('configurer') !== -1 ? (\n                <PanelWrapper\n                    name={'Configurer'}\n                    description={'This is the configuration panel for this plugin.'}\n                    icon={Build}\n                    refresh={refreshConfigurer}>\n                    <ConfigurerPanel\n                        pluginInfo={pluginInfo}\n                        initialConfig={currentConfig != null ? currentConfig : 'Loading...'}\n                        save={handleSaveConfig}\n                    />\n                </PanelWrapper>\n            ) : null}{' '}\n            {pluginInfo.capabilities.indexOf('displayer') !== -1 ? (\n                <PanelWrapper\n                    name={'Displayer'}\n                    description={'This is the information generated by the plugin.'}\n                    refresh={refreshDisplayer}\n                    icon={Subject}>\n                    <DisplayerPanel\n                        pluginInfo={pluginInfo}\n                        displayText={displayText != null ? displayText : 'Loading...'}\n                    />\n                </PanelWrapper>\n            ) : null}\n        </DefaultPage>\n    );\n};\n\ninterface IPanelWrapperProps {\n    name: string;\n    description?: string;\n    refresh?: () => Promise<void>;\n    icon?: React.ComponentType;\n}\n\nconst PanelWrapper: React.FC<React.PropsWithChildren<IPanelWrapperProps>> = ({\n    name,\n    description,\n    refresh,\n    icon,\n    children,\n}) => {\n    const Icon = icon;\n    return (\n        <div\n            style={{\n                width: '100%',\n                paddingLeft: '16px',\n                paddingRight: '16px',\n            }}>\n            <Container\n                style={{\n                    display: 'block',\n                    width: '100%',\n                    margin: '12px 0px',\n                }}>\n                <Typography variant=\"h5\">\n                    {Icon ? (\n                        <span>\n                            <Icon />\n                            &nbsp;\n                        </span>\n                    ) : null}\n                    {name}\n                    {refresh ? (\n                        <Button\n                            style={{float: 'right'}}\n                            onClick={() => {\n                                refresh();\n                            }}>\n                            <Refresh />\n                        </Button>\n                    ) : null}\n                </Typography>\n                {description ? <Typography variant=\"subtitle1\">{description}</Typography> : null}\n                <hr />\n                <div className={name.toLowerCase().trim().replace(/ /g, '-')}>{children}</div>\n            </Container>\n        </div>\n    );\n};\n\ninterface IConfigurerPanelProps {\n    pluginInfo: IPlugin;\n    initialConfig: string;\n    save: (newConfig: string) => Promise<void>;\n}\nconst ConfigurerPanel = ({initialConfig, save}: IConfigurerPanelProps) => {\n    const [unsavedChanges, setUnsavedChanges] = React.useState<string | null>(null);\n    const onChange = React.useCallback(\n        (value: string | null) => {\n            let newConf: string | null = value;\n            if (value === initialConfig) {\n                newConf = null;\n            }\n            setUnsavedChanges(newConf);\n        },\n        [initialConfig]\n    );\n    return (\n        <div>\n            <CodeMirror value={initialConfig} theme={material} onChange={onChange} />\n            <br />\n            <Button\n                variant=\"contained\"\n                color=\"primary\"\n                fullWidth={true}\n                disabled={unsavedChanges === null || unsavedChanges === initialConfig}\n                className=\"config-save\"\n                onClick={() => {\n                    const newConfig = unsavedChanges;\n                    save(newConfig!).then(() => {\n                        setUnsavedChanges(null);\n                    });\n                }}>\n                <Typography variant=\"button\">Save</Typography>\n            </Button>\n        </div>\n    );\n};\n\ninterface IDisplayerPanelProps {\n    pluginInfo: IPlugin;\n    displayText: string;\n}\nconst DisplayerPanel: React.FC<IDisplayerPanelProps> = ({displayText}) => (\n    <Typography variant=\"body2\">\n        <Markdown>{displayText}</Markdown>\n    </Typography>\n);\n\ninterface IPluginInfo {\n    pluginInfo: IPlugin;\n}\n\nconst PluginInfo = ({pluginInfo}: IPluginInfo) => {\n    const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo;\n\n    return (\n        <div style={{wordWrap: 'break-word'}}>\n            {name ? (\n                <Typography variant=\"body2\" className=\"name\">\n                    Name: <span>{name}</span>\n                </Typography>\n            ) : null}\n            {author ? (\n                <Typography variant=\"body2\" className=\"author\">\n                    Author: <span>{author}</span>\n                </Typography>\n            ) : null}\n            <Typography variant=\"body2\" className=\"module-path\">\n                Module Path: <span>{modulePath}</span>\n            </Typography>\n            {website ? (\n                <Typography variant=\"body2\" className=\"website\">\n                    Website: <span>{website}</span>\n                </Typography>\n            ) : null}\n            {license ? (\n                <Typography variant=\"body2\" className=\"license\">\n                    License: <span>{license}</span>\n                </Typography>\n            ) : null}\n            <Typography variant=\"body2\" className=\"capabilities\">\n                Capabilities: <span>{capabilities.join(', ')}</span>\n            </Typography>\n            {capabilities.indexOf('webhooker') !== -1 ? (\n                <Typography variant=\"body2\">\n                    Custom Route Prefix:{' '}\n                    {((url) => (\n                        <a\n                            href={url}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"custom-route\">\n                            {url}\n                        </a>\n                    ))(`${config.get('url')}plugin/${id}/custom/${token}/`)}\n                </Typography>\n            ) : null}\n        </div>\n    );\n};\n\nexport default PluginDetailView;\n"
  },
  {
    "path": "ui/src/plugin/PluginStore.ts",
    "content": "import axios from 'axios';\nimport {action} from 'mobx';\nimport {BaseStore} from '../common/BaseStore';\nimport * as config from '../config';\nimport {SnackReporter} from '../snack/SnackManager';\nimport {IPlugin} from '../types';\n\nexport class PluginStore extends BaseStore<IPlugin> {\n    public onDelete: () => void = () => {};\n\n    public constructor(private readonly snack: SnackReporter) {\n        super();\n    }\n\n    public requestConfig = (id: number): Promise<string> =>\n        axios.get(`${config.get('url')}plugin/${id}/config`).then((response) => response.data);\n\n    public requestDisplay = (id: number): Promise<string> =>\n        axios.get(`${config.get('url')}plugin/${id}/display`).then((response) => response.data);\n\n    protected requestItems = (): Promise<IPlugin[]> =>\n        axios.get<IPlugin[]>(`${config.get('url')}plugin`).then((response) => response.data);\n\n    protected requestDelete = (): Promise<void> => {\n        this.snack('Cannot delete plugin');\n        throw new Error('Cannot delete plugin');\n    };\n\n    public getName = (id: number): string => {\n        const plugin = this.getByIDOrUndefined(id);\n        return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown';\n    };\n\n    @action\n    public changeConfig = async (id: number, newConfig: string): Promise<void> => {\n        await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, {\n            headers: {'content-type': 'application/x-yaml'},\n        });\n        this.snack(`Plugin config updated`);\n        await this.refresh();\n    };\n\n    @action\n    public changeEnabledState = async (id: number, enabled: boolean): Promise<void> => {\n        await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);\n        this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);\n        await this.refresh();\n    };\n}\n"
  },
  {
    "path": "ui/src/plugin/Plugins.tsx",
    "content": "import React from 'react';\nimport {Link} from 'react-router-dom';\nimport Grid from '@mui/material/Grid';\nimport Paper from '@mui/material/Paper';\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableCell from '@mui/material/TableCell';\nimport TableHead from '@mui/material/TableHead';\nimport TableRow from '@mui/material/TableRow';\nimport Settings from '@mui/icons-material/Settings';\nimport {Switch, Button} from '@mui/material';\nimport DefaultPage from '../common/DefaultPage';\nimport CopyableSecret from '../common/CopyableSecret';\nimport {observer} from 'mobx-react-lite';\nimport {IPlugin} from '../types';\nimport {useStores} from '../stores';\n\nconst Plugins = observer(() => {\n    const {pluginStore} = useStores();\n    React.useEffect(() => void pluginStore.refresh(), []);\n    const plugins = pluginStore.getItems();\n    return (\n        <DefaultPage title=\"Plugins\" maxWidth={1000}>\n            <Grid size={{xs: 12}}>\n                <Paper elevation={6} style={{overflowX: 'auto'}}>\n                    <Table id=\"plugin-table\">\n                        <TableHead>\n                            <TableRow>\n                                <TableCell>ID</TableCell>\n                                <TableCell>Enabled</TableCell>\n                                <TableCell>Name</TableCell>\n                                <TableCell>Token</TableCell>\n                                <TableCell>Details</TableCell>\n                            </TableRow>\n                        </TableHead>\n                        <TableBody>\n                            {plugins.map((plugin: IPlugin) => (\n                                <Row\n                                    key={plugin.token}\n                                    id={plugin.id}\n                                    token={plugin.token}\n                                    name={plugin.name}\n                                    enabled={plugin.enabled}\n                                    fToggleStatus={() =>\n                                        pluginStore.changeEnabledState(plugin.id, !plugin.enabled)\n                                    }\n                                />\n                            ))}\n                        </TableBody>\n                    </Table>\n                </Paper>\n            </Grid>\n        </DefaultPage>\n    );\n});\n\ninterface IRowProps {\n    id: number;\n    name: string;\n    token: string;\n    enabled: boolean;\n    fToggleStatus: VoidFunction;\n}\n\nconst Row: React.FC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => (\n    <TableRow>\n        <TableCell>{id}</TableCell>\n        <TableCell>\n            <Switch\n                checked={enabled}\n                onClick={fToggleStatus}\n                className=\"switch\"\n                data-enabled={enabled}\n            />\n        </TableCell>\n        <TableCell>{name}</TableCell>\n        <TableCell>\n            <CopyableSecret value={token} style={{display: 'flex', alignItems: 'center'}} />\n        </TableCell>\n        <TableCell align=\"right\" padding=\"none\">\n            <Link to={'/plugins/' + id}>\n                <Button>\n                    <Settings />\n                </Button>\n            </Link>\n        </TableCell>\n    </TableRow>\n));\n\nexport default Plugins;\n"
  },
  {
    "path": "ui/src/react-app-env.d.ts",
    "content": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "ui/src/reactions.ts",
    "content": "import {reaction} from 'mobx';\nimport * as Notifications from './snack/browserNotification';\nimport {StoreMapping} from './stores';\n\nconst AUDIO_REPEAT_DELAY = 1000;\n\nexport const registerReactions = (stores: StoreMapping) => {\n    window.addEventListener('pagehide', stores.messagesStore.executePendingDeletes);\n    window.addEventListener('beforeunload', stores.messagesStore.executePendingDeletes);\n\n    const clearAll = () => {\n        stores.messagesStore.clearAll();\n        stores.appStore.clear();\n        stores.clientStore.clear();\n        stores.userStore.clear();\n        stores.wsStore.close();\n    };\n\n    let audio: HTMLAudioElement | undefined;\n    let lastAudio = 0;\n\n    const loadAll = () => {\n        stores.wsStore.listen((message) => {\n            stores.messagesStore.publishSingleMessage(message);\n            Notifications.notifyNewMessage(message);\n            if (message.priority >= 4 && Date.now() > lastAudio + AUDIO_REPEAT_DELAY) {\n                lastAudio = Date.now();\n\n                audio ??= new Audio('static/notification.ogg');\n                audio.currentTime = 0;\n                audio.play();\n            }\n        });\n        stores.appStore.refresh();\n    };\n\n    reaction(\n        () => stores.currentUser.loggedIn,\n        (loggedIn) => {\n            if (loggedIn) {\n                loadAll();\n            } else {\n                clearAll();\n            }\n        }\n    );\n\n    reaction(\n        () => stores.currentUser.connectionErrorMessage,\n        (connectionErrorMessage) => {\n            if (!connectionErrorMessage) {\n                clearAll();\n                loadAll();\n                stores.currentUser.refreshKey++;\n            }\n        }\n    );\n};\n"
  },
  {
    "path": "ui/src/registerServiceWorker.ts",
    "content": "export function unregister() {\n    if ('serviceWorker' in navigator) {\n        navigator.serviceWorker.ready.then((registration) => {\n            registration.unregister();\n        });\n    }\n}\n"
  },
  {
    "path": "ui/src/snack/SnackManager.ts",
    "content": "import {enqueueSnackbar} from 'notistack';\n\nexport interface SnackReporter {\n    (message: string): void;\n}\n\nexport class SnackManager {\n    public snack: SnackReporter = (message: string): void => {\n        enqueueSnackbar({message, variant: 'info'});\n    };\n}\n"
  },
  {
    "path": "ui/src/snack/browserNotification.ts",
    "content": "import Notify from 'notifyjs';\nimport removeMarkdown from 'remove-markdown';\nimport {IMessage} from '../types';\n\nexport function mayAllowPermission(): boolean {\n    return Notify.needsPermission && Notify.isSupported() && Notification.permission !== 'denied';\n}\n\nexport function requestPermission() {\n    if (Notify.needsPermission && Notify.isSupported()) {\n        Notify.requestPermission(\n            () => console.log('granted notification permissions'),\n            () => console.log('notification permission denied')\n        );\n    }\n}\n\nexport function notifyNewMessage(msg: IMessage) {\n    const notify = new Notify(msg.title, {\n        body: removeMarkdown(msg.message),\n        icon: msg.image,\n        silent: true,\n        notifyClick: closeAndFocus,\n        notifyShow: closeAfterTimeout,\n    });\n    notify.show();\n}\n\nfunction closeAndFocus(event: Event) {\n    if (window.parent) {\n        window.parent.focus();\n    }\n    window.focus();\n    window.location.href = '/';\n    const target = event.target as Notification;\n    target.close();\n}\n\nfunction closeAfterTimeout(event: Event) {\n    setTimeout(() => {\n        const target = event.target as Notification;\n        target.close();\n    }, 5000);\n}\n"
  },
  {
    "path": "ui/src/stores.tsx",
    "content": "import * as React from 'react';\nimport {UserStore} from './user/UserStore';\nimport {SnackManager} from './snack/SnackManager';\nimport {MessagesStore} from './message/MessagesStore';\nimport {CurrentUser} from './CurrentUser';\nimport {ClientStore} from './client/ClientStore';\nimport {AppStore} from './application/AppStore';\nimport {WebSocketStore} from './message/WebSocketStore';\nimport {PluginStore} from './plugin/PluginStore';\n\nexport interface StoreMapping {\n    userStore: UserStore;\n    snackManager: SnackManager;\n    messagesStore: MessagesStore;\n    currentUser: CurrentUser;\n    clientStore: ClientStore;\n    appStore: AppStore;\n    pluginStore: PluginStore;\n    wsStore: WebSocketStore;\n}\n\nexport const StoreContext = React.createContext<StoreMapping | undefined>(undefined);\n\nexport const useStores = (): StoreMapping => {\n    const mapping = React.useContext(StoreContext);\n    if (!mapping) throw new Error('uninitialized');\n    return mapping;\n};\n"
  },
  {
    "path": "ui/src/tests/application.test.ts",
    "content": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';\nimport {afterAll, beforeAll, describe, expect, it} from 'vitest';\nimport * as auth from './authentication';\nimport * as selector from './selector';\n\nlet page: Page;\nlet gotify: GotifyTest;\nbeforeAll(async () => {\n    gotify = await newTest();\n    page = gotify.page;\n});\n\nafterAll(async () => await gotify.close());\n\nenum Col {\n    Name = 3,\n    Token = 4,\n    Description = 5,\n    DefaultPriority = 6,\n    LastUsed = 7,\n    EditUpdate = 8,\n    EditDelete = 9,\n}\n\nconst hiddenToken = '•••••••••••••••';\n\nconst $table = selector.table('#app-table');\nconst $dialog = selector.form('#app-dialog');\n\nconst waitforApp =\n    (name: string, description: string, row: number): (() => Promise<void>) =>\n    async () => {\n        await waitForExists(page, $table.cell(row, Col.Name), name);\n        expect(await innerText(page, $table.cell(row, Col.Token))).toBe(hiddenToken);\n        expect(await innerText(page, $table.cell(row, Col.Description))).toBe(description);\n    };\n\nconst updateApp =\n    (id: number, data: {name?: string; description?: string}): (() => Promise<void>) =>\n    async () => {\n        await page.click($table.cell(id, Col.EditUpdate, '.edit'));\n        await page.waitForSelector($dialog.selector());\n        if (data.name) {\n            const nameSelector = $dialog.input('.name');\n            await clearField(page, nameSelector);\n            await page.type(nameSelector, data.name);\n        }\n        if (data.description) {\n            const descSelector = $dialog.textarea('.description');\n            await clearField(page, descSelector);\n            await page.type(descSelector, data.description);\n        }\n        await page.click($dialog.button('.update'));\n        await waitToDisappear(page, $dialog.selector());\n    };\n\nconst createApp =\n    (name: string, description: string): (() => Promise<void>) =>\n    async () => {\n        await page.click('#create-app');\n        await page.waitForSelector($dialog.selector());\n        await page.type($dialog.input('.name'), name);\n        await page.type($dialog.textarea('.description'), description);\n        await page.click($dialog.button('.create'));\n        await waitToDisappear(page, $dialog.selector());\n    };\n\ndescribe('Application', () => {\n    it('does login', async () => await auth.login(page));\n    it('navigates to applications', async () => {\n        await page.click('#navigate-apps');\n        await waitForExists(page, selector.heading(), 'Applications');\n    });\n    it('has changed url', async () => {\n        expect(page.url()).toContain('/applications');\n    });\n    it('does not have any applications', async () => {\n        expect(await count(page, $table.rows())).toBe(0);\n    });\n    describe('create apps', () => {\n        it('server', createApp('server', '#1'));\n        it('desktop', createApp('desktop', '#2'));\n        it('raspberry', createApp('raspberry', '#3'));\n    });\n    describe('has created apps', () => {\n        it('has three apps', async () => {\n            await page.waitForSelector($table.row(3));\n            expect(await count(page, $table.rows())).toBe(3);\n        });\n        it('has server app', waitforApp('server', '#1', 1));\n        it('has desktop app', waitforApp('desktop', '#2', 2));\n        it('has raspberry app', waitforApp('raspberry', '#3', 3));\n        it('shows token', async () => {\n            await page.click($table.cell(3, Col.Token, '.toggle-visibility'));\n            const token = await innerText(page, $table.cell(3, Col.Token));\n            expect(token.startsWith('A')).toBeTruthy();\n            await page.click($table.cell(3, Col.Token, '.toggle-visibility'));\n        });\n    });\n    it('updates application', async () => {\n        await updateApp(1, {name: 'server_linux'})();\n        await updateApp(2, {description: 'kitchen_computer'})();\n        await updateApp(3, {name: 'raspberry_pi', description: 'home_pi'})();\n    });\n    it('has updated application', async () => {\n        await waitforApp('server_linux', '#1', 1)();\n        await waitforApp('desktop', 'kitchen_computer', 2)();\n        await waitforApp('raspberry_pi', 'home_pi', 3)();\n    });\n    it('deletes application', async () => {\n        await page.click($table.cell(2, Col.EditDelete, '.delete'));\n\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n    });\n    it('has deleted application', async () => {\n        await waitToDisappear(page, $table.row(3));\n        expect(await count(page, $table.rows())).toBe(2);\n    });\n    it('does logout', async () => await auth.logout(page));\n});\n"
  },
  {
    "path": "ui/src/tests/authentication.ts",
    "content": "import {Page} from 'puppeteer';\nimport {waitForExists} from './utils';\nimport {expect} from 'vitest';\nimport * as selector from './selector';\n\nconst $loginForm = selector.form('#login-form');\n\nexport const login = async (page: Page, user = 'admin', pass = 'admin'): Promise<void> => {\n    await waitForExists(page, selector.heading(), 'Login');\n    expect(page.url()).toContain('/login');\n    await page.type($loginForm.input('.name'), user);\n    await page.type($loginForm.input('.password'), pass);\n    await page.click($loginForm.button('.login'));\n    await waitForExists(page, selector.heading(), 'All Messages');\n    await waitForExists(page, 'button', 'logout');\n};\n\nexport const logout = async (page: Page): Promise<void> => {\n    await page.click('#logout');\n    await waitForExists(page, selector.heading(), 'Login');\n    expect(page.url()).toContain('/login');\n};\n"
  },
  {
    "path": "ui/src/tests/client.test.ts",
    "content": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';\nimport {afterAll, beforeAll, describe, expect, it} from 'vitest';\nimport * as auth from './authentication';\n\nimport * as selector from './selector';\n\nlet page: Page;\nlet gotify: GotifyTest;\nbeforeAll(async () => {\n    gotify = await newTest();\n    page = gotify.page;\n});\n\nafterAll(async () => await gotify.close());\n\nenum Col {\n    Name = 1,\n    Token = 2,\n    LastSeen = 3,\n    Edit = 4,\n    Delete = 5,\n}\n\nconst waitForClient =\n    (name: string, row: number): (() => Promise<void>) =>\n    async () => {\n        await waitForExists(page, $table.cell(row, Col.Name), name);\n    };\n\nconst updateClient =\n    (id: number, data: {name?: string}): (() => Promise<void>) =>\n    async () => {\n        await page.click($table.cell(id, Col.Edit, '.edit'));\n        await page.waitForSelector($dialog.selector());\n        if (data.name) {\n            const nameSelector = $dialog.input('.name');\n            await clearField(page, nameSelector);\n            await page.type(nameSelector, data.name);\n        }\n        await page.click($dialog.button('.update'));\n        await waitToDisappear(page, $dialog.selector());\n    };\n\nconst $table = selector.table('#client-table');\nconst $dialog = selector.form('#client-dialog');\n\ndescribe('Client', () => {\n    it('does login', async () => await auth.login(page));\n    it('navigates to clients', async () => {\n        await page.click('#navigate-clients');\n        await waitForExists(page, selector.heading(), 'Clients');\n    });\n    it('has changed url', async () => {\n        expect(page.url()).toContain('/clients');\n    });\n    it('has one client (the current session)', async () => {\n        expect(await count(page, $table.rows())).toBe(1);\n    });\n    describe('create clients', () => {\n        const createClient =\n            (name: string): (() => Promise<void>) =>\n            async () => {\n                await page.click('#create-client');\n                await page.waitForSelector($dialog.selector());\n                await page.type($dialog.input('.name'), name);\n                await page.click($dialog.button('.create'));\n                await waitToDisappear(page, $dialog.selector());\n            };\n        it('phone', createClient('phone'));\n        it('desktop app', createClient('desktop app'));\n    });\n    it('has created clients', async () => {\n        await page.waitForSelector($table.row(3));\n\n        expect(await count(page, $table.rows())).toBe(3);\n\n        expect(await innerText(page, $table.cell(1, Col.Name))).toContain('chrome');\n        expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone');\n        expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app');\n    });\n    it('updates client', updateClient(1, {name: 'firefox'}));\n    it('has updated client name', waitForClient('firefox', 1));\n    it('shows token', async () => {\n        await page.click($table.cell(3, Col.Token, '.toggle-visibility'));\n        expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();\n    });\n    it('shows last seen', async () => {\n        expect(await innerText(page, $table.cell(3, Col.LastSeen))).toBeTruthy();\n    });\n    it('deletes client', async () => {\n        await page.click($table.cell(2, Col.Delete, '.delete'));\n\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n    });\n    it('has deleted client', async () => {\n        await waitToDisappear(page, $table.row(3));\n\n        expect(await count(page, $table.rows())).toBe(2);\n    });\n    it('deletes own client', async () => {\n        await page.click($table.cell(1, Col.Delete, '.delete'));\n\n        // confirm delete\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n    });\n    it('automatically logs out', async () => {\n        await waitForExists(page, selector.heading(), 'Login');\n    });\n});\n"
  },
  {
    "path": "ui/src/tests/message.test.ts",
    "content": "// todo before all tests jest start puppeteer\nimport {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {\n    clearField,\n    clickByText,\n    count,\n    innerText,\n    waitForCount,\n    waitForExists,\n    waitToDisappear,\n} from './utils';\nimport {afterAll, beforeAll, describe, expect, it} from 'vitest';\nimport * as auth from './authentication';\nimport * as selector from './selector';\nimport axios from 'axios';\nimport {IApplication, IMessage, IMessageExtras} from '../types';\n\nlet page: Page;\nlet gotify: GotifyTest;\nbeforeAll(async () => {\n    gotify = await newTest();\n    page = gotify.page;\n});\n\nafterAll(async () => await gotify.close());\n\nconst axiosAuth = {auth: {username: 'admin', password: 'admin'}};\n\nlet windowsServerToken: string;\nlet linuxServerToken: string;\nlet backupServerToken: string;\n\nconst naviId = '#message-navigation';\n\ninterface Msg {\n    message: string;\n    title: string;\n}\n\nconst navigate = async (appName: string) => {\n    await clickByText(page, 'a', appName);\n    await waitForExists(page, selector.heading(), appName);\n};\n\ndescribe('Messages', () => {\n    it('does login', async () => await auth.login(page));\n    it('is on messages', async () => {\n        await waitForExists(page, selector.heading(), 'All Messages');\n    });\n    it('has url', async () => {\n        expect(page.url()).toContain('/');\n    });\n    const createApp = (name: string) =>\n        axios\n            .post<IApplication>(`${gotify.url}/application`, {name}, axiosAuth)\n            .then((resp) => resp.data.token);\n    it('shows navigation', async () => {\n        await page.waitForSelector(naviId);\n    });\n    it('has all messages button', async () => {\n        await page.waitForSelector(`${naviId} .all`);\n    });\n    it('has no applications', async () => {\n        expect(await count(page, `${naviId} .item`)).toBe(0);\n    });\n    describe('create apps', () => {\n        it('Windows', async () => {\n            windowsServerToken = await createApp('Windows');\n            await page.reload();\n            await waitForExists(page, 'a', 'Windows');\n        });\n        it('Backup', async () => {\n            backupServerToken = await createApp('Backup');\n            await page.reload();\n            await waitForExists(page, 'a', 'Backup');\n        });\n        it('Linux', async () => {\n            linuxServerToken = await createApp('Linux');\n            await page.reload();\n            await waitForExists(page, 'a', 'Linux');\n        });\n    });\n    it('has three applications', async () => {\n        expect(await count(page, `${naviId} .item`)).toBe(3);\n    });\n    it('changes url when navigating to application', async () => {\n        await navigate('Windows');\n        expect(page.url()).toContain('/messages/1');\n        await navigate('All Messages');\n    });\n    it('has no messages', async () => {\n        expect(await count(page, '#messages .message')).toBe(0);\n    });\n    it('has no messages in app', async () => {\n        await navigate('Windows');\n        expect(await count(page, '#messages .message')).toBe(0);\n        await navigate('All Messages');\n    });\n    it('hides push message on all messages', async () => {\n        await navigate('All Messages');\n        expect(await count(page, '#push-message')).toBe(0);\n    });\n    it('pushes a message via ui', async () => {\n        await navigate('Windows');\n        await page.waitForSelector('#push-message');\n        await page.click('#push-message');\n        await page.waitForSelector('#push-message-dialog');\n        await page.type('#push-message-dialog .title input', 'UI Test');\n        await page.type('#push-message-dialog .message textarea', 'Hello from UI');\n        await clearField(page, '#push-message-dialog .priority input');\n        await page.type('#push-message-dialog .priority input', '2');\n        await page.click('#push-message-dialog .send');\n        await waitToDisappear(page, '#push-message-dialog');\n        expect(await extractMessages(1)).toEqual([m('UI Test', 'Hello from UI')]);\n        await page.click('#messages .message .delete');\n        expect(await extractMessages(0)).toEqual([]);\n        await navigate('All Messages');\n    });\n\n    const extractMessages = async (expectCount: number) => {\n        await waitForCount(page, '#messages .message', expectCount);\n        const messages = await page.$$(`#messages .message`);\n        const result: Msg[] = [];\n        for (const item of messages) {\n            const message = await innerText(item, '.content');\n            const title = await innerText(item, '.title');\n            result.push({message, title});\n        }\n        return result;\n    };\n    const m = (title: string, message: string, extras?: IMessageExtras) => ({\n        title,\n        message,\n        extras,\n    });\n\n    const windows1 = m('Login', 'User jmattheis logged in.');\n    const windows2 = m('Shutdown', 'Windows will be shut down.');\n    const windows3 = m('Login', 'User nicories logged in.');\n\n    const linux1 = m('SSH-Login', 'root@127.0.0.1 did a ssh login.');\n    const linux2 = m('Reboot', 'Linux server just rebooted.');\n    const linux3 = m('SSH-Login', 'jmattheis@localhost did a ssh login.');\n\n    const backup1 = m('Backup done', 'Linux Server Backup finished (1.6GB).');\n    const backup2 = m('Backup done', 'Windows Server Backup finished (6.2GB).');\n    const backup3 = m('Backup done', 'Gotify Backup finished (0.1MB).');\n\n    const createMessage = (msg: Partial<IMessage>, token: string) =>\n        axios.post<IMessage>(`${gotify.url}/message`, msg, {\n            headers: {'X-Gotify-Key': token},\n        });\n\n    const expectMessages = async (toCheck: {\n        all: Msg[];\n        windows: Msg[];\n        linux: Msg[];\n        backup: Msg[];\n    }) => {\n        await navigate('All Messages');\n        expect(await extractMessages(toCheck.all.length)).toEqual(toCheck.all);\n        await navigate('Windows');\n        expect(await extractMessages(toCheck.windows.length)).toEqual(toCheck.windows);\n        await navigate('Linux');\n        expect(await extractMessages(toCheck.linux.length)).toEqual(toCheck.linux);\n        await navigate('Backup');\n        expect(await extractMessages(toCheck.backup.length)).toEqual(toCheck.backup);\n        await navigate('All Messages');\n    };\n\n    it('create a message', async () => {\n        await createMessage(windows1, windowsServerToken);\n        expect(await extractMessages(1)).toEqual([windows1]);\n    });\n    it('has one message in windows app', async () => {\n        await navigate('Windows');\n        expect(await extractMessages(1)).toEqual([windows1]);\n    });\n    it('has no message in linux app', async () => {\n        await navigate('Linux');\n        expect(await extractMessages(0)).toEqual([]);\n        await navigate('All Messages');\n    });\n    describe('add some messages', () => {\n        it('1', async () => {\n            await createMessage(windows2, windowsServerToken);\n            await expectMessages({\n                all: [windows2, windows1],\n                windows: [windows2, windows1],\n                linux: [],\n                backup: [],\n            });\n        });\n        it('2', async () => {\n            await createMessage(linux1, linuxServerToken);\n            await expectMessages({\n                all: [linux1, windows2, windows1],\n                windows: [windows2, windows1],\n                linux: [linux1],\n                backup: [],\n            });\n        });\n        it('3', async () => {\n            await createMessage(backup1, backupServerToken);\n            await expectMessages({\n                all: [backup1, linux1, windows2, windows1],\n                windows: [windows2, windows1],\n                linux: [linux1],\n                backup: [backup1],\n            });\n        });\n        it('4', async () => {\n            await createMessage(windows3, windowsServerToken);\n            await expectMessages({\n                all: [windows3, backup1, linux1, windows2, windows1],\n                windows: [windows3, windows2, windows1],\n                linux: [linux1],\n                backup: [backup1],\n            });\n        });\n        it('5', async () => {\n            await createMessage(linux2, linuxServerToken);\n            await expectMessages({\n                all: [linux2, windows3, backup1, linux1, windows2, windows1],\n                windows: [windows3, windows2, windows1],\n                linux: [linux2, linux1],\n                backup: [backup1],\n            });\n        });\n    });\n    it('deletes a windows message', async () => {\n        await navigate('Windows');\n        await page.evaluate(() =>\n            (\n                document.querySelectorAll('#messages .message .delete')[1] as HTMLButtonElement\n            ).click()\n        );\n        await expectMessages({\n            all: [linux2, windows3, backup1, linux1, windows1],\n            windows: [windows3, windows1],\n            linux: [linux2, linux1],\n            backup: [backup1],\n        });\n    });\n    it('deletes all linux messages', async () => {\n        await navigate('Linux');\n        await page.click('#delete-all');\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n        await page.waitForSelector('#delete-all:disabled');\n        await expectMessages({\n            all: [windows3, backup1, windows1],\n            windows: [windows3, windows1],\n            linux: [],\n            backup: [backup1],\n        });\n    });\n    describe('add some more messages', () => {\n        it('1', async () => {\n            await createMessage(linux3, linuxServerToken);\n            await expectMessages({\n                all: [linux3, windows3, backup1, windows1],\n                windows: [windows3, windows1],\n                linux: [linux3],\n                backup: [backup1],\n            });\n        });\n        it('2', async () => {\n            await createMessage(backup2, backupServerToken);\n            await expectMessages({\n                all: [backup2, linux3, windows3, backup1, windows1],\n                windows: [windows3, windows1],\n                linux: [linux3],\n                backup: [backup2, backup1],\n            });\n        });\n    });\n    it('deletes all messages', async () => {\n        await navigate('All Messages');\n        await page.click('#delete-all');\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n        await page.waitForSelector('#delete-all:disabled');\n        await expectMessages({\n            all: [],\n            windows: [],\n            linux: [],\n            backup: [],\n        });\n    });\n    it('adds one last message', async () => {\n        await createMessage(backup3, backupServerToken);\n        await expectMessages({\n            all: [backup3],\n            windows: [],\n            linux: [],\n            backup: [backup3],\n        });\n    });\n    it('deletes all backup messages and navigates to all messages', async () => {\n        await navigate('Backup');\n        await page.click('#delete-all');\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n        await page.waitForSelector('#delete-all:disabled');\n        await navigate('All Messages');\n        await createMessage(backup3, backupServerToken);\n        await waitForExists(page, '.message .title', backup3.title);\n        expect(await extractMessages(1)).toEqual([backup3]);\n    });\n    it('does logout', async () => await auth.logout(page));\n});\n"
  },
  {
    "path": "ui/src/tests/plugin.test.ts",
    "content": "import * as os from 'os';\nimport {Page} from 'puppeteer';\nimport axios from 'axios';\nimport {afterAll, beforeAll, describe, expect, it} from 'vitest';\nimport * as auth from './authentication';\nimport * as selector from './selector';\nimport {GotifyTest, newTest, newPluginDir} from './setup';\nimport {innerText, waitForCount, waitForExists} from './utils';\n\nconst pluginSupported = ['linux', 'darwin'].indexOf(os.platform()) !== -1;\n\nlet page: Page;\nlet gotify: GotifyTest;\n\nbeforeAll(async () => {\n    const gotifyPluginDir = pluginSupported\n        ? await newPluginDir(['github.com/gotify/server/v2/plugin/example/echo'])\n        : '';\n    gotify = await newTest(gotifyPluginDir);\n    page = gotify.page;\n});\n\nafterAll(async () => await gotify.close());\n\nenum Col {\n    ID = 1,\n    SetEnabled = 2,\n    Name = 3,\n    Token = 4,\n    Details = 5,\n}\n\nconst hiddenToken = '•••••••••••••••';\n\nconst $table = selector.table('#plugin-table');\n\nconst switchSelctor = (id: number) => $table.cell(id, Col.SetEnabled, '[data-enabled]');\n\nconst enabledState = async (id: number) =>\n    (await page.$eval(switchSelctor(id), (el) => el.getAttribute('data-enabled'))) === 'true';\n\nconst toggleEnabled = async (id: number) => {\n    const origEnabled = (await enabledState(id)).toString();\n    await page.click(switchSelctor(id));\n    await page.waitForFunction(\n        `document.querySelector(\"${switchSelctor(\n            id\n        )}\").getAttribute(\"data-enabled\") !== \"${origEnabled}\"`\n    );\n};\n\nconst pluginInfo = async (className: string) =>\n    await innerText(page, `.plugin-info .${className} > span`);\n\nconst getDisplayer = async () => await innerText(page, '.displayer');\n\nconst hasReceivedMessage = async (title: RegExp, content: RegExp) => {\n    await page.click('#message-navigation a');\n    await waitForExists(page, selector.heading(), 'All Messages');\n    await waitForCount(page, '#messages .message', 1);\n\n    expect(await innerText(page, '.title')).toMatch(title);\n    expect(await innerText(page, '.content')).toMatch(content);\n\n    await page.click('#navigate-plugins');\n    await waitForExists(page, selector.heading(), 'Plugins');\n};\n\nconst inDetailPage = async (id: number, callback: () => Promise<void>) => {\n    const name = await innerText(page, $table.cell(id, Col.Name));\n    await page.click($table.cell(id, Col.Details, 'button'));\n    await waitForExists(page, '.plugin-info .name > span', name);\n    await callback();\n    await page.click('#navigate-plugins');\n    await waitForExists(page, selector.heading(), 'Plugins');\n    await page.waitForSelector($table.selector());\n};\n\ndescribe('plugin', () => {\n    describe('navigation', () => {\n        it('does login', async () => await auth.login(page));\n        it('navigates to plugins', async () => {\n            await page.click('#navigate-plugins');\n            await waitForExists(page, selector.heading(), 'Plugins');\n        });\n    });\n    if (!pluginSupported) {\n        return;\n    }\n    describe('functionality test', () => {\n        describe('initial status', () => {\n            it('has echo plugin', async () => {\n                await waitForCount(page, $table.rows(), 1);\n                expect(await innerText(page, $table.cell(1, Col.Name))).toEqual('test plugin');\n                expect(await innerText(page, $table.cell(1, Col.Token))).toBe(hiddenToken);\n                expect(parseInt(await innerText(page, $table.cell(1, Col.ID)), 10)).toBeGreaterThan(\n                    0\n                );\n            });\n            it('is disabled by default', async () => {\n                expect(await enabledState(1)).toBe(false);\n            });\n        });\n        describe('enable and disable plugin', () => {\n            it('enable', async () => {\n                await toggleEnabled(1);\n                expect(await enabledState(1)).toBe(true);\n            });\n\n            it('disable', async () => {\n                await toggleEnabled(1);\n                expect(await enabledState(1)).toBe(false);\n            });\n        });\n        describe('details page', () => {\n            it('has plugin info', async () => {\n                await inDetailPage(1, async () => {\n                    expect(await pluginInfo('module-path')).toBe(\n                        'github.com/gotify/server/v2/plugin/example/echo'\n                    );\n                });\n            });\n            it('has displayer', async () => {\n                await inDetailPage(1, async () => {\n                    expect(await getDisplayer()).toBeTruthy();\n                });\n            });\n            it('has configurer', async () => {\n                await inDetailPage(1, async () => {\n                    expect(await page.$('.configurer')).toBeTruthy();\n                });\n            });\n            it('updates configurer', async () => {\n                await inDetailPage(1, async () => {\n                    expect(\n                        await (\n                            await (await page.$('.config-save'))!.getProperty('disabled')\n                        ).jsonValue()\n                    ).toBe(true);\n                    await page.waitForSelector('.cm-editor .cm-content');\n                    await page.waitForFunction(\n                        'document.querySelector(\".cm-editor .cm-content\").innerText.toLowerCase().indexOf(\"loading\")<0'\n                    );\n                    await page.click('.cm-editor .cm-content > div');\n                    await page.keyboard.press('x');\n                    await page.waitForFunction(\n                        'document.querySelector(\".config-save\") && !document.querySelector(\".config-save\").disabled'\n                    );\n                    await page.click('.config-save');\n                    await page.waitForFunction('document.querySelector(\".config-save\").disabled');\n                });\n            });\n            it('configurer updated', async () => {\n                await inDetailPage(1, async () => {\n                    expect(\n                        await (\n                            await (await page.$('.config-save'))!.getProperty('disabled')\n                        ).jsonValue()\n                    ).toBe(true);\n                    await page.waitForSelector('.cm-editor .cm-content > div');\n                    await page.waitForFunction(\n                        'document.querySelector(\".cm-editor .cm-content > div\").innerText.toLowerCase().indexOf(\"loading\")<0'\n                    );\n                    expect(await innerText(page, '.cm-editor .cm-content > div')).toMatch(/x$/);\n                });\n            });\n            it('sends messages', async () => {\n                if (!(await enabledState(1))) {\n                    await toggleEnabled(1);\n                }\n                await inDetailPage(1, async () => {\n                    await page.waitForSelector('.displayer a');\n                    const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href'));\n                    if (!hook) {\n                        throw 'href not found';\n                    }\n                    await axios.get(hook);\n                });\n            });\n            it('has received message', async () => {\n                await hasReceivedMessage(\n                    /^.+received$/,\n                    /^echo server received a hello message \\d+ times$/\n                );\n            });\n        });\n    });\n});\n"
  },
  {
    "path": "ui/src/tests/selector.ts",
    "content": "export const heading = () => `main h4`;\n\nexport const table = (tableSelector: string) => ({\n    selector: () => tableSelector,\n    rows: () => `${tableSelector} tbody tr`,\n    row: (index: number) => `${tableSelector} tbody tr:nth-child(${index})`,\n    cell: (index: number, col: number, suffix = '') =>\n        `${tableSelector} tbody tr:nth-child(${index})  td:nth-child(${col}) ${suffix}`,\n});\n\nexport const form = (dialogSelector: string) => ({\n    selector: () => dialogSelector,\n    input: (selector: string) => `${dialogSelector} ${selector} input`,\n    textarea: (selector: string) => `${dialogSelector} ${selector} textarea`,\n    button: (selector: string) => `${dialogSelector} button${selector}`,\n});\n\nexport const $confirmDialog = form('.confirm-dialog');\n"
  },
  {
    "path": "ui/src/tests/setup.ts",
    "content": "import getPort from 'get-port';\nimport {spawn, exec, ChildProcess} from 'child_process';\nimport {rimrafSync} from 'rimraf';\nimport path from 'path';\nimport puppeteer, {Browser, Page} from 'puppeteer';\nimport fs from 'fs';\n// @ts-expect-error no types\nimport wait from 'wait-on';\nimport kill from 'tree-kill';\n\nexport interface GotifyTest {\n    url: string;\n    close: () => Promise<void>;\n    browser: Browser;\n    page: Page;\n}\n\nconst windowsPrefix = process.platform === 'win32' ? '.exe' : '';\nconst appDotGo = path.join(__dirname, '..', '..', '..', 'app.go');\nconst testBuildPath = path.join(__dirname, 'build');\n\nexport const newPluginDir = async (plugins: string[]): Promise<string> => {\n    const {dir, generator} = testPluginDir();\n    for (const pluginName of plugins) {\n        await buildGoPlugin(generator(), pluginName);\n    }\n    return dir;\n};\n\nexport const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {\n    const port = await getPort();\n\n    const gotifyFile = testFilePath();\n\n    await buildGoExecutable(gotifyFile);\n\n    const gotifyInstance = startGotify(gotifyFile, port, pluginsDir);\n\n    const gotifyURL = 'http://localhost:' + port;\n    await waitForGotify('http-get://localhost:' + port);\n    const browser = await puppeteer.launch({\n        headless: process.env.CI === 'true',\n        args: [`--window-size=1920,1080`, '--no-sandbox'],\n    });\n    const page = await browser.newPage();\n    await page.setViewport({width: 1920, height: 1080});\n    await page.goto(gotifyURL);\n\n    return {\n        close: async () => {\n            await Promise.all([\n                browser.close(),\n                new Promise((resolve) =>\n                    kill(gotifyInstance.pid!, 'SIGKILL', () => resolve(undefined))\n                ),\n            ]);\n            rimrafSync(gotifyFile, {maxRetries: 8});\n        },\n        url: gotifyURL,\n        browser,\n        page,\n    };\n};\n\nconst testPluginDir = (): {dir: string; generator: () => string} => {\n    const random = Math.random().toString(36).substring(2, 15);\n    const dirName = 'gotifyplugin_' + random;\n    const dir = path.join(testBuildPath, dirName);\n    if (!fs.existsSync(dir)) {\n        fs.mkdirSync(dir, {recursive: true, mode: 0o755});\n    }\n    return {\n        dir,\n        generator: () => {\n            const randomFn = Math.random().toString(36).substring(2, 15);\n            return path.join(dir, randomFn + '.so');\n        },\n    };\n};\n\nconst testFilePath = (): string => {\n    const random = Math.random().toString(36).substring(2, 15);\n    const filename = 'gotifytest_' + random + windowsPrefix;\n    return path.join(testBuildPath, filename);\n};\n\nconst waitForGotify = (url: string): Promise<void> =>\n    new Promise((resolve, err) => {\n        wait({resources: [url], timeout: 40000}, (error: string) => {\n            if (error) {\n                console.log(error);\n                err(error);\n            } else {\n                resolve();\n            }\n        });\n    });\n\nconst buildGoPlugin = (filename: string, pluginPath: string): Promise<void> => {\n    process.stdout.write(`### Building Plugin ${pluginPath}\\n`);\n    return new Promise((resolve) =>\n        exec(`go build -o ${filename} -buildmode=plugin ${pluginPath}`, () => resolve())\n    );\n};\n\nconst buildGoExecutable = (filename: string): Promise<void> => {\n    const envGotify = process.env.GOTIFY_EXE;\n    if (envGotify) {\n        if (!fs.existsSync(testBuildPath)) {\n            fs.mkdirSync(testBuildPath, {recursive: true});\n        }\n        fs.copyFileSync(envGotify, filename);\n        process.stdout.write(`### Copying ${envGotify} to ${filename}\\n`);\n        return Promise.resolve();\n    } else {\n        process.stdout.write(`### Building Gotify ${filename}\\n`);\n        return new Promise((resolve) =>\n            exec(`go build -ldflags=\"-X main.Mode=prod\" -o ${filename} ${appDotGo}`, () =>\n                resolve()\n            )\n        );\n    }\n};\n\nconst startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => {\n    const gotify = spawn(filename, [], {\n        env: {\n            GOTIFY_SERVER_PORT: '' + port,\n            GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared',\n            GOTIFY_PLUGINSDIR: pluginDir,\n            NODE_ENV: process.env.NODE_ENV,\n            PUBLIC_URL: process.env.PUBLIC_URL,\n        },\n    });\n    gotify.stdout.pipe(process.stdout);\n    gotify.stderr.pipe(process.stderr);\n    return gotify;\n};\n"
  },
  {
    "path": "ui/src/tests/user.test.ts",
    "content": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {clearField, count, innerText, waitForExists, waitToDisappear} from './utils';\nimport {afterAll, beforeAll, describe, expect, it} from 'vitest';\nimport * as auth from './authentication';\nimport * as selector from './selector';\n\nlet page: Page;\nlet gotify: GotifyTest;\nbeforeAll(async () => {\n    gotify = await newTest();\n    page = gotify.page;\n});\n\nafterAll(async () => await gotify.close());\n\nenum Col {\n    Name = 1,\n    Admin = 2,\n    EditDelete = 3,\n}\n\nconst $table = selector.table('#user-table');\nconst $dialog = selector.form('#add-edit-user-dialog');\n\ndescribe('User', () => {\n    it('does login', async () => await auth.login(page));\n    it('navigates to users through window location', async () => {\n        await page.goto(gotify.url + '/#/users');\n        await waitForExists(page, selector.heading(), 'Users');\n    });\n    it('has changed url', async () => {\n        expect(page.url()).toContain('/users');\n    });\n    it('has only admin user (the current one)', async () => {\n        expect(await count(page, $table.rows())).toBe(1);\n    });\n    describe('create users', () => {\n        const createUser =\n            (name: string, password: string, isAdmin: boolean): (() => Promise<void>) =>\n            async () => {\n                await page.click('#create-user');\n                await page.waitForSelector($dialog.selector());\n                await page.type($dialog.input('.name'), name);\n                await page.type($dialog.input('.password'), password);\n                if (isAdmin) {\n                    await page.click($dialog.input('.admin-rights'));\n                }\n                await page.click($dialog.button('.save-create'));\n                await waitToDisappear(page, $dialog.selector());\n            };\n        it('nicories', createUser('nicories', '123', false));\n        it('jmattheis', createUser('jmattheis', 'noice', true));\n        it('dude', createUser('dude', '1', false));\n    });\n    const hasUser =\n        (name: string, isAdmin: boolean, row: number): (() => Promise<void>) =>\n        async () => {\n            expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name);\n            expect(await innerText(page, $table.cell(row, Col.Admin))).toBe(isAdmin ? 'Yes' : 'No');\n        };\n\n    describe('has created users', () => {\n        it('has four users', async () => {\n            await page.waitForSelector($table.row(4));\n            expect(await count(page, $table.rows())).toBe(4);\n        });\n        it('has admin user', hasUser('admin', true, 1));\n        it('has nicories user', hasUser('nicories', false, 2));\n        it('has jmattheis user', hasUser('jmattheis', true, 3));\n        it('has dude user', hasUser('dude', false, 4));\n    });\n    describe('edit users', () => {\n        it('changes password of jmattheis', async () => {\n            await page.click($table.cell(3, Col.EditDelete, '.edit'));\n            await page.waitForSelector($dialog.selector());\n            await page.type($dialog.input('.password'), 'unicorn');\n            await page.click($dialog.button('.save-create'));\n            await waitToDisappear(page, $dialog.selector());\n        });\n        it('changed jmattheis', hasUser('jmattheis', true, 3));\n\n        it('changes name of nicories', async () => {\n            await page.click($table.cell(2, 3, '.edit'));\n\n            await page.waitForSelector($dialog.selector());\n\n            await clearField(page, $dialog.input('.name'));\n            await page.type($dialog.input('.name'), 'nicolas');\n            await page.click($dialog.button('.save-create'));\n            await waitToDisappear(page, $dialog.selector());\n\n            await waitForExists(page, $table.cell(2, Col.Name), 'nicolas');\n        });\n        it('changed nicories to nicolas', hasUser('nicolas', false, 2));\n\n        it('makes dude admin', async () => {\n            await page.click($table.cell(4, Col.EditDelete, '.edit'));\n\n            await page.waitForSelector($dialog.selector());\n\n            await page.click($dialog.input('.admin-rights'));\n            await page.click($dialog.button('.save-create'));\n            await waitToDisappear(page, $dialog.selector());\n\n            await waitForExists(page, $table.cell(4, Col.Admin), 'Yes');\n        });\n        it('made dude admin', hasUser('dude', true, 4));\n    });\n\n    it('deletes dude', async () => {\n        await page.click($table.cell(4, Col.EditDelete, '.delete'));\n\n        await page.waitForSelector(selector.$confirmDialog.selector());\n        await page.click(selector.$confirmDialog.button('.confirm'));\n    });\n    it('has deleted dude', async () => {\n        await waitToDisappear(page, $table.row(4));\n        expect(await count(page, $table.rows())).toBe(3);\n    });\n    it('changes password of current user', async () => {\n        const $changepw = selector.form('#changepw-dialog');\n        await page.click('#changepw');\n        await page.waitForSelector($changepw.selector());\n        await page.type($changepw.input('.newpass'), 'changed');\n        await page.click($changepw.button('.change'));\n    });\n    it('does logout', async () => await auth.logout(page));\n    it('can login with new password (admin)', async () =>\n        await auth.login(page, 'admin', 'changed'));\n    it('does logout admin', async () => await auth.logout(page));\n\n    it('can login with nicolas', async () => await auth.login(page, 'nicolas', '123'));\n    it('does logout nicolas', async () => await auth.logout(page));\n    it('can login with jmattheis', async () => await auth.login(page, 'jmattheis', 'unicorn'));\n    it('does logout jmattheis', async () => await auth.logout(page));\n});\n"
  },
  {
    "path": "ui/src/tests/utils.ts",
    "content": "import {ElementHandle, JSHandle, Page} from 'puppeteer';\n\nexport const innerText = async (page: ElementHandle | Page, selector: string): Promise<string> => {\n    const element = await page.$(selector);\n    const handle = await element!.getProperty('innerText');\n    const value = await handle.jsonValue();\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return (value as any).toString().trim();\n};\n\nexport const clickByText = async (page: Page, selector: string, text: string): Promise<void> => {\n    await waitForExists(page, selector, text);\n    text = text.toLowerCase();\n    await page.evaluate(\n        (_selector, _text) => {\n            (\n                Array.from(document.querySelectorAll(_selector)).filter(\n                    (element) => element.textContent?.toLowerCase().trim() === _text\n                )[0] as HTMLButtonElement\n            ).click();\n        },\n        selector,\n        text\n    );\n};\n\nexport const count = async (page: Page, selector: string): Promise<number> =>\n    page.$$(selector).then((elements) => elements.length);\n\nexport const waitToDisappear = async (page: Page, selector: string): Promise<JSHandle> =>\n    page.waitForFunction((_selector: string) => !document.querySelector(_selector), {}, selector);\n\nexport const waitForCount = async (\n    page: Page,\n    selector: string,\n    amount: number\n): Promise<JSHandle> =>\n    page.waitForFunction(\n        (_selector: string, _amount: number) =>\n            document.querySelectorAll(_selector).length === _amount,\n        {},\n        selector,\n        amount\n    );\n\nexport const waitForExists = async (page: Page, selector: string, text: string): Promise<void> => {\n    text = text.toLowerCase();\n    await page.waitForFunction(\n        (_selector: string, _text: string) =>\n            Array.from(document.querySelectorAll(_selector)).filter(\n                (element) => element.textContent!.toLowerCase().trim() === _text\n            ).length > 0,\n        {},\n        selector,\n        text\n    );\n};\n\nexport const clearField = async (element: ElementHandle | Page, selector: string) => {\n    const elementHandle = await element.$(selector);\n    if (!elementHandle) {\n        throw 'element handle not set';\n    }\n    await elementHandle.click();\n    await elementHandle.focus();\n    // click three times to select all\n    await elementHandle.click({clickCount: 3});\n    await elementHandle.press('Backspace');\n};\n"
  },
  {
    "path": "ui/src/typedef/notifyjs.d.ts",
    "content": "// eslint-disable-next-line\nimport Notify = require('notifyjs');\nexport as namespace notifyjs;\nexport = Notify;\n"
  },
  {
    "path": "ui/src/typedef/react-timeago.d.ts",
    "content": "declare module 'react-timeago' {\n    import React from 'react';\n\n    export type FormatterOptions = {\n        style?: 'long' | 'short' | 'narrow';\n        locale?: string;\n    };\n    export type Formatter = (options: FormatterOptions) => React.ReactNode;\n\n    export interface ITimeAgoProps {\n        date: string;\n        formatter?: Formatter;\n    }\n\n    export default class TimeAgo extends React.Component<ITimeAgoProps, unknown> {}\n}\n\ndeclare module 'react-timeago/defaultFormatter' {\n    declare function makeIntlFormatter(options: FormatterOptions): Formatter;\n}\n"
  },
  {
    "path": "ui/src/types.ts",
    "content": "export interface IApplication {\n    id: number;\n    token: string;\n    name: string;\n    sortKey: string;\n    description: string;\n    image: string;\n    internal: boolean;\n    defaultPriority: number;\n    lastUsed: string | null;\n}\n\nexport interface IClient {\n    id: number;\n    token: string;\n    name: string;\n    lastUsed: string | null;\n}\n\nexport interface IPlugin {\n    id: number;\n    token: string;\n    name: string;\n    modulePath: string;\n    enabled: boolean;\n    author?: string;\n    website?: string;\n    license?: string;\n    capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>;\n}\n\nexport interface IMessage {\n    id: number;\n    appid: number;\n    message: string;\n    title: string;\n    priority: number;\n    date: string;\n    image?: string;\n    extras?: IMessageExtras;\n}\n\nexport interface IMessageExtras {\n    [key: string]: any; // eslint-disable-line  @typescript-eslint/no-explicit-any\n}\n\nexport interface IPagedMessages {\n    paging: IPaging;\n    messages: IMessage[];\n}\n\nexport interface IPaging {\n    next?: string;\n    since?: number;\n    size: number;\n    limit: number;\n}\n\nexport interface IUser {\n    id: number;\n    name: string;\n    admin: boolean;\n}\n\nexport interface IVersion {\n    version: string;\n    commit: string;\n    buildDate: string;\n}\n"
  },
  {
    "path": "ui/src/user/AddEditUserDialog.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport FormControlLabel from '@mui/material/FormControlLabel';\nimport Switch from '@mui/material/Switch';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport React from 'react';\n\ninterface IProps {\n    name?: string;\n    admin?: boolean;\n    fClose: VoidFunction;\n    fOnSubmit: (name: string, pass: string, admin: boolean) => Promise<void>;\n    isEdit?: boolean;\n}\n\nconst AddEditUserDialog = ({\n    fClose,\n    fOnSubmit,\n    isEdit,\n    name: initialName = '',\n    admin: initialAdmin = false,\n}: IProps) => {\n    const [name, setName] = React.useState(initialName);\n    const [pass, setPass] = React.useState('');\n    const [admin, setAdmin] = React.useState(initialAdmin);\n\n    const namePresent = name.length !== 0;\n    const passPresent = pass.length !== 0 || isEdit;\n    const submitAndClose = async () => {\n        await fOnSubmit(name, pass, admin);\n        fClose();\n    };\n    return (\n        <Dialog\n            open={true}\n            onClose={fClose}\n            aria-labelledby=\"form-dialog-title\"\n            id=\"add-edit-user-dialog\">\n            <DialogTitle id=\"form-dialog-title\">\n                {isEdit ? 'Edit ' + name : 'Add a user'}\n            </DialogTitle>\n            <DialogContent>\n                <TextField\n                    autoFocus\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Username *\"\n                    value={name}\n                    name=\"username\"\n                    id=\"username\"\n                    onChange={(e) => setName(e.target.value)}\n                    fullWidth\n                />\n                <TextField\n                    margin=\"dense\"\n                    className=\"password\"\n                    type=\"password\"\n                    value={pass}\n                    fullWidth\n                    label={isEdit ? 'Password (empty if no change)' : 'Password *'}\n                    name=\"password\"\n                    id=\"password\"\n                    onChange={(e) => setPass(e.target.value)}\n                />\n                <FormControlLabel\n                    control={\n                        <Switch\n                            checked={admin}\n                            className=\"admin-rights\"\n                            onChange={(e) => setAdmin(e.target.checked)}\n                            value=\"admin\"\n                        />\n                    }\n                    label=\"has administrator rights\"\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip\n                    placement={'bottom-start'}\n                    title={\n                        namePresent\n                            ? passPresent\n                                ? ''\n                                : 'password is required'\n                            : 'username is required'\n                    }>\n                    <div>\n                        <Button\n                            className=\"save-create\"\n                            disabled={!passPresent || !namePresent}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            {isEdit ? 'Save' : 'Create'}\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\nexport default AddEditUserDialog;\n"
  },
  {
    "path": "ui/src/user/Login.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Grid from '@mui/material/Grid';\nimport TextField from '@mui/material/TextField';\nimport React from 'react';\nimport Container from '../common/Container';\nimport DefaultPage from '../common/DefaultPage';\nimport * as config from '../config';\nimport RegistrationDialog from './Register';\nimport {useStores} from '../stores';\nimport {observer} from 'mobx-react-lite';\nimport {useNavigate} from 'react-router';\n\nconst Login = observer(() => {\n    const [username, setUsername] = React.useState('');\n    const [password, setPassword] = React.useState('');\n    const [registerDialog, setRegisterDialog] = React.useState(false);\n    const {currentUser} = useStores();\n    const navigate = useNavigate();\n    React.useEffect(() => {\n        if (currentUser.loggedIn) {\n            navigate('/');\n        }\n    }, [currentUser.loggedIn]);\n    const registerButton = () => {\n        if (config.get('register'))\n            return (\n                <Button\n                    id=\"register\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                    onClick={() => setRegisterDialog(true)}>\n                    Register\n                </Button>\n            );\n        else return null;\n    };\n    const login = (e: React.MouseEvent<HTMLButtonElement>) => {\n        e.preventDefault();\n        currentUser.login(username, password);\n    };\n    return (\n        <DefaultPage title=\"Login\" rightControl={registerButton()} maxWidth={250}>\n            <Grid size={{xs: 12}} style={{textAlign: 'center'}}>\n                <Container>\n                    <form onSubmit={(e) => e.preventDefault()} id=\"login-form\">\n                        <TextField\n                            autoFocus\n                            id=\"username\"\n                            className=\"name\"\n                            label=\"Username\"\n                            name=\"username\"\n                            margin=\"dense\"\n                            autoComplete=\"username\"\n                            value={username}\n                            onChange={(e) => setUsername(e.target.value)}\n                        />\n                        <TextField\n                            id=\"password\"\n                            type=\"password\"\n                            className=\"password\"\n                            label=\"Password\"\n                            name=\"password\"\n                            margin=\"normal\"\n                            autoComplete=\"current-password\"\n                            value={password}\n                            onChange={(e) => setPassword(e.target.value)}\n                        />\n                        <Button\n                            type=\"submit\"\n                            variant=\"contained\"\n                            size=\"large\"\n                            className=\"login\"\n                            color=\"primary\"\n                            disabled={\n                                !!currentUser.connectionErrorMessage || currentUser.authenticating\n                            }\n                            style={{marginTop: 15, marginBottom: 5}}\n                            loading={currentUser.authenticating}\n                            onClick={login}>\n                            Login\n                        </Button>\n                    </form>\n                </Container>\n            </Grid>\n            {registerDialog && (\n                <RegistrationDialog\n                    fClose={() => setRegisterDialog(false)}\n                    fOnSubmit={currentUser.register}\n                />\n            )}\n        </DefaultPage>\n    );\n});\n\nexport default Login;\n"
  },
  {
    "path": "ui/src/user/Register.tsx",
    "content": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/material/DialogActions';\nimport DialogContent from '@mui/material/DialogContent';\nimport DialogTitle from '@mui/material/DialogTitle';\nimport TextField from '@mui/material/TextField';\nimport Tooltip from '@mui/material/Tooltip';\nimport React from 'react';\n\ninterface IProps {\n    name?: string;\n    fClose: VoidFunction;\n    fOnSubmit: (name: string, pass: string) => Promise<boolean>;\n}\n\nconst RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => {\n    const [name, setName] = React.useState(initialName);\n    const [pass, setPass] = React.useState('');\n    const namePresent = name.length !== 0;\n    const passPresent = pass.length !== 0;\n\n    const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        setName(e.target.value);\n    };\n\n    const handlePassChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        setPass(e.target.value);\n    };\n\n    const submitAndClose = (): void => {\n        fOnSubmit(name, pass).then((success) => {\n            if (success) {\n                fClose();\n            }\n        });\n    };\n\n    return (\n        <Dialog\n            open={true}\n            onClose={fClose}\n            aria-labelledby=\"form-dialog-title\"\n            id=\"add-edit-user-dialog\">\n            <DialogTitle id=\"form-dialog-title\">Registration</DialogTitle>\n            <DialogContent>\n                <TextField\n                    autoFocus\n                    id=\"register-username\"\n                    margin=\"dense\"\n                    className=\"name\"\n                    label=\"Username *\"\n                    name=\"username\"\n                    value={name}\n                    autoComplete=\"username\"\n                    onChange={handleNameChange}\n                    fullWidth\n                />\n                <TextField\n                    id=\"register-password\"\n                    margin=\"dense\"\n                    className=\"password\"\n                    type=\"password\"\n                    value={pass}\n                    fullWidth\n                    label=\"Password *\"\n                    name=\"password\"\n                    autoComplete=\"new-password\"\n                    onChange={handlePassChange}\n                />\n            </DialogContent>\n            <DialogActions>\n                <Button onClick={fClose}>Cancel</Button>\n                <Tooltip\n                    placement={'bottom-start'}\n                    title={\n                        namePresent\n                            ? passPresent\n                                ? ''\n                                : 'password is required'\n                            : 'username is required'\n                    }>\n                    <div>\n                        <Button\n                            className=\"save-create\"\n                            disabled={!passPresent || !namePresent}\n                            onClick={submitAndClose}\n                            color=\"primary\"\n                            variant=\"contained\">\n                            Register\n                        </Button>\n                    </div>\n                </Tooltip>\n            </DialogActions>\n        </Dialog>\n    );\n};\nexport default RegistrationDialog;\n"
  },
  {
    "path": "ui/src/user/UserStore.ts",
    "content": "import {BaseStore} from '../common/BaseStore';\nimport axios from 'axios';\nimport * as config from '../config';\nimport {action} from 'mobx';\nimport {SnackReporter} from '../snack/SnackManager';\nimport {IUser} from '../types';\n\nexport class UserStore extends BaseStore<IUser> {\n    constructor(private readonly snack: SnackReporter) {\n        super();\n    }\n\n    protected requestItems = (): Promise<IUser[]> =>\n        axios.get<IUser[]>(`${config.get('url')}user`).then((response) => response.data);\n\n    protected requestDelete(id: number): Promise<void> {\n        return axios\n            .delete(`${config.get('url')}user/${id}`)\n            .then(() => this.snack('User deleted'));\n    }\n\n    @action\n    public create = async (name: string, pass: string, admin: boolean) => {\n        await axios.post(`${config.get('url')}user`, {name, pass, admin});\n        await this.refresh();\n        this.snack('User created');\n    };\n\n    @action\n    public update = async (id: number, name: string, pass: string | null, admin: boolean) => {\n        await axios.post(config.get('url') + 'user/' + id, {name, pass, admin});\n        await this.refresh();\n        this.snack('User updated');\n    };\n}\n"
  },
  {
    "path": "ui/src/user/Users.tsx",
    "content": "import Grid from '@mui/material/Grid';\nimport IconButton from '@mui/material/IconButton';\nimport Paper from '@mui/material/Paper';\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableCell from '@mui/material/TableCell';\nimport TableHead from '@mui/material/TableHead';\nimport TableRow from '@mui/material/TableRow';\nimport Delete from '@mui/icons-material/Delete';\nimport Edit from '@mui/icons-material/Edit';\nimport React from 'react';\nimport ConfirmDialog from '../common/ConfirmDialog';\nimport DefaultPage from '../common/DefaultPage';\nimport Button from '@mui/material/Button';\nimport AddEditDialog from './AddEditUserDialog';\nimport {IUser} from '../types';\nimport {useStores} from '../stores';\nimport {observer} from 'mobx-react-lite';\n\ninterface IRowProps {\n    name: string;\n    admin: boolean;\n    fDelete: VoidFunction;\n    fEdit: VoidFunction;\n}\n\nconst UserRow: React.FC<IRowProps> = ({name, admin, fDelete, fEdit}) => (\n    <TableRow>\n        <TableCell>{name}</TableCell>\n        <TableCell>{admin ? 'Yes' : 'No'}</TableCell>\n        <TableCell align=\"right\" padding=\"none\">\n            <IconButton onClick={fEdit} className=\"edit\" size=\"large\">\n                <Edit />\n            </IconButton>\n            <IconButton onClick={fDelete} className=\"delete\" size=\"large\">\n                <Delete />\n            </IconButton>\n        </TableCell>\n    </TableRow>\n);\n\nconst Users = observer(() => {\n    const [deleteUser, setDeleteUser] = React.useState<IUser>();\n    const [editUser, setEditUser] = React.useState<IUser>();\n    const [createDialog, setCreateDialog] = React.useState(false);\n    const {userStore} = useStores();\n    React.useEffect(() => void userStore.refresh(), []);\n    const users = userStore.getItems();\n    return (\n        <DefaultPage\n            title=\"Users\"\n            rightControl={\n                <Button\n                    id=\"create-user\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                    onClick={() => setCreateDialog(true)}>\n                    Create User\n                </Button>\n            }>\n            <Grid size={{xs: 12}}>\n                <Paper elevation={6} style={{overflowX: 'auto'}}>\n                    <Table id=\"user-table\">\n                        <TableHead>\n                            <TableRow style={{textAlign: 'center'}}>\n                                <TableCell>Username</TableCell>\n                                <TableCell>Admin</TableCell>\n                                <TableCell />\n                            </TableRow>\n                        </TableHead>\n                        <TableBody>\n                            {users.map((user: IUser) => (\n                                <UserRow\n                                    key={user.id}\n                                    name={user.name}\n                                    admin={user.admin}\n                                    fDelete={() => setDeleteUser(user)}\n                                    fEdit={() => setEditUser(user)}\n                                />\n                            ))}\n                        </TableBody>\n                    </Table>\n                </Paper>\n            </Grid>\n            {createDialog && (\n                <AddEditDialog fClose={() => setCreateDialog(false)} fOnSubmit={userStore.create} />\n            )}\n            {editUser && (\n                <AddEditDialog\n                    fClose={() => setEditUser(undefined)}\n                    fOnSubmit={userStore.update.bind(this, editUser.id)}\n                    name={editUser.name}\n                    admin={editUser.admin}\n                    isEdit={true}\n                />\n            )}\n            {deleteUser && (\n                <ConfirmDialog\n                    title=\"Confirm Delete\"\n                    text={'Delete ' + deleteUser.name + '?'}\n                    fClose={() => setDeleteUser(undefined)}\n                    fOnSubmit={() => userStore.remove(deleteUser.id)}\n                />\n            )}\n        </DefaultPage>\n    );\n});\n\nexport default Users;\n"
  },
  {
    "path": "ui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"allowImportingTsExtensions\": true,\n\n    \"sourceMap\": true,\n    \"allowJs\": true,\n    \"jsx\": \"react\",\n    \"moduleResolution\": \"node\",\n    \"rootDir\": \"src\",\n    \"forceConsistentCasingInFileNames\": true,\n    \"noImplicitReturns\": true,\n    \"noImplicitThis\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"noUnusedLocals\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"module\": \"esnext\",\n    \"resolveJsonModule\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"exclude\": [\n    \"node_modules\",\n    \"build\",\n    \"scripts\",\n    \"acceptance-tests\",\n    \"webpack\",\n    \"jest\",\n    \"src/setupTests.ts\"\n  ],\n  \"include\": [\n    \"src\"\n  ]\n}\n"
  },
  {
    "path": "ui/tsconfig.prod.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\"\n}"
  },
  {
    "path": "ui/tsconfig.test.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\"\n  }\n}"
  },
  {
    "path": "ui/vite-env.d.ts",
    "content": "// Example: vite-env.d.ts\n/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "ui/vite.config.ts",
    "content": "import {defineConfig} from 'vite';\nimport react from '@vitejs/plugin-react';\n\nconst GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80';\n\nexport default defineConfig({\n    base: './',\n    build: {\n        outDir: 'build',\n        emptyOutDir: true,\n        sourcemap: false,\n        assetsDir: 'static',\n    },\n    plugins: [react()],\n    define: {\n        // Some libraries use the global object, even though it doesn't exist in the browser.\n        // Alternatively, we could add `<script>window.global = window;</script>` to index.html.\n        // https://github.com/vitejs/vite/discussions/5912\n        global: {},\n    },\n    server: {\n        host: '0.0.0.0',\n        proxy: {\n            '^/(application|message|client|current|user|plugin|version|image)': {\n                target: `http://localhost:${GOTIFY_SERVER_PORT}/`,\n                changeOrigin: true,\n                secure: false,\n            },\n            '/stream': {\n                target: `ws://localhost:${GOTIFY_SERVER_PORT}/`,\n                ws: true,\n                rewriteWsOrigin: true,\n            },\n        },\n        cors: false,\n    },\n});\n"
  },
  {
    "path": "ui/vitest.config.js",
    "content": "import {defineConfig} from 'vitest/config';\n\nconst timeout = process.env.CI === 'true' ? 60000 : 30000;\n\nexport default defineConfig({\n    test: {\n        testTimeout: timeout,\n        hookTimeout: timeout,\n    },\n});\n"
  }
]