Full Code of gotify/server for AI

master 061053711ffa cached
216 files
670.4 KB
180.5k tokens
914 symbols
1 requests
Download .txt
Showing preview only (720K chars total). Download the full file or copy to clipboard to get everything.
Repository: gotify/server
Branch: master
Commit: 061053711ffa
Files: 216
Total size: 670.4 KB

Directory structure:
gitextract_5ctt_wna/

├── .dockerignore
├── .editorconfig
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── feature_request.md
│   │   └── questions.md
│   └── workflows/
│       └── build.yml
├── .gitignore
├── .golangci.yml
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── GO_VERSION
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── api/
│   ├── application.go
│   ├── application_test.go
│   ├── client.go
│   ├── client_test.go
│   ├── errorHandling.go
│   ├── errorHandling_test.go
│   ├── health.go
│   ├── health_test.go
│   ├── internalutil.go
│   ├── message.go
│   ├── message_test.go
│   ├── plugin.go
│   ├── plugin_test.go
│   ├── stream/
│   │   ├── client.go
│   │   ├── once.go
│   │   ├── once_test.go
│   │   ├── stream.go
│   │   └── stream_test.go
│   ├── tokens.go
│   ├── tokens_test.go
│   ├── user.go
│   └── user_test.go
├── app.go
├── auth/
│   ├── authentication.go
│   ├── authentication_test.go
│   ├── cors.go
│   ├── cors_test.go
│   ├── password/
│   │   ├── password.go
│   │   └── password_test.go
│   ├── token.go
│   ├── token_test.go
│   ├── util.go
│   └── util_test.go
├── config/
│   ├── config.go
│   └── config_test.go
├── config.example.yml
├── database/
│   ├── application.go
│   ├── application_test.go
│   ├── client.go
│   ├── client_test.go
│   ├── database.go
│   ├── database_test.go
│   ├── message.go
│   ├── message_test.go
│   ├── migration_test.go
│   ├── ping.go
│   ├── ping_test.go
│   ├── plugin.go
│   ├── plugin_test.go
│   ├── user.go
│   └── user_test.go
├── docker/
│   └── Dockerfile
├── docs/
│   ├── package.go
│   ├── spec.json
│   ├── swagger.go
│   ├── swagger_test.go
│   ├── ui.go
│   └── ui_test.go
├── error/
│   ├── handler.go
│   ├── handler_test.go
│   ├── notfound.go
│   └── notfound_test.go
├── fracdex/
│   ├── fracdex.go
│   └── fracdex_test.go
├── go.mod
├── go.sum
├── mode/
│   ├── mode.go
│   └── mode_test.go
├── model/
│   ├── application.go
│   ├── client.go
│   ├── error.go
│   ├── health.go
│   ├── message.go
│   ├── paging.go
│   ├── pluginconf.go
│   ├── user.go
│   └── version.go
├── plugin/
│   ├── compat/
│   │   ├── instance.go
│   │   ├── plugin.go
│   │   ├── plugin_test.go
│   │   ├── v1.go
│   │   ├── v1_test.go
│   │   ├── wrap.go
│   │   ├── wrap_test.go
│   │   ├── wrap_test_norace.go
│   │   └── wrap_test_race.go
│   ├── example/
│   │   ├── clock/
│   │   │   └── main.go
│   │   ├── echo/
│   │   │   └── echo.go
│   │   └── minimal/
│   │       └── main.go
│   ├── manager.go
│   ├── manager_test.go
│   ├── manager_test_norace.go
│   ├── manager_test_race.go
│   ├── messagehandler.go
│   ├── pluginenabled.go
│   ├── pluginenabled_test.go
│   ├── storagehandler.go
│   └── testing/
│       ├── broken/
│       │   ├── cantinstantiate/
│       │   │   └── main.go
│       │   ├── malformedconstructor/
│       │   │   └── main.go
│       │   ├── noinstance/
│       │   │   └── main.go
│       │   ├── nothing/
│       │   │   └── main.go
│       │   └── unknowninfo/
│       │       └── main.go
│       └── mock/
│           └── mock.go
├── renovate.json
├── router/
│   ├── router.go
│   └── router_test.go
├── runner/
│   ├── runner.go
│   ├── umask.go
│   └── umask_fallback.go
├── test/
│   ├── asserts.go
│   ├── asserts_test.go
│   ├── assets/
│   │   ├── image-header-with.html
│   │   └── text.txt
│   ├── auth.go
│   ├── auth_test.go
│   ├── filepath.go
│   ├── filepath_test.go
│   ├── testdb/
│   │   ├── database.go
│   │   └── database_test.go
│   ├── tmpdir.go
│   ├── tmpdir_test.go
│   ├── token.go
│   └── token_test.go
└── ui/
    ├── .gitignore
    ├── .prettierrc
    ├── .yarnrc
    ├── eslint.config.mjs
    ├── index.html
    ├── package.json
    ├── public/
    │   ├── manifest.json
    │   └── static/
    │       └── notification.ogg
    ├── serve.go
    ├── src/
    │   ├── CurrentUser.ts
    │   ├── apiAuth.ts
    │   ├── application/
    │   │   ├── AddApplicationDialog.tsx
    │   │   ├── AppStore.ts
    │   │   ├── Applications.tsx
    │   │   └── UpdateApplicationDialog.tsx
    │   ├── client/
    │   │   ├── AddClientDialog.tsx
    │   │   ├── ClientStore.ts
    │   │   ├── Clients.tsx
    │   │   └── UpdateClientDialog.tsx
    │   ├── common/
    │   │   ├── BaseStore.ts
    │   │   ├── ConfirmDialog.tsx
    │   │   ├── ConnectionErrorBanner.tsx
    │   │   ├── Container.tsx
    │   │   ├── CopyableSecret.tsx
    │   │   ├── DefaultPage.tsx
    │   │   ├── LastUsedCell.tsx
    │   │   ├── LoadingSpinner.tsx
    │   │   ├── Markdown.tsx
    │   │   ├── NumberField.tsx
    │   │   ├── ScrollUpButton.tsx
    │   │   ├── SettingsDialog.tsx
    │   │   └── TimeAgoFormatter.ts
    │   ├── config.ts
    │   ├── index.tsx
    │   ├── layout/
    │   │   ├── Header.tsx
    │   │   ├── Layout.tsx
    │   │   ├── Navigation.tsx
    │   │   └── theme.ts
    │   ├── message/
    │   │   ├── Message.tsx
    │   │   ├── Messages.tsx
    │   │   ├── MessagesStore.ts
    │   │   ├── PushMessageDialog.tsx
    │   │   ├── WebSocketStore.ts
    │   │   └── extras.ts
    │   ├── plugin/
    │   │   ├── PluginDetailView.tsx
    │   │   ├── PluginStore.ts
    │   │   └── Plugins.tsx
    │   ├── react-app-env.d.ts
    │   ├── reactions.ts
    │   ├── registerServiceWorker.ts
    │   ├── snack/
    │   │   ├── SnackManager.ts
    │   │   └── browserNotification.ts
    │   ├── stores.tsx
    │   ├── tests/
    │   │   ├── application.test.ts
    │   │   ├── authentication.ts
    │   │   ├── client.test.ts
    │   │   ├── message.test.ts
    │   │   ├── plugin.test.ts
    │   │   ├── selector.ts
    │   │   ├── setup.ts
    │   │   ├── user.test.ts
    │   │   └── utils.ts
    │   ├── typedef/
    │   │   ├── notifyjs.d.ts
    │   │   └── react-timeago.d.ts
    │   ├── types.ts
    │   └── user/
    │       ├── AddEditUserDialog.tsx
    │       ├── Login.tsx
    │       ├── Register.tsx
    │       ├── UserStore.ts
    │       └── Users.tsx
    ├── tsconfig.json
    ├── tsconfig.prod.json
    ├── tsconfig.test.json
    ├── vite-env.d.ts
    ├── vite.config.ts
    └── vitest.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
vendor/
.idea/
build/
licenses/
coverage.txt
data/
images/
.git/
*/node_modules/


================================================
FILE: .editorconfig
================================================
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_size = 4
trim_trailing_whitespace = true

[*.go]
indent_style = tab

[*.{js,ts,tsx}]
indent_style = space
quote_type = single

[*.json]
indent_style = space

[*.html]
indent_style = space

[*.md]
indent_style = space
trim_trailing_whitespace = false

[Makefile]
indent_style = tab


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: jmattheis
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: https://jmattheis.de/donate


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Found a bug? Tell us and help us improve
title: ''
labels: a:bug
assignees: ''

---

**Can the issue be reproduced with the latest available release? (y/n)**

**Which one is the environment gotify server is running in?**
- [ ] Docker
- [ ] Linux machine
- [ ] Windows machine
<details><summary>Docker startup command or config file here (please mask sensitive information)</summary><br>

```

```
</details>

**Do you have an reverse proxy installed in front of gotify server? (Please select None if the problem can be reproduced without the presense of a reverse proxy)**
- [ ] None
- [ ] Nginx
- [ ] Apache
- [ ] Caddy
<details><summary>Reverse proxy configuration (please mask sensitive information)</summary><br>

```

```
</details>

**On which client do you experience problems? (Select as many as you can see)**
- [ ] WebUI
- [ ] gotify-cli
- [ ] Android Client <!-- (Please open the issue in gotify/android instead if it is only related to the android client) -->
- [ ] 3rd-party API call (Please include your code)


**What did you do?**

**What did you expect to see?**

**What did you see instead? (Include screenshots, android logcat/request dumps if possible)**


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: a:feature
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/ISSUE_TEMPLATE/questions.md
================================================
---
name: Questions
about: Having difficulties with gotify? Feel free to ask here
title: ''
labels: question
assignees: ''

---

<!-- 
Alternative ways to get help:
Official documentation - https://gotify.net/
Community chat - https://matrix.to/#/#gotify:matrix.org
-->

**Have you read the documentation?**
- [ ] Yes, but it does not include related information regarding my question.
- [ ] Yes, but the steps described in the documentation do not work on my machine.
- [ ] Yes, but I am having difficulty understanding it and want clarification.

**You are setting up gotify in**
- [ ] Docker
- [ ] Linux native platform
- [ ] Windows native platform


**Describe your problem**
<!-- EXAMPLE
I'm having difficulties setting up my apache reverse proxy
....
my config is ...
-->



**Any errors, logs, or other information that might help us identify your problem**

Ex: `docker-compose.yml`, `nginx.conf`, android logcat, browser requests, etc.

<details><summary>Name of the information here</summary><br><pre>

contents here

</pre></details>


================================================
FILE: .github/workflows/build.yml
================================================
name: build
on: [push, pull_request]

jobs:
  gotify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-go@v6
        with:
          go-version: 1.26.x
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
      - uses: actions/checkout@v6
      - run: (cd ui && yarn)
      - run: make build-js
      - uses: golangci/golangci-lint-action@v9
        with:
          version: v2.11.3
          args: --timeout=5m
          skip-cache: true
      - run: go mod download
      - run: make download-tools
      - run: make test
      - run: make check-ci
      - uses: codecov/codecov-action@v5
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
      - if: startsWith(github.ref, 'refs/tags/v')
        run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV
      - if: startsWith(github.ref, 'refs/tags/v')
        run: |
          export LD_FLAGS="-w -s -X main.Version=$VERSION -X main.BuildDate=$(date "+%F-%T") -X main.Commit=$(git rev-parse --verify HEAD) -X main.Mode=prod"
          echo "LD_FLAGS=$LD_FLAGS" >> $GITHUB_ENV

          make build
          sudo chown -R $UID build
          make package-zip
          ls -lath build
      - if: startsWith(github.ref, 'refs/tags/v')
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - if: startsWith(github.ref, 'refs/tags/v')
        name: Set up QEMU
        uses: docker/setup-qemu-action@v4
      - if: startsWith(github.ref, 'refs/tags/v')
        uses: docker/login-action@v4
        with:
          username: ${{ secrets.DOCKER_USER }}
          password: ${{ secrets.DOCKER_PASS }}
      - if: startsWith(github.ref, 'refs/tags/v')
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ secrets.DOCKER_GHCR_USER }}
          password: ${{ secrets.DOCKER_GHCR_PASS }}
      - if: startsWith(github.ref, 'refs/tags/v')
        run: |
          make DOCKER_BUILD_PUSH=true build-docker
      - if: startsWith(github.ref, 'refs/tags/v')
        uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: build/*.zip
          tag: ${{ github.ref }}
          overwrite: true
          file_glob: true


================================================
FILE: .gitignore
================================================
vendor/
.idea/
build/
certs/
build/
licenses/
coverage.txt
*/node_modules/
**/*-packr.go
config.yml
data/
images/

================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
  enable:
    - asciicheck
    - copyloopvar
    - godot
    - gomodguard
    - goprintffuncname
    - misspell
    - nakedret
    - nolintlint
    - sqlclosecheck
    - staticcheck
    - unconvert
    - whitespace
  disable:
    - err113
    - errcheck
    - funlen
    - gochecknoglobals
    - gocognit
    - goconst
    - gocyclo
    - godox
    - lll
    - nestif
    - nlreturn
    - noctx
    - testpackage
    - wsl
  settings:
    misspell:
      locale: US
  exclusions:
    generated: lax
    presets:
      - comments
      - common-false-positives
      - legacy
      - std-error-handling
    paths:
      - plugin/example
      - plugin/testing
      - third_party$
      - builtin$
      - examples$
formatters:
  enable:
    - gofmt
    - gofumpt
    - goimports
  settings:
    gofumpt:
      extra-rules: true
  exclusions:
    generated: lax
    paths:
      - plugin/example
      - plugin/testing
      - third_party$
      - builtin$
      - examples$


================================================
FILE: CODEOWNERS
================================================
* @gotify/committers

================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at gotify@protonmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]

[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing

Thanks for your interest in Gotify!

First of all, please note that we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 

If you have any questions you can join the chat on [#gotify:matrix.org](https://matrix.to/#/#gotify:matrix.org).

## Where to Contribute

|                                                         Repository|                                   Description|               Technology|
|                                                                ---|                                           ---|                      ---|
|[gotify/server](https://github.com/gotify/server)                  |server implementation and WebUI code          |`Go` `Typescript` `React`|
|[gotify/android](https://github.com/gotify/android)                |android client implementation                 |`Java` `Android`         |
|[gotify/plugin-template](https://github.com/gotify/plugin-template)|official gotify plugin template               |`Go`                     |
|[gotify/cli](https://github.com/gotify/cli)                        |official CLI client                           |`Go`                     |
|[gotify/website](https://github.com/gotify/website)                |documentaion [gotify.net](https://gotify.net/)|`Markdown` `Docusaurus`  |
|[gotify/contrib](https://github.com/gotify/contrib)                |community-contributed projects                |`misc`                   |    

## Ways to Contribute

### Document Refinements

_Keywords: **Documentation**, **Writing**_

Documents are residing in the [gotify/website](https://github.com/gotify/website) repository. Open an issue or PR and indicate the part of the document you are working on or the information you want to add to the document.

### Feature Request and implementation

_Keywords: **Features**, **Coding**_

When proposing features to gotify/\*, please first discuss the change you wish to make via issue, chat or any other method with the maintainers.

After the feature request is approved, file an issue or comment under the existing one indicating whether you want to submit the implementation yourself. If you decided not to, the maintainers would evaluate the necessity and urgency of the feature and decide whether to wait for another contributor to claim the request or commit an implementation himself/herself.

### Bug Reports and Fixes

_Keywords: **Bug Hunt**, **Coding**_

If you are not sure if the problem you are facing is indeed a bug, we recommend discussing it in the [community chat]((https://matrix.to/#/#gotify:matrix.org)) first, opening an issue is also welcome.

After the bug is confirmed, please file a new or comment under the existing issue describing the bug and indicate whether you want to sumbit the fix yourself.

If you want to submit a fix to an already confirmed issue, please indicate that you wish to submit a PR in a comment before starting your work.

### Community Contribution Projects

_Keywords:_ **Features**, **Coding**, **Writing**

Make gotify more powerful and easy-to-use than ever by:
 - writing a [plugin](https://gotify.net/docs/plugin)
 - writing a client (smartphones, Windows, Linux, Browser Add-on, etc.)
 - writing about how you have used gotify for your applications
 
Also, after you have finished, consider submitting your hard work to the community contributions [repository](https://github.com/gotify/contrib) so that more users can make a use of it.


================================================
FILE: GO_VERSION
================================================
1.26.0


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2018 jmattheis

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

The Gotify logo is licensed under the Creative Commons Attribution 4.0 International Public License.
http://creativecommons.org/licenses/by/4.0/

================================================
FILE: Makefile
================================================
LICENSE_DIR=./licenses/
BUILD_DIR=./build
DOCKER_DIR=./docker/
SHELL := /bin/bash
GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//')
DOCKER_BUILD_IMAGE=docker.io/gotify/build
DOCKER_WORKDIR=/proj
DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR}
DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS"
DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build

test: test-coverage test-js
check: check-go check-swagger check-js
check-ci: check-swagger check-js

require-version:
	if [ -n ${VERSION} ] && [[ $$VERSION == "v"* ]]; then echo "The version may not start with v" && exit 1; fi
	if [ -z ${VERSION} ]; then echo "Need to set VERSION" && exit 1; fi;

test-coverage:
	go test --race -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./...

format:
	goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*")

test-js:
	go build -ldflags="-s -w -X main.Mode=prod" -o removeme/gotify app.go
	(cd ui && CI=true GOTIFY_EXE=../removeme/gotify yarn test)
	rm -rf removeme

check-go:
	golangci-lint run

check-js:
	(cd ui && yarn lint)
	(cd ui && yarn testformat)

download-tools:
	go install github.com/go-swagger/go-swagger/cmd/swagger@717e3cb29becaaf00e56953556c6d80f8a01b286

update-swagger:
	swagger generate spec --scan-models -o docs/spec.json
	sed -i 's/"uint64"/"int64"/g' docs/spec.json

check-swagger: update-swagger
## add the docs to git, this changes line endings in git, otherwise this does not work on windows
	git add docs
	if [ -n "$(shell git status --porcelain | grep docs)" ]; then \
        echo Swagger Spec is not up-to-date; \
        exit 1; \
    fi

extract-licenses:
	mkdir ${LICENSE_DIR} || true
	for LICENSE in $(shell find vendor/* -name LICENSE); do \
		DIR=`echo $$LICENSE | tr "/" _ | sed -e 's/vendor_//; s/_LICENSE//'` ; \
        cp $$LICENSE ${LICENSE_DIR}$$DIR ; \
    done

package-zip: extract-licenses
	for BUILD in $(shell find ${BUILD_DIR}/*); do \
       zip -j $$BUILD.zip $$BUILD ./LICENSE; \
       zip -ur $$BUILD.zip ${LICENSE_DIR}; \
    done

build-docker-multiarch: require-version
	docker buildx build --sbom=true --provenance=true \
		$(if $(DOCKER_BUILD_PUSH),--push) \
		--label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \
		--label org.opencontainers.image.version=$(VERSION) \
		--label org.opencontainers.image.created=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) \
		-t gotify/server:latest \
		-t gotify/server:${VERSION} \
		-t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \
	    -t ghcr.io/gotify/server:latest \
		-t ghcr.io/gotify/server:${VERSION} \
		-t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t ghcr.io/gotify/server:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t gotify/server-arm64:latest \
		-t gotify/server-arm64:${VERSION} \
		-t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t ghcr.io/gotify/server-arm64:latest \
		-t ghcr.io/gotify/server-arm64:${VERSION} \
		-t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t ghcr.io/gotify/server-arm64:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t gotify/server-arm7:latest \
		-t gotify/server-arm7:${VERSION} \
		-t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t ghcr.io/gotify/server-arm7:latest \
		-t ghcr.io/gotify/server-arm7:${VERSION} \
		-t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t ghcr.io/gotify/server-arm7:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t gotify/server-riscv64:latest \
		-t gotify/server-riscv64:${VERSION} \
		-t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		-t ghcr.io/gotify/server-riscv64:latest \
		-t ghcr.io/gotify/server-riscv64:${VERSION} \
		-t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -2) \
		-t ghcr.io/gotify/server-riscv64:$(shell echo $(VERSION) | cut -d '.' -f -1) \
		--build-arg RUN_TESTS=$(DOCKER_TEST_LEVEL) \
		--build-arg GO_VERSION=$(GO_VERSION) \
		--build-arg LD_FLAGS="$$LD_FLAGS" \
		--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/riscv64 \
		-f docker/Dockerfile .

build-docker: build-docker-multiarch

_build_within_docker: OUTPUT = gotify-app
_build_within_docker:
	${DOCKER_GO_BUILD} -o ${OUTPUT}

build-js:
	(cd ui && yarn build)

build-linux-amd64:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64

build-linux-386:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-386

build-linux-arm-7:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm-7 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm-7

build-linux-arm64:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-arm64

build-linux-riscv64:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-riscv64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-riscv64

build-windows-amd64:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-amd64.exe

build-windows-386:
	${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-windows-386 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-windows-386.exe

build: build-linux-arm-7 build-linux-amd64 build-linux-386 build-linux-arm64 build-linux-riscv64 build-windows-amd64 build-windows-386

.PHONY: test-coverage test check-go check-js verify-swagger check download-tools update-swagger package-zip build-docker build-js build


================================================
FILE: README.md
================================================
<p align="center">
    <a href="https://github.com/gotify/logo">
        <img height="275px" src="https://raw.githubusercontent.com/gotify/logo/master/gotify-logo.png" />
    </a>
</p>

<h1 align="center">gotify/server</h1>

<p align="center">
    <a href="https://github.com/gotify/server/actions/workflows/build.yml">
        <img alt="Build Status" src="https://github.com/gotify/server/actions/workflows/build.yml/badge.svg">
    </a>
    <a href="https://codecov.io/gh/gotify/server">
        <img alt="codecov" src="https://codecov.io/gh/gotify/server/branch/master/graph/badge.svg">
    </a>
    <a href="https://goreportcard.com/report/github.com/gotify/server">
        <img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/gotify/server">
    </a>
    <a href="https://matrix.to/#/#gotify:matrix.org">
        <img alt="Matrix" src="https://img.shields.io/matrix/gotify:matrix.org.svg">
    </a>
    <a href="https://hub.docker.com/r/gotify/server">
        <img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/gotify/server.svg">
    </a>
    <a href="https://github.com/gotify/server/releases/latest">
        <img alt="latest release" src="https://img.shields.io/github/release/gotify/server.svg">
    </a>
</p>

## Intro
We wanted a simple server for sending and receiving messages (in real time per WebSocket). For this, not many open source projects existed and most of the existing ones were abandoned. Also, a requirement was that it can be self-hosted. We know there are many free and commercial push services out there.

## Features

<img alt="Gotify UI screenshot" src="ui.png" align="right" width="500px"/>

* send messages via REST-API
* receive messages via WebSocket
* manage users, clients and applications
* [Plugins](https://gotify.net/docs/plugin)
* Web-UI -> [./ui](ui)
* CLI for sending messages -> [gotify/cli](https://github.com/gotify/cli)
* Android-App -> [gotify/android](https://github.com/gotify/android)

[<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]
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" width="150"/>][fdroid]

<sub>(Google Play and the Google Play logo are trademarks of Google LLC.)</sub>

---

**[Documentation](https://gotify.net/docs)**

[Install](https://gotify.net/docs/install) ᛫
[Configuration](https://gotify.net/docs/config) ᛫
[REST-API](https://gotify.net/api-docs) ᛫
[Setup Dev Environment](https://gotify.net/docs/dev-setup)

## Contributing

We welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## Versioning
We use [SemVer](http://semver.org/) for versioning. For the versions available, see the
[tags on this repository](https://github.com/gotify/server/tags).

## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details

 [playstore]: https://play.google.com/store/apps/details?id=com.github.gotify
 [fdroid]: https://f-droid.org/de/packages/com.github.gotify/


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Supported Versions

Only the latest version.

## Reporting a Vulnerability

Please report (suspected) security vulnerabilities to
**[gotify@protonmail.com](mailto:gotify@protonmail.com)**. You will receive a
response from us within a few days. If the issue is confirmed, we will release a
patch as soon as possible.


================================================
FILE: api/application.go
================================================
package api

import (
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/model"
	"github.com/h2non/filetype"
	"gorm.io/gorm"
)

// The ApplicationDatabase interface for encapsulating database access.
type ApplicationDatabase interface {
	CreateApplication(application *model.Application) error
	GetApplicationByToken(token string) (*model.Application, error)
	GetApplicationByID(id uint) (*model.Application, error)
	GetApplicationsByUser(userID uint) ([]*model.Application, error)
	DeleteApplicationByID(id uint) error
	UpdateApplication(application *model.Application) error
}

// The ApplicationAPI provides handlers for managing applications.
type ApplicationAPI struct {
	DB       ApplicationDatabase
	ImageDir string
}

// Application Params Model
//
// Params allowed to create or update Applications.
//
// swagger:model ApplicationParams
type ApplicationParams struct {
	// The application name. This is how the application should be displayed to the user.
	//
	// required: true
	// example: Backup Server
	Name string `form:"name" query:"name" json:"name" binding:"required"`
	// The description of the application.
	//
	// example: Backup server for the interwebs
	Description string `form:"description" query:"description" json:"description"`
	// The default priority of messages sent by this application. Defaults to 0.
	//
	// example: 5
	DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
	// The sortKey for the application. Uses fractional indexing.
	//
	// example: a1
	SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"`
}

// CreateApplication creates an application and returns the access token.
// swagger:operation POST /application application createApp
//
// Create an application.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the application to add
//	  required: true
//	  schema:
//	    $ref: "#/definitions/ApplicationParams"
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Application"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
	applicationParams := ApplicationParams{}
	if err := ctx.Bind(&applicationParams); err == nil {
		app := model.Application{
			Name:            applicationParams.Name,
			Description:     applicationParams.Description,
			DefaultPriority: applicationParams.DefaultPriority,
			SortKey:         applicationParams.SortKey,
			Token:           auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists),
			UserID:          auth.GetUserID(ctx),
			Internal:        false,
		}

		if err := a.DB.CreateApplication(&app); err != nil {
			handleApplicationError(ctx, err)
			return
		}
		ctx.JSON(200, withResolvedImage(&app))
	}
}

// GetApplications returns all applications a user has.
// swagger:operation GET /application application getApps
//
// Return all applications.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      type: array
//	      items:
//	        $ref: "#/definitions/Application"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) GetApplications(ctx *gin.Context) {
	userID := auth.GetUserID(ctx)
	apps, err := a.DB.GetApplicationsByUser(userID)
	if success := successOrAbort(ctx, 500, err); !success {
		return
	}
	for _, app := range apps {
		withResolvedImage(app)
	}
	ctx.JSON(200, apps)
}

// DeleteApplication deletes an application by its id.
// swagger:operation DELETE /application/{id} application deleteApp
//
// Delete an application.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		app, err := a.DB.GetApplicationByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if app != nil && app.UserID == auth.GetUserID(ctx) {
			if app.Internal {
				ctx.AbortWithError(400, errors.New("cannot delete internal application"))
				return
			}
			if success := successOrAbort(ctx, 500, a.DB.DeleteApplicationByID(id)); !success {
				return
			}
			if app.Image != "" {
				os.Remove(a.ImageDir + app.Image)
			}
		} else {
			ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
		}
	})
}

// UpdateApplication updates an application info by its id.
// swagger:operation PUT /application/{id} application updateApplication
//
// Update an application.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the application to update
//	  required: true
//	  schema:
//	    $ref: "#/definitions/ApplicationParams"
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Application"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		app, err := a.DB.GetApplicationByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if app != nil && app.UserID == auth.GetUserID(ctx) {
			applicationParams := ApplicationParams{}
			if err := ctx.Bind(&applicationParams); err == nil {
				app.Description = applicationParams.Description
				app.Name = applicationParams.Name
				app.DefaultPriority = applicationParams.DefaultPriority
				if applicationParams.SortKey != "" {
					app.SortKey = applicationParams.SortKey
				}

				if err := a.DB.UpdateApplication(app); err != nil {
					handleApplicationError(ctx, err)
					return
				}
				ctx.JSON(200, withResolvedImage(app))
			}
		} else {
			ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
		}
	})
}

// UploadApplicationImage uploads an image for an application.
// swagger:operation POST /application/{id}/image application uploadAppImage
//
// Upload an image for an application.
//
//	---
//	consumes:
//	- multipart/form-data
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: file
//	  in: formData
//	  description: the application image
//	  required: true
//	  type: file
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Application"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		app, err := a.DB.GetApplicationByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if app != nil && app.UserID == auth.GetUserID(ctx) {
			file, err := ctx.FormFile("file")
			if err == http.ErrMissingFile {
				ctx.AbortWithError(400, errors.New("file with key 'file' must be present"))
				return
			} else if err != nil {
				ctx.AbortWithError(500, err)
				return
			}
			head := make([]byte, 261)
			open, _ := file.Open()
			open.Read(head)
			if !filetype.IsImage(head) {
				ctx.AbortWithError(400, errors.New("file must be an image"))
				return
			}

			ext := filepath.Ext(file.Filename)
			if !ValidApplicationImageExt(ext) {
				ctx.AbortWithError(400, errors.New("invalid file extension"))
				return
			}

			name := generateNonExistingImageName(a.ImageDir, func() string {
				return generateImageName() + ext
			})

			err = ctx.SaveUploadedFile(file, a.ImageDir+name)
			if err != nil {
				ctx.AbortWithError(500, err)
				return
			}

			if app.Image != "" {
				os.Remove(a.ImageDir + app.Image)
			}

			app.Image = name
			if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
				return
			}
			ctx.JSON(200, withResolvedImage(app))
		} else {
			ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
		}
	})
}

// RemoveApplicationImage deletes an image of an application.
// swagger:operation DELETE /application/{id}/image application removeAppImage
//
// Deletes an image of an application.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		app, err := a.DB.GetApplicationByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if app != nil && app.UserID == auth.GetUserID(ctx) {
			if app.Image == "" {
				ctx.AbortWithError(400, fmt.Errorf("app with id %d does not have a customized image", id))
				return
			}

			image := app.Image
			app.Image = ""
			if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
				return
			}
			os.Remove(a.ImageDir + image)
			ctx.JSON(200, withResolvedImage(app))
		} else {
			ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id))
		}
	})
}

func withResolvedImage(app *model.Application) *model.Application {
	if app.Image == "" {
		// This must stay in sync with the isDefaultImage check in ui/src/application/Applications.tsx.
		app.Image = "static/defaultapp.png"
	} else {
		app.Image = "image/" + app.Image
	}
	return app
}

func (a *ApplicationAPI) applicationExists(token string) bool {
	app, _ := a.DB.GetApplicationByToken(token)
	return app != nil
}

func exist(path string) bool {
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return false
	}
	return true
}

func generateNonExistingImageName(imgDir string, gen func() string) string {
	for {
		name := gen()
		if !exist(imgDir + name) {
			return name
		}
	}
}

func ValidApplicationImageExt(ext string) bool {
	switch strings.ToLower(ext) {
	case ".gif", ".png", ".jpg", ".jpeg":
		return true
	default:
		return false
	}
}

func handleApplicationError(ctx *gin.Context, err error) {
	if errors.Is(err, gorm.ErrDuplicatedKey) {
		ctx.AbortWithError(400, errors.New("sort key is not unique"))
	} else {
		ctx.AbortWithError(500, err)
	}
}


================================================
FILE: api/application_test.go
================================================
package api

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
)

var (
	firstApplicationToken  = "Aaaaaaaaaaaaaaa"
	secondApplicationToken = "Abbbbbbbbbbbbbb"
	thirdApplicationToken  = "Acccccccccccccc"
)

func TestApplicationSuite(t *testing.T) {
	suite.Run(t, new(ApplicationSuite))
}

type ApplicationSuite struct {
	suite.Suite
	db       *testdb.Database
	a        *ApplicationAPI
	ctx      *gin.Context
	recorder *httptest.ResponseRecorder
}

var (
	originalGenerateApplicationToken func() string
	originalGenerateImageName        func() string
)

func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
	originalGenerateApplicationToken = generateApplicationToken
	originalGenerateImageName = generateImageName
	generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken)
	generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:])
	mode.Set(mode.TestDev)
	s.recorder = httptest.NewRecorder()
	s.db = testdb.NewDB(s.T())
	s.ctx, _ = gin.CreateTestContext(s.recorder)
	withURL(s.ctx, "http", "example.com")
	s.a = &ApplicationAPI{DB: s.db}
}

func (s *ApplicationSuite) AfterTest(suiteName, testName string) {
	generateApplicationToken = originalGenerateApplicationToken
	generateImageName = originalGenerateImageName
	s.db.Close()
}

func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name&description=description_text&sortKey=a5")
	s.a.CreateApplication(s.ctx)

	expected := &model.Application{
		ID:          1,
		Token:       firstApplicationToken,
		UserID:      5,
		Name:        "custom_name",
		Description: "description_text",
		SortKey:     "a5",
	}
	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), expected, app)
	}
}

func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() {
	actual := &model.Application{
		ID:          1,
		UserID:      2,
		Token:       "Aasdasfgeeg",
		Name:        "myapp",
		Description: "mydesc",
		Image:       "asd",
		Internal:    true,
		LastUsed:    nil,
		SortKey:     "a1",
	}
	test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`)
}

func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=&description=description_text")
	s.a.CreateApplication(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
		assert.Empty(s.T(), app)
	}
}

func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInParams() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withJSON(&model.Application{
		Name:        "name",
		Description: "description",
		ID:          333,
		Internal:    true,
		Token:       "token",
		Image:       "adfdf",
		SortKey:     "a5",
	})

	s.a.CreateApplication(s.ctx)

	expectedJSONValue, _ := json.Marshal(&model.Application{
		ID:          1,
		Token:       firstApplicationToken,
		UserID:      5,
		Name:        "name",
		Description: "description",
		Internal:    false,
		Image:       "static/defaultapp.png",
		SortKey:     "a5",
	})

	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), string(expectedJSONValue), s.recorder.Body.String())
}

func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() {
	s.db.User(2)
	s.db.User(5).App(5)

	test.WithUser(s.ctx, 2)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/5", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}

	s.a.DeleteApplication(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
	s.db.AssertAppExist(5)
}

func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name")
	s.a.CreateApplication(s.ctx)

	expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"}
	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
		assert.Contains(s.T(), app, expected)
	}
}

func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name")

	s.a.CreateApplication(s.ctx)

	expected := &model.Application{
		ID:      1,
		Token:   firstApplicationToken,
		Name:    "custom_name",
		Image:   "static/defaultapp.png",
		UserID:  5,
		SortKey: "a0",
	}
	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {
	s.db.User(5)
	s.db.User(6).AppWithToken(1, firstApplicationToken)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name")

	s.a.CreateApplication(s.ctx)

	expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"}
	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
		assert.Contains(s.T(), app, expected)
	}
}

func (s *ApplicationSuite) Test_Sorting() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=one")
	s.a.CreateApplication(s.ctx)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=two")
	s.a.CreateApplication(s.ctx)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=three")
	s.a.CreateApplication(s.ctx)

	apps, err := s.db.GetApplicationsByUser(5)
	require.NoError(s.T(), err)
	require.Len(s.T(), apps, 3)
	assert.Equal(s.T(), apps[0].Name, "one")
	assert.Equal(s.T(), apps[0].SortKey, "a0")
	assert.Equal(s.T(), apps[1].Name, "two")
	assert.Equal(s.T(), apps[1].SortKey, "a1")
	assert.Equal(s.T(), apps[2].Name, "three")
	assert.Equal(s.T(), apps[2].SortKey, "a2")

	s.withFormData("name=one&description=&sortKey=a1V")
	s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(apps[0].ID)}}
	s.a.UpdateApplication(s.ctx)

	apps, err = s.db.GetApplicationsByUser(5)
	require.NoError(s.T(), err)
	require.Len(s.T(), apps, 3)
	assert.Equal(s.T(), apps[0].Name, "two")
	assert.Equal(s.T(), apps[0].SortKey, "a1")
	assert.Equal(s.T(), apps[1].Name, "one")
	assert.Equal(s.T(), apps[1].SortKey, "a1V")
	assert.Equal(s.T(), apps[2].Name, "three")
	assert.Equal(s.T(), apps[2].SortKey, "a2")
}

func (s *ApplicationSuite) Test_GetApplications() {
	userBuilder := s.db.User(5)
	first := userBuilder.NewAppWithToken(1, "perfper")
	second := userBuilder.NewAppWithToken(2, "asdasd")

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil)

	s.a.GetApplications(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	first.Image = "static/defaultapp.png"
	second.Image = "static/defaultapp.png"
	test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)
}

func (s *ApplicationSuite) Test_GetApplications_WithImage() {
	userBuilder := s.db.User(5)
	first := userBuilder.NewAppWithToken(1, "perfper")
	second := userBuilder.NewAppWithToken(2, "asdasd")
	first.Image = "abcd.jpg"
	s.db.UpdateApplication(first)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil)

	s.a.GetApplications(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	first.Image = "image/abcd.jpg"
	second.Image = "static/defaultapp.png"
	test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder)
}

func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() {
	s.db.User(5).InternalApp(10)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "10"}}

	s.a.DeleteApplication(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "4"}}

	s.a.DeleteApplication(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_DeleteApplication() {
	s.db.User(5).App(1)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.DeleteApplication(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertAppNotExist(1)
}

func (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() {
	s.db.User(5).App(1)
	var b bytes.Buffer
	writer := multipart.NewWriter(&b)
	writer.Close()
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b)
	s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())

	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file with key 'file' must be present"))
}

func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() {
	s.db.User(5).App(1)
	var b bytes.Buffer
	writer := multipart.NewWriter(&b)
	defer writer.Close()
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &b)
	s.ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())

	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 500, s.recorder.Code)
	assert.Error(s.T(), s.ctx.Errors[0].Err, "multipart: NextPart: EOF")
}

func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() {
	s.db.User(5).App(1)

	cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
	assert.Nil(s.T(), err)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
	s.ctx.Request.Header.Set("Content-Type", cType)
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
		imgName := app.Image

		assert.Equal(s.T(), 200, s.recorder.Code)
		_, err = os.Stat(imgName)
		assert.Nil(s.T(), err)

		s.a.DeleteApplication(s.ctx)

		_, err = os.Stat(imgName)
		assert.True(s.T(), os.IsNotExist(err))
	}
}

func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() {
	existingImageName := "2lHMAel6BDHLL-HrwphcviX-l.png"
	firstGeneratedImageName := firstApplicationToken[1:] + ".png"
	secondGeneratedImageName := secondApplicationToken[1:] + ".png"
	s.db.User(5)
	s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName})

	cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
	assert.Nil(s.T(), err)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
	s.ctx.Request.Header.Set("Content-Type", cType)
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
	fakeImage(s.T(), existingImageName)
	fakeImage(s.T(), firstGeneratedImageName)

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)

	_, err = os.Stat(existingImageName)
	assert.True(s.T(), os.IsNotExist(err))

	_, err = os.Stat(secondGeneratedImageName)
	assert.Nil(s.T(), err)
	assert.Nil(s.T(), os.Remove(secondGeneratedImageName))
	assert.Nil(s.T(), os.Remove(firstGeneratedImageName))
}

func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() {
	s.db.User(5)
	s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"})

	fakeImage(s.T(), "existing.png")
	cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")})
	assert.Nil(s.T(), err)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
	s.ctx.Request.Header.Set("Content-Type", cType)
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)

	_, err = os.Stat("existing.png")
	assert.True(s.T(), os.IsNotExist(err))

	os.Remove(firstApplicationToken[1:] + ".png")
}

func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() {
	s.db.User(5).App(1)

	cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/text.txt")})
	assert.Nil(s.T(), err)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
	s.ctx.Request.Header.Set("Content-Type", cType)
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file must be an image"))
}

func (s *ApplicationSuite) Test_UploadAppImage_WithHtmlFileHavingImageHeader() {
	s.db.User(5).App(1)

	cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image-header-with.html")})
	assert.Nil(s.T(), err)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer)
	s.ctx.Request.Header.Set("Content-Type", cType)
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("invalid file extension"))
}

func (s *ApplicationSuite) Test_UploadAppImage_expectNotFound() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "4"}}

	s.a.UploadApplicationImage(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_RemoveAppImage_expectNotFound() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "4"}}

	s.a.RemoveApplicationImage(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_RemoveAppImage_noCustomizedImage() {
	s.db.User(5).App(1)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
	s.a.RemoveApplicationImage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() {
	s.db.User(5)

	imageFile := "existing.png"
	s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile})
	fakeImage(s.T(), imageFile)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
	s.a.RemoveApplicationImage(s.ctx)

	_, err := os.Stat(imageFile)
	assert.True(s.T(), os.IsNotExist(err))

	assert.Equal(s.T(), 200, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSuccess() {
	s.db.User(5).NewAppWithToken(2, "app-2")

	test.WithUser(s.ctx, 5)
	s.withFormData("name=new_name&description=new_description_text")
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.UpdateApplication(s.ctx)

	expected := &model.Application{
		ID:          2,
		Token:       "app-2",
		UserID:      5,
		Name:        "new_name",
		Description: "new_description_text",
		SortKey:     "a0",
	}

	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), expected, app)
	}
}

func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {
	s.db.User(5).NewAppWithToken(2, "app-2")

	test.WithUser(s.ctx, 5)
	s.withFormData("name=new_name")
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.UpdateApplication(s.ctx)

	expected := &model.Application{
		ID:          2,
		Token:       "app-2",
		UserID:      5,
		Name:        "new_name",
		Description: "",
		SortKey:     "a0",
	}

	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), expected, app)
	}
}

func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() {
	s.db.User(5).NewAppWithToken(2, "app-2")

	test.WithUser(s.ctx, 5)
	s.withFormData("name=name&description=&defaultPriority=4")
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.UpdateApplication(s.ctx)

	expected := &model.Application{
		ID:              2,
		Token:           "app-2",
		UserID:          5,
		Name:            "name",
		Description:     "",
		DefaultPriority: 4,
		SortKey:         "a0",
	}

	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), expected, app)
	}
}

func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() {
	app := s.db.User(5).NewAppWithToken(2, "app-2")
	app.Image = "existing.png"
	app.SortKey = "a5"
	assert.Nil(s.T(), s.db.UpdateApplication(app))

	test.WithUser(s.ctx, 5)
	s.withFormData("name=new_name")
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.UpdateApplication(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), "existing.png", app.Image)
		assert.Equal(s.T(), "a5", app.SortKey)
	}
}

func (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() {
	app := s.db.User(5).NewAppWithToken(2, "app-2")
	app.Description = "my desc"
	assert.Nil(s.T(), s.db.UpdateApplication(app))

	test.WithUser(s.ctx, 5)
	s.withFormData("name=new_name&desc=")
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.UpdateApplication(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), "", app.Description)
	}
}

func (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() {
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.UpdateApplication(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttributes_expectBadRequest() {
	test.WithUser(s.ctx, 5)
	s.a.UpdateApplication(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFound() {
	s.db.User(5).NewAppWithToken(2, "app-2")

	test.WithUser(s.ctx, 4)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.UpdateApplication(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() {
	user := s.db.User(5)
	user.App(1) // sortKey=a0
	user.App(2) // sortKey=a1

	s.withFormData("name=new_name&sortKey=a0")
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.UpdateApplication(s.ctx)

	assert.EqualError(s.T(), s.ctx.Errors[0].Err, "sort key is not unique")
	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ApplicationSuite) withFormData(formData string) {
	s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
	s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}

func (s *ApplicationSuite) withJSON(value interface{}) {
	jsonVal, _ := json.Marshal(value)
	s.ctx.Request = httptest.NewRequest("POST", "/application", bytes.NewBuffer(jsonVal))
	s.ctx.Request.Header.Set("Content-Type", "application/json")
}

// A modified version of https://stackoverflow.com/a/20397167/4244993 from Attila O.
func upload(values map[string]*os.File) (contentType string, buffer bytes.Buffer, err error) {
	w := multipart.NewWriter(&buffer)
	for key, r := range values {
		var fw io.Writer
		if fw, err = w.CreateFormFile(key, r.Name()); err != nil {
			return contentType, buffer, err
		}

		if _, err = io.Copy(fw, r); err != nil {
			return contentType, buffer, err
		}
	}
	contentType = w.FormDataContentType()
	w.Close()
	return contentType, buffer, err
}

func mustOpen(f string) *os.File {
	r, err := os.Open(f)
	if err != nil {
		panic(err)
	}
	return r
}

func fakeImage(t *testing.T, path string) {
	data, err := os.ReadFile("../test/assets/image.png")
	assert.Nil(t, err)
	// Write data to dst
	err = os.WriteFile(path, data, 0o644)
	assert.Nil(t, err)
}


================================================
FILE: api/client.go
================================================
package api

import (
	"fmt"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/model"
)

// The ClientDatabase interface for encapsulating database access.
type ClientDatabase interface {
	CreateClient(client *model.Client) error
	GetClientByToken(token string) (*model.Client, error)
	GetClientByID(id uint) (*model.Client, error)
	GetClientsByUser(userID uint) ([]*model.Client, error)
	DeleteClientByID(id uint) error
	UpdateClient(client *model.Client) error
}

// The ClientAPI provides handlers for managing clients and applications.
type ClientAPI struct {
	DB            ClientDatabase
	ImageDir      string
	NotifyDeleted func(uint, string)
}

// Client Params Model
//
// Params allowed to create or update Clients.
//
// swagger:model ClientParams
type ClientParams struct {
	// The client name
	//
	// required: true
	// example: My Client
	Name string `form:"name" query:"name" json:"name" binding:"required"`
}

// UpdateClient updates a client by its id.
// swagger:operation PUT /client/{id} client updateClient
//
// Update a client.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the client to update
//	  required: true
//	  schema:
//	    $ref: "#/definitions/ClientParams"
//	- name: id
//	  in: path
//	  description: the client id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Client"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		client, err := a.DB.GetClientByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if client != nil && client.UserID == auth.GetUserID(ctx) {
			newValues := ClientParams{}
			if err := ctx.Bind(&newValues); err == nil {
				client.Name = newValues.Name

				if success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success {
					return
				}
				ctx.JSON(200, client)
			}
		} else {
			ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id))
		}
	})
}

// CreateClient creates a client and returns the access token.
// swagger:operation POST /client client createClient
//
// Create a client.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the client to add
//	  required: true
//	  schema:
//	    $ref: "#/definitions/ClientParams"
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Client"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ClientAPI) CreateClient(ctx *gin.Context) {
	clientParams := ClientParams{}
	if err := ctx.Bind(&clientParams); err == nil {
		client := model.Client{
			Name:   clientParams.Name,
			Token:  auth.GenerateNotExistingToken(generateClientToken, a.clientExists),
			UserID: auth.GetUserID(ctx),
		}

		if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success {
			return
		}
		ctx.JSON(200, client)
	}
}

// GetClients returns all clients a user has.
// swagger:operation GET /client client getClients
//
// Return all clients.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      type: array
//	      items:
//	        $ref: "#/definitions/Client"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ClientAPI) GetClients(ctx *gin.Context) {
	userID := auth.GetUserID(ctx)
	clients, err := a.DB.GetClientsByUser(userID)
	if success := successOrAbort(ctx, 500, err); !success {
		return
	}
	ctx.JSON(200, clients)
}

// DeleteClient deletes a client by its id.
// swagger:operation DELETE /client/{id} client deleteClient
//
// Delete a client.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the client id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *ClientAPI) DeleteClient(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		client, err := a.DB.GetClientByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if client != nil && client.UserID == auth.GetUserID(ctx) {
			a.NotifyDeleted(client.UserID, client.Token)
			successOrAbort(ctx, 500, a.DB.DeleteClientByID(id))
		} else {
			ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id))
		}
	})
}

func (a *ClientAPI) clientExists(token string) bool {
	client, _ := a.DB.GetClientByToken(token)
	return client != nil
}


================================================
FILE: api/client_test.go
================================================
package api

import (
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
)

var (
	firstClientToken  = "Caaaaaaaaaaaaaa"
	secondClientToken = "Cbbbbbbbbbbbbbb"
)

func TestClientSuite(t *testing.T) {
	suite.Run(t, new(ClientSuite))
}

type ClientSuite struct {
	suite.Suite
	db       *testdb.Database
	a        *ClientAPI
	ctx      *gin.Context
	recorder *httptest.ResponseRecorder
	notified bool
}

var originalGenerateClientToken func() string

func (s *ClientSuite) BeforeTest(suiteName, testName string) {
	originalGenerateClientToken = generateClientToken
	generateClientToken = test.Tokens(firstClientToken, secondClientToken)
	mode.Set(mode.TestDev)
	s.recorder = httptest.NewRecorder()
	s.db = testdb.NewDB(s.T())
	s.ctx, _ = gin.CreateTestContext(s.recorder)
	withURL(s.ctx, "http", "example.com")
	s.notified = false
	s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify}
}

func (s *ClientSuite) notify(uint, string) {
	s.notified = true
}

func (s *ClientSuite) AfterTest(suiteName, testName string) {
	generateClientToken = originalGenerateClientToken
	s.db.Close()
}

func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
	actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"}
	test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`)
}

func (s *ClientSuite) Test_CreateClient_mapAllParameters() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name&description=description_text")

	s.a.CreateClient(s.ctx)

	expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"}
	assert.Equal(s.T(), 200, s.recorder.Code)
	if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
		assert.Contains(s.T(), clients, expected)
	}
}

func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() {
	s.db.User(5)
	test.WithUser(s.ctx, 5)

	s.withFormData("name=myclient&ID=45&Token=12341234&UserID=333")

	s.a.CreateClient(s.ctx)
	expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: "myclient"}

	assert.Equal(s.T(), 200, s.recorder.Code)
	if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
		assert.Contains(s.T(), clients, expected)
	}
}

func (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=&description=description_text")

	s.a.CreateClient(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) {
		assert.Empty(s.T(), clients)
	}
}

func (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() {
	s.db.User(5).Client(7)
	s.db.User(2)

	test.WithUser(s.ctx, 2)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/7", nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}

	s.a.DeleteClient(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
	s.db.AssertClientExist(7)
}

func (s *ClientSuite) Test_CreateClient_returnsClientWithID() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name")

	s.a.CreateClient(s.ctx)

	expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5}
	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *ClientSuite) Test_CreateClient_withExistingToken() {
	s.db.User(5).ClientWithToken(1, firstClientToken)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=custom_name")

	s.a.CreateClient(s.ctx)

	expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5}
	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *ClientSuite) Test_GetClients() {
	userBuilder := s.db.User(5)
	first := userBuilder.NewClientWithToken(1, "perfper")
	second := userBuilder.NewClientWithToken(2, "asdasd")

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil)

	s.a.GetClients(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder)
}

func (s *ClientSuite) Test_DeleteClient_expectNotFound() {
	s.db.User(5)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "8"}}

	s.a.DeleteClient(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ClientSuite) Test_DeleteClient() {
	s.db.User(5).Client(8)

	test.WithUser(s.ctx, 5)
	s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil)
	s.ctx.Params = gin.Params{{Key: "id", Value: "8"}}

	assert.False(s.T(), s.notified)

	s.a.DeleteClient(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertClientNotExist(8)
	assert.True(s.T(), s.notified)
}

func (s *ClientSuite) Test_UpdateClient_expectSuccess() {
	s.db.User(5).NewClientWithToken(1, firstClientToken)

	test.WithUser(s.ctx, 5)
	s.withFormData("name=firefox")
	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
	s.a.UpdateClient(s.ctx)

	expected := &model.Client{
		ID:     1,
		Token:  firstClientToken,
		UserID: 5,
		Name:   "firefox",
	}

	assert.Equal(s.T(), 200, s.recorder.Code)
	if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) {
		assert.Equal(s.T(), expected, client)
	}
}

func (s *ClientSuite) Test_UpdateClient_expectNotFound() {
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.UpdateClient(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() {
	test.WithUser(s.ctx, 5)
	s.a.UpdateClient(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *ClientSuite) withFormData(formData string) {
	s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
	s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}

func withURL(ctx *gin.Context, scheme, host string) {
	ctx.Set("location", &url.URL{Scheme: scheme, Host: host})
}


================================================
FILE: api/errorHandling.go
================================================
package api

import "github.com/gin-gonic/gin"

func successOrAbort(ctx *gin.Context, code int, err error) (success bool) {
	if err != nil {
		ctx.AbortWithError(code, err)
	}
	return err == nil
}


================================================
FILE: api/errorHandling_test.go
================================================
package api

import (
	"errors"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
)

func TestErrorHandling(t *testing.T) {
	rec := httptest.NewRecorder()

	ctx, _ := gin.CreateTestContext(rec)
	successOrAbort(ctx, 500, errors.New("err"))

	if rec.Code != 500 {
		t.Fail()
	}
}


================================================
FILE: api/health.go
================================================
package api

import (
	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/model"
)

// The HealthDatabase interface for encapsulating database access.
type HealthDatabase interface {
	Ping() error
}

// The HealthAPI provides handlers for the health information.
type HealthAPI struct {
	DB HealthDatabase
}

// Health returns health information.
// swagger:operation GET /health health getHealth
//
// Get health information.
//
//	---
//	produces: [application/json]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Health"
//	  500:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Health"
func (a *HealthAPI) Health(ctx *gin.Context) {
	if err := a.DB.Ping(); err != nil {
		ctx.JSON(500, model.Health{
			Health:   model.StatusOrange,
			Database: model.StatusRed,
		})
		return
	}
	ctx.JSON(200, model.Health{
		Health:   model.StatusGreen,
		Database: model.StatusGreen,
	})
}


================================================
FILE: api/health_test.go
================================================
package api

import (
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/suite"
)

func TestHealthSuite(t *testing.T) {
	suite.Run(t, new(HealthSuite))
}

type HealthSuite struct {
	suite.Suite
	db       *testdb.Database
	a        *HealthAPI
	ctx      *gin.Context
	recorder *httptest.ResponseRecorder
}

func (s *HealthSuite) BeforeTest(suiteName, testName string) {
	mode.Set(mode.TestDev)
	s.recorder = httptest.NewRecorder()
	s.db = testdb.NewDB(s.T())
	s.ctx, _ = gin.CreateTestContext(s.recorder)
	withURL(s.ctx, "http", "example.com")
	s.a = &HealthAPI{DB: s.db}
}

func (s *HealthSuite) AfterTest(suiteName, testName string) {
	s.db.Close()
}

func (s *HealthSuite) TestHealthSuccess() {
	s.a.Health(s.ctx)
	test.BodyEquals(s.T(), model.Health{Health: model.StatusGreen, Database: model.StatusGreen}, s.recorder)
}

func (s *HealthSuite) TestDatabaseFailure() {
	s.db.Close()
	s.a.Health(s.ctx)
	test.BodyEquals(s.T(), model.Health{Health: model.StatusOrange, Database: model.StatusRed}, s.recorder)
}


================================================
FILE: api/internalutil.go
================================================
package api

import (
	"errors"
	"math/bits"
	"strconv"

	"github.com/gin-gonic/gin"
)

func withID(ctx *gin.Context, name string, f func(id uint)) {
	if id, err := strconv.ParseUint(ctx.Param(name), 10, bits.UintSize); err == nil {
		f(uint(id))
	} else {
		ctx.AbortWithError(400, errors.New("invalid id"))
	}
}


================================================
FILE: api/message.go
================================================
package api

import (
	"encoding/json"
	"errors"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"github.com/gotify/location"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/model"
)

// The MessageDatabase interface for encapsulating database access.
type MessageDatabase interface {
	GetMessagesByApplicationSince(appID uint, limit int, since uint) ([]*model.Message, error)
	GetApplicationByID(id uint) (*model.Application, error)
	GetMessagesByUserSince(userID uint, limit int, since uint) ([]*model.Message, error)
	DeleteMessageByID(id uint) error
	GetMessageByID(id uint) (*model.Message, error)
	DeleteMessagesByUser(userID uint) error
	DeleteMessagesByApplication(applicationID uint) error
	CreateMessage(message *model.Message) error
	GetApplicationByToken(token string) (*model.Application, error)
}

var timeNow = time.Now

// Notifier notifies when a new message was created.
type Notifier interface {
	Notify(userID uint, message *model.MessageExternal)
}

// The MessageAPI provides handlers for managing messages.
type MessageAPI struct {
	DB       MessageDatabase
	Notifier Notifier
}

type pagingParams struct {
	Limit int  `form:"limit" binding:"min=1,max=200"`
	Since uint `form:"since" binding:"min=0"`
}

// GetMessages returns all messages from a user.
// swagger:operation GET /message message getMessages
//
// Return all messages.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: limit
//	  in: query
//	  description: the maximal amount of messages to return
//	  required: false
//	  maximum: 200
//	  minimum: 1
//	  default: 100
//	  type: integer
//	- name: since
//	  in: query
//	  description: return all messages with an ID less than this value
//	  minimum: 0
//	  required: false
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/PagedMessages"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) GetMessages(ctx *gin.Context) {
	userID := auth.GetUserID(ctx)
	withPaging(ctx, func(params *pagingParams) {
		// the +1 is used to check if there are more messages and will be removed on buildWithPaging
		messages, err := a.DB.GetMessagesByUserSince(userID, params.Limit+1, params.Since)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		ctx.JSON(200, buildWithPaging(ctx, params, messages))
	})
}

func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages {
	next := ""
	since := uint(0)
	useMessages := messages
	if len(messages) > paging.Limit {
		useMessages = messages[:len(messages)-1]
		since = useMessages[len(useMessages)-1].ID
		url := location.Get(ctx)
		url.Path = ctx.Request.URL.Path
		query := url.Query()
		query.Add("limit", strconv.Itoa(paging.Limit))
		query.Add("since", strconv.FormatUint(uint64(since), 10))
		url.RawQuery = query.Encode()
		next = url.String()
	}
	return &model.PagedMessages{
		Paging:   model.Paging{Size: len(useMessages), Limit: paging.Limit, Next: next, Since: since},
		Messages: toExternalMessages(useMessages),
	}
}

func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) {
	params := &pagingParams{Limit: 100}
	if err := ctx.MustBindWith(params, binding.Query); err == nil {
		f(params)
	}
}

// GetMessagesWithApplication returns all messages from a specific application.
// swagger:operation GET /application/{id}/message message getAppMessages
//
// Return all messages from a specific application.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	- name: limit
//	  in: query
//	  description: the maximal amount of messages to return
//	  required: false
//	  maximum: 200
//	  minimum: 1
//	  default: 100
//	  type: integer
//	- name: since
//	  in: query
//	  description: return all messages with an ID less than this value
//	  minimum: 0
//	  required: false
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/PagedMessages"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		withPaging(ctx, func(params *pagingParams) {
			app, err := a.DB.GetApplicationByID(id)
			if success := successOrAbort(ctx, 500, err); !success {
				return
			}
			if app != nil && app.UserID == auth.GetUserID(ctx) {
				// the +1 is used to check if there are more messages and will be removed on buildWithPaging
				messages, err := a.DB.GetMessagesByApplicationSince(id, params.Limit+1, params.Since)
				if success := successOrAbort(ctx, 500, err); !success {
					return
				}
				ctx.JSON(200, buildWithPaging(ctx, params, messages))
			} else {
				ctx.AbortWithError(404, errors.New("application does not exist"))
			}
		})
	})
}

// DeleteMessages delete all messages from a user.
// swagger:operation DELETE /message message deleteMessages
//
// Delete all messages.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessages(ctx *gin.Context) {
	userID := auth.GetUserID(ctx)
	successOrAbort(ctx, 500, a.DB.DeleteMessagesByUser(userID))
}

// DeleteMessageWithApplication deletes all messages from a specific application.
// swagger:operation DELETE /application/{id}/message message deleteAppMessages
//
// Delete all messages from a specific application.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the application id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessageWithApplication(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		application, err := a.DB.GetApplicationByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if application != nil && application.UserID == auth.GetUserID(ctx) {
			successOrAbort(ctx, 500, a.DB.DeleteMessagesByApplication(id))
		} else {
			ctx.AbortWithError(404, errors.New("application does not exists"))
		}
	})
}

// DeleteMessage deletes a message with an id.
// swagger:operation DELETE /message/{id} message deleteMessage
//
// Deletes a message with an id.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the message id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) DeleteMessage(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		msg, err := a.DB.GetMessageByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if msg == nil {
			ctx.AbortWithError(404, errors.New("message does not exist"))
			return
		}
		app, err := a.DB.GetApplicationByID(msg.ApplicationID)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if app != nil && app.UserID == auth.GetUserID(ctx) {
			successOrAbort(ctx, 500, a.DB.DeleteMessageByID(id))
		} else {
			ctx.AbortWithError(404, errors.New("message does not exist"))
		}
	})
}

// CreateMessage creates a message, authentication via application-token is required.
// swagger:operation POST /message message createMessage
//
// Create a message.
//
// __NOTE__: This API ONLY accepts an application token as authentication.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the message to add
//	  required: true
//	  schema:
//	    $ref: "#/definitions/Message"
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      $ref: "#/definitions/Message"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *MessageAPI) CreateMessage(ctx *gin.Context) {
	message := model.MessageExternal{}
	if err := ctx.Bind(&message); err == nil {
		application, err := a.DB.GetApplicationByToken(auth.GetTokenID(ctx))
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		message.ApplicationID = application.ID
		if strings.TrimSpace(message.Title) == "" {
			message.Title = application.Name
		}

		if message.Priority == nil {
			message.Priority = &application.DefaultPriority
		}

		message.Date = timeNow()
		message.ID = 0
		msgInternal := toInternalMessage(&message)
		if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success {
			return
		}
		a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal))
		ctx.JSON(200, toExternalMessage(msgInternal))
	}
}

func toInternalMessage(msg *model.MessageExternal) *model.Message {
	res := &model.Message{
		ID:            msg.ID,
		ApplicationID: msg.ApplicationID,
		Message:       msg.Message,
		Title:         msg.Title,
		Date:          msg.Date,
	}
	if msg.Priority != nil {
		res.Priority = *msg.Priority
	}

	if msg.Extras != nil {
		res.Extras, _ = json.Marshal(msg.Extras)
	}
	return res
}

func toExternalMessage(msg *model.Message) *model.MessageExternal {
	res := &model.MessageExternal{
		ID:            msg.ID,
		ApplicationID: msg.ApplicationID,
		Message:       msg.Message,
		Title:         msg.Title,
		Priority:      &msg.Priority,
		Date:          msg.Date,
	}
	if len(msg.Extras) != 0 {
		res.Extras = make(map[string]interface{})
		json.Unmarshal(msg.Extras, &res.Extras)
	}
	return res
}

func toExternalMessages(msg []*model.Message) []*model.MessageExternal {
	res := make([]*model.MessageExternal, len(msg))
	for i := range msg {
		res[i] = toExternalMessage(msg[i])
	}
	return res
}


================================================
FILE: api/message_test.go
================================================
package api

import (
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
)

func TestMessageSuite(t *testing.T) {
	suite.Run(t, new(MessageSuite))
}

type MessageSuite struct {
	suite.Suite
	db              *testdb.Database
	a               *MessageAPI
	ctx             *gin.Context
	recorder        *httptest.ResponseRecorder
	notifiedMessage *model.MessageExternal
}

func (s *MessageSuite) BeforeTest(suiteName, testName string) {
	mode.Set(mode.TestDev)
	s.recorder = httptest.NewRecorder()
	s.ctx, _ = gin.CreateTestContext(s.recorder)
	s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil)
	s.db = testdb.NewDB(s.T())
	s.notifiedMessage = nil
	s.a = &MessageAPI{DB: s.db, Notifier: s}
}

func (s *MessageSuite) AfterTest(string, string) {
	s.db.Close()
}

func (s *MessageSuite) Notify(userID uint, msg *model.MessageExternal) {
	s.notifiedMessage = msg
}

func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() {
	t, _ := time.Parse("2006/01/02", "2017/01/02")

	actual := &model.PagedMessages{
		Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"},
		Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{
			"test::string": "string",
			"test::array":  []interface{}{1, 2, 3},
			"test::int":    1,
			"test::float":  0.5,
		}}},
	}
	test.JSONEquals(s.T(), actual, `{"paging": {"limit":5, "since": 122, "size": 5, "next": "http://example.com/message?limit=5&since=122"},
                                              "messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z","extras":{"test::string":"string","test::array":[1,2,3],"test::int":1,"test::float":0.5}}]}`)
}

func (s *MessageSuite) Test_GetMessages() {
	user := s.db.User(5)
	first := user.App(1).NewMessage(1)
	second := user.App(2).NewMessage(2)
	firstExternal := toExternalMessage(&first)
	secondExternal := toExternalMessage(&second)

	test.WithUser(s.ctx, 5)
	s.a.GetMessages(s.ctx)

	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 100, Size: 2, Next: ""},
		Messages: []*model.MessageExternal{secondExternal, firstExternal},
	}

	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() {
	user := s.db.User(5)
	app1 := user.App(1)
	app2 := user.App(2)
	var messages []*model.Message
	for i := 100; i >= 1; i -= 2 {
		one := app2.NewMessage(uint(i))
		two := app1.NewMessage(uint(i - 1))
		messages = append(messages, &one, &two)
	}

	s.withURL("http", "example.com", "/messages", "limit=5")
	test.WithUser(s.ctx, 5)
	s.a.GetMessages(s.ctx)

	// Since: entries with ids from 100 - 96 will be returned (5 entries)
	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 5, Size: 5, Since: 96, Next: "http://example.com/messages?limit=5&since=96"},
		Messages: toExternalMessages(messages[:5]),
	}

	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() {
	user := s.db.User(5)
	app1 := user.App(1)
	app2 := user.App(2)
	var messages []*model.Message
	for i := 100; i >= 1; i -= 2 {
		one := app2.NewMessage(uint(i))
		two := app1.NewMessage(uint(i - 1))
		messages = append(messages, &one, &two)
	}

	s.withURL("http", "example.com", "/messages", "limit=13&since=55")
	test.WithUser(s.ctx, 5)
	s.a.GetMessages(s.ctx)

	// Since: entries with ids from 54 - 42 will be returned (13 entries)
	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/messages?limit=13&since=42"},
		Messages: toExternalMessages(messages[46 : 46+13]),
	}
	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() {
	s.db.User(5)
	test.WithUser(s.ctx, 5)
	s.withURL("http", "example.com", "/messages", "limit=555")
	s.a.GetMessages(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() {
	s.db.User(5)
	test.WithUser(s.ctx, 5)
	s.withURL("http", "example.com", "/messages", "limit=-5")
	s.a.GetMessages(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() {
	s.db.User(4).App(2).NewMessage(1)

	test.WithUser(s.ctx, 4)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.withURL("http", "example.com", "/messages", "limit=555")
	s.a.GetMessagesWithApplication(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *MessageSuite) Test_GetMessagesWithToken() {
	msg := s.db.User(4).App(2).NewMessage(1)

	test.WithUser(s.ctx, 4)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.GetMessagesWithApplication(s.ctx)

	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 100, Size: 1, Next: ""},
		Messages: toExternalMessages([]*model.Message{&msg}),
	}

	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext() {
	user := s.db.User(5)
	app1 := user.App(2)
	var messages []*model.Message
	for i := 100; i >= 1; i-- {
		msg := app1.NewMessage(uint(i))
		messages = append(messages, &msg)
	}

	s.withURL("http", "example.com", "/app/2/message", "limit=9")
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.GetMessagesWithApplication(s.ctx)

	// Since: entries with ids from 100 - 92 will be returned (9 entries)
	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 9, Size: 9, Since: 92, Next: "http://example.com/app/2/message?limit=9&since=92"},
		Messages: toExternalMessages(messages[:9]),
	}

	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext() {
	user := s.db.User(5)
	app1 := user.App(2)
	var messages []*model.Message
	for i := 100; i >= 1; i-- {
		msg := app1.NewMessage(uint(i))
		messages = append(messages, &msg)
	}

	s.withURL("http", "example.com", "/app/2/message", "limit=13&since=55")
	test.WithUser(s.ctx, 5)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.GetMessagesWithApplication(s.ctx)

	// Since: entries with ids from 54 - 42 will be returned (13 entries)
	expected := &model.PagedMessages{
		Paging:   model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/app/2/message?limit=13&since=42"},
		Messages: toExternalMessages(messages[46 : 46+13]),
	}
	test.BodyEquals(s.T(), expected, s.recorder)
}

func (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectNotFound() {
	s.db.User(4)
	s.db.User(5).App(2).Message(66)

	test.WithUser(s.ctx, 4)
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
	s.a.GetMessagesWithApplication(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessage_invalidID() {
	s.ctx.Params = gin.Params{{Key: "id", Value: "string"}}

	s.a.DeleteMessage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessage_notExistingID() {
	s.db.User(1).App(5).Message(55)

	s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
	s.a.DeleteMessage(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessage_existingIDButNotOwner() {
	s.db.User(1).App(10).Message(100)
	s.db.User(2)

	test.WithUser(s.ctx, 2)
	s.ctx.Params = gin.Params{{Key: "id", Value: "100"}}
	s.a.DeleteMessage(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessage() {
	s.db.User(6).App(1).Message(50)

	test.WithUser(s.ctx, 6)
	s.ctx.Params = gin.Params{{Key: "id", Value: "50"}}
	s.a.DeleteMessage(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertMessageNotExist(50)
}

func (s *MessageSuite) Test_DeleteMessageWithID() {
	s.db.User(2).AppWithToken(5, "mytoken").Message(55)

	test.WithUser(s.ctx, 2)
	s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}
	s.a.DeleteMessageWithApplication(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertMessageNotExist(55)
}

func (s *MessageSuite) Test_DeleteMessageWithToken_notExistingID() {
	s.db.User(2).AppWithToken(1, "wrong").Message(1)

	test.WithUser(s.ctx, 2)
	s.ctx.Params = gin.Params{{Key: "id", Value: "55"}}
	s.a.DeleteMessageWithApplication(s.ctx)

	s.db.AssertMessageExist(1)
	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessageWithToken_notOwner() {
	s.db.User(4)
	s.db.User(2).App(55).Message(5)

	test.WithUser(s.ctx, 4)
	s.ctx.Params = gin.Params{{Key: "id", Value: "55"}}
	s.a.DeleteMessageWithApplication(s.ctx)

	s.db.AssertMessageExist(5)
	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *MessageSuite) Test_DeleteMessages() {
	userBuilder := s.db.User(4)
	userBuilder.App(5).Message(5).Message(6)
	userBuilder.App(2).Message(7).Message(8)
	s.db.User(5).App(7).Message(22)

	test.WithUser(s.ctx, 4)
	s.a.DeleteMessages(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertMessageExist(22)
	s.db.AssertMessageNotExist(5, 6, 7, 8)
}

func (s *MessageSuite) Test_CreateMessage_onJson_allParams() {
	t, _ := time.Parse("2006/01/02", "2017/01/02")

	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(7, "app-token")
	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": 1}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(7)
	assert.NoError(s.T(), err)
	expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), expected, s.notifiedMessage)
}

func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() {
	t, _ := time.Parse("2006/01/02", "2017/01/02")

	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5)
	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(8)
	assert.NoError(s.T(), err)
	expected := &model.MessageExternal{ID: 1, ApplicationID: 8, Title: "mytitle", Message: "mymessage", Priority: intPtr(5), Date: t}
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), expected, s.notifiedMessage)
}

func (s *MessageSuite) Test_CreateMessage_WithTitle() {
	t, _ := time.Parse("2006/01/02", "2017/01/02")
	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(5, "app-token")
	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(5)
	assert.NoError(s.T(), err)
	expected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: "mytitle", Message: "mymessage", Date: t, Priority: intPtr(0)}
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), expected, s.notifiedMessage)
}

func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(1, "app-token")

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {
		assert.Empty(s.T(), msgs)
	}
	assert.Equal(s.T(), 400, s.recorder.Code)
	assert.Nil(s.T(), s.notifiedMessage)
}

func (s *MessageSuite) Test_CreateMessage_WithoutTitle() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(8)
	assert.NoError(s.T(), err)
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), "Application name", msgs[0].Title)
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), "mymessage", s.notifiedMessage.Message)
}

func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": "  "}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(8)
	assert.NoError(s.T(), err)
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), "Application name", msgs[0].Title)
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), "mymessage", msgs[0].Message)
}

func (s *MessageSuite) Test_CreateMessage_IgnoreID() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "id": 1337}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(8)
	assert.NoError(s.T(), err)
	assert.Len(s.T(), msgs, 1)
	assert.NotEqual(s.T(), msgs[0].ID, uint(1337))
	assert.Equal(s.T(), 200, s.recorder.Code)
}

func (s *MessageSuite) Test_CreateMessage_WithExtras() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name")

	t, _ := time.Parse("2006/01/02", "2017/01/02")
	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": "msg with extras", "extras": {"gotify::test":{"int":1,"float":0.5,"string":"test","array":[1,2,3]}}}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	msgs, err := s.db.GetMessagesByApplication(8)
	assert.NoError(s.T(), err)
	expected := &model.MessageExternal{
		ID:            1,
		ApplicationID: 8,
		Message:       "mymessage",
		Title:         "msg with extras",
		Date:          t,
		Priority:      intPtr(0),
		Extras: map[string]interface{}{
			"gotify::test": map[string]interface{}{
				"string": "test",
				"array":  []interface{}{float64(1), float64(2), float64(3)},
				"int":    float64(1),
				"float":  float64(0.5),
			},
		},
	}
	assert.Len(s.T(), msgs, 1)

	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))

	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}

func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(8, "app-token")

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": "asd"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateMessage(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	assert.Nil(s.T(), s.notifiedMessage)
	if msgs, err := s.db.GetMessagesByApplication(1); assert.NoError(s.T(), err) {
		assert.Empty(s.T(), msgs)
	}
}

func (s *MessageSuite) Test_CreateMessage_onQueryData() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(2, "app-token")

	t, _ := time.Parse("2006/01/02", "2017/01/02")
	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	s.ctx.Request = httptest.NewRequest("POST", "/message?title=mytitle&message=mymessage&priority=1", nil)
	s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	s.a.CreateMessage(s.ctx)

	expected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}

	msgs, err := s.db.GetMessagesByApplication(2)
	assert.NoError(s.T(), err)
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}

func (s *MessageSuite) Test_CreateMessage_onFormData() {
	auth.RegisterAuthentication(s.ctx, nil, 4, "app-token")
	s.db.User(4).AppWithToken(99, "app-token")

	t, _ := time.Parse("2006/01/02", "2017/01/02")
	timeNow = func() time.Time { return t }
	defer func() { timeNow = time.Now }()

	s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`title=mytitle&message=mymessage&priority=1`))
	s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	s.a.CreateMessage(s.ctx)

	expected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
	msgs, err := s.db.GetMessagesByApplication(99)
	assert.NoError(s.T(), err)
	assert.Len(s.T(), msgs, 1)
	assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
	assert.Equal(s.T(), 200, s.recorder.Code)
	assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
}

func (s *MessageSuite) withURL(scheme, host, path, query string) {
	s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query}
	s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host})
}

func intPtr(x int) *int {
	return &x
}


================================================
FILE: api/plugin.go
================================================
package api

import (
	"errors"
	"fmt"
	"io"

	"github.com/gin-gonic/gin"
	"github.com/gotify/location"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/plugin"
	"github.com/gotify/server/v2/plugin/compat"
	"gopkg.in/yaml.v3"
)

// The PluginDatabase interface for encapsulating database access.
type PluginDatabase interface {
	GetPluginConfByUser(userid uint) ([]*model.PluginConf, error)
	UpdatePluginConf(p *model.PluginConf) error
	GetPluginConfByID(id uint) (*model.PluginConf, error)
}

// The PluginAPI provides handlers for managing plugins.
type PluginAPI struct {
	Notifier Notifier
	Manager  *plugin.Manager
	DB       PluginDatabase
}

// GetPlugins returns all plugins a user has.
// swagger:operation GET /plugin plugin getPlugins
//
// Return all plugins.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      type: array
//	      items:
//	        $ref: "#/definitions/PluginConf"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) GetPlugins(ctx *gin.Context) {
	userID := auth.GetUserID(ctx)
	plugins, err := c.DB.GetPluginConfByUser(userID)
	if success := successOrAbort(ctx, 500, err); !success {
		return
	}
	result := make([]model.PluginConfExternal, 0)
	for _, conf := range plugins {
		if inst, err := c.Manager.Instance(conf.ID); err == nil {
			info := c.Manager.PluginInfo(conf.ModulePath)
			result = append(result, model.PluginConfExternal{
				ID:           conf.ID,
				Name:         info.String(),
				Token:        conf.Token,
				ModulePath:   conf.ModulePath,
				Author:       info.Author,
				Website:      info.Website,
				License:      info.License,
				Enabled:      conf.Enabled,
				Capabilities: inst.Supports().Strings(),
			})
		}
	}
	ctx.JSON(200, result)
}

// EnablePlugin enables a plugin.
// swagger:operation POST /plugin/{id}/enable plugin enablePlugin
//
// Enable a plugin.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the plugin id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) EnablePlugin(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		conf, err := c.DB.GetPluginConfByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if conf == nil || !isPluginOwner(ctx, conf) {
			ctx.AbortWithError(404, errors.New("unknown plugin"))
			return
		}
		_, err = c.Manager.Instance(id)
		if err != nil {
			ctx.AbortWithError(404, errors.New("plugin instance not found"))
			return
		}
		if err := c.Manager.SetPluginEnabled(id, true); err == plugin.ErrAlreadyEnabledOrDisabled {
			ctx.AbortWithError(400, err)
		} else if err != nil {
			ctx.AbortWithError(500, err)
		}
	})
}

// DisablePlugin disables a plugin.
// swagger:operation POST /plugin/{id}/disable plugin disablePlugin
//
// Disable a plugin.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the plugin id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) DisablePlugin(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		conf, err := c.DB.GetPluginConfByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if conf == nil || !isPluginOwner(ctx, conf) {
			ctx.AbortWithError(404, errors.New("unknown plugin"))
			return
		}
		_, err = c.Manager.Instance(id)
		if err != nil {
			ctx.AbortWithError(404, errors.New("plugin instance not found"))
			return
		}
		if err := c.Manager.SetPluginEnabled(id, false); err == plugin.ErrAlreadyEnabledOrDisabled {
			ctx.AbortWithError(400, err)
		} else if err != nil {
			ctx.AbortWithError(500, err)
		}
	})
}

// GetDisplay get display info for Displayer plugin.
// swagger:operation GET /plugin/{id}/display plugin getPluginDisplay
//
// Get display info for a Displayer plugin.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the plugin id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      type: string
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) GetDisplay(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		conf, err := c.DB.GetPluginConfByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if conf == nil || !isPluginOwner(ctx, conf) {
			ctx.AbortWithError(404, errors.New("unknown plugin"))
			return
		}
		instance, err := c.Manager.Instance(id)
		if err != nil {
			ctx.AbortWithError(404, errors.New("plugin instance not found"))
			return
		}
		ctx.JSON(200, instance.GetDisplay(location.Get(ctx)))
	})
}

// GetConfig returns Configurer plugin configuration in YAML format.
// swagger:operation GET /plugin/{id}/config plugin getPluginConfig
//
// Get YAML configuration for Configurer plugin.
//
//	---
//	consumes: [application/json]
//	produces: [application/x-yaml]
//	parameters:
//	- name: id
//	  in: path
//	  description: the plugin id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        type: object
//	        description: plugin configuration
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) GetConfig(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		conf, err := c.DB.GetPluginConfByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if conf == nil || !isPluginOwner(ctx, conf) {
			ctx.AbortWithError(404, errors.New("unknown plugin"))
			return
		}
		instance, err := c.Manager.Instance(id)
		if err != nil {
			ctx.AbortWithError(404, errors.New("plugin instance not found"))
			return
		}

		if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {
			return
		}

		ctx.Header("content-type", "application/x-yaml")
		ctx.Writer.Write(conf.Config)
	})
}

// UpdateConfig updates Configurer plugin configuration in YAML format.
// swagger:operation POST /plugin/{id}/config plugin updatePluginConfig
//
// Update YAML configuration for Configurer plugin.
//
//	---
//	consumes: [application/x-yaml]
//	produces: [application/json]
//	parameters:
//	- name: id
//	  in: path
//	  description: the plugin id
//	  required: true
//	  type: integer
//	  format: int64
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Internal Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (c *PluginAPI) UpdateConfig(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		conf, err := c.DB.GetPluginConfByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if conf == nil || !isPluginOwner(ctx, conf) {
			ctx.AbortWithError(404, errors.New("unknown plugin"))
			return
		}
		instance, err := c.Manager.Instance(id)
		if err != nil {
			ctx.AbortWithError(404, errors.New("plugin instance not found"))
			return
		}

		if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted {
			return
		}

		newConf := instance.DefaultConfig()
		newconfBytes, err := io.ReadAll(ctx.Request.Body)
		if err != nil {
			ctx.AbortWithError(500, err)
			return
		}
		if err := yaml.Unmarshal(newconfBytes, newConf); err != nil {
			ctx.AbortWithError(400, err)
			return
		}
		if err := instance.ValidateAndSetConfig(newConf); err != nil {
			ctx.AbortWithError(400, err)
			return
		}
		conf.Config = newconfBytes
		successOrAbort(ctx, 500, c.DB.UpdatePluginConf(conf))
	})
}

func isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool {
	return conf.UserID == auth.GetUserID(ctx)
}

func supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, module compat.Capability) (aborted bool) {
	if compat.HasSupport(instance, module) {
		return false
	}
	ctx.AbortWithError(400, fmt.Errorf("plugin does not support %s", module))
	return true
}


================================================
FILE: api/plugin_test.go
================================================
package api

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/plugin"
	"github.com/gotify/server/v2/plugin/compat"
	"github.com/gotify/server/v2/plugin/testing/mock"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
	"gopkg.in/yaml.v3"
)

func TestPluginSuite(t *testing.T) {
	suite.Run(t, new(PluginSuite))
}

type PluginSuite struct {
	suite.Suite
	db       *testdb.Database
	a        *PluginAPI
	ctx      *gin.Context
	recorder *httptest.ResponseRecorder
	manager  *plugin.Manager
	notified bool
}

func (s *PluginSuite) BeforeTest(suiteName, testName string) {
	mode.Set(mode.TestDev)
	s.db = testdb.NewDB(s.T())
	s.resetRecorder()
	manager, err := plugin.NewManager(s.db, "", nil, s)
	assert.Nil(s.T(), err)
	s.manager = manager
	withURL(s.ctx, "http", "example.com")
	s.a = &PluginAPI{DB: s.db, Manager: manager, Notifier: s}

	mockPluginCompat := new(mock.Plugin)
	assert.Nil(s.T(), s.manager.LoadPlugin(mockPluginCompat))

	s.db.User(1)
	assert.Nil(s.T(), s.manager.InitializeForUserID(1))
	s.db.User(2)
	assert.Nil(s.T(), s.manager.InitializeForUserID(2))

	s.db.CreatePluginConf(&model.PluginConf{
		UserID:     1,
		ModulePath: "github.com/gotify/server/v2/plugin/example/removed",
		Token:      "P1234",
		Enabled:    false,
	})
}

func (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf {
	conf, err := s.db.GetPluginConfByUserAndPath(uid, "github.com/gotify/server/v2/plugin/example/removed")
	assert.NoError(s.T(), err)
	return conf
}

func (s *PluginSuite) resetRecorder() {
	s.recorder = httptest.NewRecorder()
	s.ctx, _ = gin.CreateTestContext(s.recorder)
}

func (s *PluginSuite) AfterTest(suiteName, testName string) {
	s.db.Close()
}

func (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) {
	s.notified = true
}

func (s *PluginSuite) Test_GetPlugins() {
	test.WithUser(s.ctx, 1)

	s.ctx.Request = httptest.NewRequest("GET", "/plugin", nil)
	s.a.GetPlugins(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)

	pluginConfs := make([]model.PluginConfExternal, 0)
	assert.Nil(s.T(), json.Unmarshal(s.recorder.Body.Bytes(), &pluginConfs))

	assert.Equal(s.T(), mock.Name, pluginConfs[0].Name)
	assert.Equal(s.T(), mock.ModulePath, pluginConfs[0].ModulePath)

	assert.False(s.T(), pluginConfs[0].Enabled, "Plugins should be disabled by default")
}

func (s *PluginSuite) Test_EnableDisablePlugin() {
	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.True(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.True(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expect500() {
	s.db.User(16)
	assert.Nil(s.T(), s.manager.InitializeForUserID(16))
	mock.ReturnErrorOnEnableForUser(16, errors.New("test error"))
	conf, err := s.db.GetPluginConfByUserAndPath(16, mock.ModulePath)
	assert.NoError(s.T(), err)

	{
		test.WithUser(s.ctx, 16)
		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 500, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_expect500() {
	s.db.User(17)
	assert.Nil(s.T(), s.manager.InitializeForUserID(17))
	mock.ReturnErrorOnDisableForUser(17, errors.New("test error"))
	conf, err := s.db.GetPluginConfByUserAndPath(17, mock.ModulePath)
	assert.NoError(s.T(), err)
	s.manager.SetPluginEnabled(conf.ID, true)

	{
		test.WithUser(s.ctx, 17)
		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 500, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNotFound() {
	{
		test.WithUser(s.ctx, 2)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 2)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)

		if pluginConf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath); assert.NoError(s.T(), err) {
			assert.False(s.T(), pluginConf.Enabled)
		}
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNotFound() {
	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/enable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/disable", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotFound() {
	conf := s.getDanglingConf(1)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.EnablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
		s.resetRecorder()
	}

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.DisablePlugin(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
		s.resetRecorder()
	}
}

func (s *PluginSuite) Test_GetDisplay() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	mockInst.DisplayString = "test string"

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetDisplay(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)
		test.JSONEquals(s.T(), mockInst.DisplayString, s.recorder.Body.String())
	}
}

func (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	mockInst.SetCapability(compat.Displayer, false)
	defer mockInst.SetCapability(compat.Displayer, true)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetDisplay(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)
		test.JSONEquals(s.T(), "", s.recorder.Body.String())
	}
}

func (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	mockInst.DisplayString = "test string"

	{
		test.WithUser(s.ctx, 2)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetDisplay(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() {
	conf := s.getDanglingConf(1)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetDisplay(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() {
	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/display", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
		s.a.GetDisplay(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetConfig() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	assert.Equal(s.T(), mockInst.DefaultConfig(), mockInst.Config, "Initial config should be default config")
	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetConfig(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)
		returnedConfig := new(mock.PluginConfig)
		assert.Nil(s.T(), yaml.Unmarshal(s.recorder.Body.Bytes(), returnedConfig))
		assert.Equal(s.T(), mockInst.Config, returnedConfig)
	}
}

func (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	mockInst.SetCapability(compat.Configurer, false)
	defer mockInst.SetCapability(compat.Configurer, true)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetConfig(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)

	{
		test.WithUser(s.ctx, 2)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() {
	conf := s.getDanglingConf(1)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.GetConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() {
	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/config", nil)
		s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
		s.a.GetConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_UpdateConfig() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	newConfig := &mock.PluginConfig{
		TestKey: "test__new__config",
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 200, s.recorder.Code)
		assert.Equal(s.T(), newConfig, mockInst.Config, "config should be received by plugin")

		var pluginFromDBBytes []byte
		if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
			pluginFromDBBytes = pluginConf.Config
		}
		pluginFromDB := new(mock.PluginConfig)
		err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
		assert.Nil(s.T(), err)
		assert.Equal(s.T(), newConfig, pluginFromDB, "config should be updated in database")
	}
}

func (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)
	origConfig := mockInst.Config

	newConfig := &mock.PluginConfig{
		TestKey:    "test__new__config__invalid",
		IsNotValid: true,
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)
		assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")

		var pluginFromDBBytes []byte
		if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
			pluginFromDBBytes = pluginConf.Config
		}
		pluginFromDB := new(mock.PluginConfig)
		err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
		assert.Nil(s.T(), err)
		assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
	}
}

func (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)
	origConfig := mockInst.Config

	newConfigYAML := []byte(`--- "rg e""`)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)
		assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")

		var pluginFromDBBytes []byte
		if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
			pluginFromDBBytes = pluginConf.Config
		}
		pluginFromDB := new(mock.PluginConfig)
		err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
		assert.Nil(s.T(), err)
		assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
	}
}

func (s *PluginSuite) Test_UpdateConfig_ioError_expect500() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)
	origConfig := mockInst.Config

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), test.UnreadableReader())
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 500, s.recorder.Code)
		assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")

		var pluginFromDBBytes []byte
		if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
			pluginFromDBBytes = pluginConf.Config
		}
		pluginFromDB := new(mock.PluginConfig)
		err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
		assert.Nil(s.T(), err)
		assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
	}
}

func (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)

	newConfig := &mock.PluginConfig{
		TestKey: "test__new__config",
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	mockInst.SetCapability(compat.Configurer, false)
	defer mockInst.SetCapability(compat.Configurer, true)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 400, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() {
	conf, err := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath)
	assert.NoError(s.T(), err)
	inst, err := s.manager.Instance(conf.ID)
	assert.Nil(s.T(), err)
	mockInst := inst.(*mock.PluginInstance)
	origConfig := mockInst.Config

	newConfig := &mock.PluginConfig{
		TestKey: "test__new__config",
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	{
		test.WithUser(s.ctx, 2)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
		assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin")

		var pluginFromDBBytes []byte
		if pluginConf, err := s.db.GetPluginConfByID(conf.ID); assert.NoError(s.T(), err) {
			pluginFromDBBytes = pluginConf.Config
		}
		pluginFromDB := new(mock.PluginConfig)
		err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB)
		assert.Nil(s.T(), err)
		assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database")
	}
}

func (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() {
	conf := s.getDanglingConf(1)

	newConfig := &mock.PluginConfig{
		TestKey: "test__new__config",
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}

func (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() {
	newConfig := &mock.PluginConfig{
		TestKey: "test__new__config",
	}
	newConfigYAML, err := yaml.Marshal(newConfig)
	assert.Nil(s.T(), err)

	{
		test.WithUser(s.ctx, 1)

		s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/config", bytes.NewReader(newConfigYAML))
		s.ctx.Header("Content-Type", "application/x-yaml")
		s.ctx.Params = gin.Params{{Key: "id", Value: "99"}}
		s.a.UpdateConfig(s.ctx)

		assert.Equal(s.T(), 404, s.recorder.Code)
	}
}


================================================
FILE: api/stream/client.go
================================================
package stream

import (
	"fmt"
	"time"

	"github.com/gorilla/websocket"
	"github.com/gotify/server/v2/model"
)

const (
	writeWait = 2 * time.Second
)

var ping = func(conn *websocket.Conn) error {
	return conn.WriteMessage(websocket.PingMessage, nil)
}

var writeJSON = func(conn *websocket.Conn, v interface{}) error {
	return conn.WriteJSON(v)
}

type client struct {
	conn    *websocket.Conn
	onClose func(*client)
	write   chan *model.MessageExternal
	userID  uint
	token   string
	once    once
}

func newClient(conn *websocket.Conn, userID uint, token string, onClose func(*client)) *client {
	return &client{
		conn:    conn,
		write:   make(chan *model.MessageExternal, 1),
		userID:  userID,
		token:   token,
		onClose: onClose,
	}
}

// Close closes the connection.
func (c *client) Close() {
	c.once.Do(func() {
		c.conn.Close()
		close(c.write)
	})
}

// NotifyClose closes the connection and notifies that the connection was closed.
func (c *client) NotifyClose() {
	c.once.Do(func() {
		c.conn.Close()
		close(c.write)
		c.onClose(c)
	})
}

// startWriteHandler starts listening on the client connection. As we do not need anything from the client,
// we ignore incoming messages. Leaves the loop on errors.
func (c *client) startReading(pongWait time.Duration) {
	defer c.NotifyClose()
	c.conn.SetReadLimit(64)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(appData string) error {
		c.conn.SetReadDeadline(time.Now().Add(pongWait))
		return nil
	})
	for {
		if _, _, err := c.conn.NextReader(); err != nil {
			printWebSocketError("ReadError", err)
			return
		}
	}
}

// startWriteHandler starts the write loop. The method has the following tasks:
// * ping the client in the interval provided as parameter
// * write messages send by the channel to the client
// * on errors exit the loop.
func (c *client) startWriteHandler(pingPeriod time.Duration) {
	pingTicker := time.NewTicker(pingPeriod)
	defer func() {
		c.NotifyClose()
		pingTicker.Stop()
	}()

	for {
		select {
		case message, ok := <-c.write:
			if !ok {
				return
			}

			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := writeJSON(c.conn, message); err != nil {
				printWebSocketError("WriteError", err)
				return
			}
		case <-pingTicker.C:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := ping(c.conn); err != nil {
				printWebSocketError("PingError", err)
				return
			}
		}
	}
}

func printWebSocketError(prefix string, err error) {
	closeError, ok := err.(*websocket.CloseError)

	if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) {
		// normal closure
		return
	}

	fmt.Println("WebSocket:", prefix, err)
}


================================================
FILE: api/stream/once.go
================================================
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package stream

import (
	"sync"
	"sync/atomic"
)

// Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go)
// This version unlocks the mutex early and therefore doesn't hold the lock while executing func f().
type once struct {
	m    sync.Mutex
	done uint32
}

func (o *once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 1 {
		return
	}
	if o.mayExecute() {
		f()
	}
}

func (o *once) mayExecute() bool {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		atomic.StoreUint32(&o.done, 1)
		return true
	}
	return false
}


================================================
FILE: api/stream/once_test.go
================================================
package stream

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func Test_Execute(t *testing.T) {
	executeOnce := once{}
	execution := make(chan struct{})
	fExecute := func() {
		execution <- struct{}{}
	}
	go executeOnce.Do(fExecute)
	go executeOnce.Do(fExecute)

	select {
	case <-execution:
		// expected
	case <-time.After(100 * time.Millisecond):
		t.Fatal("fExecute should be executed once")
	}

	select {
	case <-execution:
		t.Fatal("should only execute once")
	case <-time.After(100 * time.Millisecond):
		// expected
	}

	assert.False(t, executeOnce.mayExecute())

	go executeOnce.Do(fExecute)

	select {
	case <-execution:
		t.Fatal("should only execute once")
	case <-time.After(100 * time.Millisecond):
		// expected
	}
}


================================================
FILE: api/stream/stream.go
================================================
package stream

import (
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
)

// The API provides a handler for a WebSocket stream API.
type API struct {
	clients     map[uint][]*client
	lock        sync.RWMutex
	pingPeriod  time.Duration
	pongTimeout time.Duration
	upgrader    *websocket.Upgrader
}

// New creates a new instance of API.
// pingPeriod: is the interval, in which is server sends the a ping to the client.
// pongTimeout: is the duration after the connection will be terminated, when the client does not respond with the
// pong command.
func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string) *API {
	return &API{
		clients:     make(map[uint][]*client),
		pingPeriod:  pingPeriod,
		pongTimeout: pingPeriod + pongTimeout,
		upgrader:    newUpgrader(allowedWebSocketOrigins),
	}
}

// CollectConnectedClientTokens returns all tokens of the connected clients.
func (a *API) CollectConnectedClientTokens() []string {
	a.lock.RLock()
	defer a.lock.RUnlock()
	var clients []string
	for _, cs := range a.clients {
		for _, c := range cs {
			clients = append(clients, c.token)
		}
	}
	return uniq(clients)
}

// NotifyDeletedUser closes existing connections for the given user.
func (a *API) NotifyDeletedUser(userID uint) error {
	a.lock.Lock()
	defer a.lock.Unlock()
	if clients, ok := a.clients[userID]; ok {
		for _, client := range clients {
			client.Close()
		}
		delete(a.clients, userID)
	}
	return nil
}

// NotifyDeletedClient closes existing connections with the given token.
func (a *API) NotifyDeletedClient(userID uint, token string) {
	a.lock.Lock()
	defer a.lock.Unlock()
	if clients, ok := a.clients[userID]; ok {
		for i := len(clients) - 1; i >= 0; i-- {
			client := clients[i]
			if client.token == token {
				client.Close()
				clients = append(clients[:i], clients[i+1:]...)
			}
		}
		a.clients[userID] = clients
	}
}

// Notify notifies the clients with the given userID that a new messages was created.
func (a *API) Notify(userID uint, msg *model.MessageExternal) {
	a.lock.RLock()
	defer a.lock.RUnlock()
	if clients, ok := a.clients[userID]; ok {
		for _, c := range clients {
			c.write <- msg
		}
	}
}

func (a *API) remove(remove *client) {
	a.lock.Lock()
	defer a.lock.Unlock()
	if userIDClients, ok := a.clients[remove.userID]; ok {
		for i, client := range userIDClients {
			if client == remove {
				a.clients[remove.userID] = append(userIDClients[:i], userIDClients[i+1:]...)
				break
			}
		}
	}
}

func (a *API) register(client *client) {
	a.lock.Lock()
	defer a.lock.Unlock()
	a.clients[client.userID] = append(a.clients[client.userID], client)
}

// Handle handles incoming requests. First it upgrades the protocol to the WebSocket protocol and then starts listening
// for read and writes.
// swagger:operation GET /stream message streamMessages
//
// Websocket, return newly created messages.
//
//	---
//	schema: ws, wss
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/Message"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  500:
//	    description: Server Error
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *API) Handle(ctx *gin.Context) {
	conn, err := a.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
	if err != nil {
		ctx.Error(err)
		return
	}

	client := newClient(conn, auth.GetUserID(ctx), auth.GetTokenID(ctx), a.remove)
	a.register(client)
	go client.startReading(a.pongTimeout)
	go client.startWriteHandler(a.pingPeriod)
}

// Close closes all client connections and stops answering new connections.
func (a *API) Close() {
	a.lock.Lock()
	defer a.lock.Unlock()

	for _, clients := range a.clients {
		for _, client := range clients {
			client.Close()
		}
	}
	for k := range a.clients {
		delete(a.clients, k)
	}
}

func uniq[T comparable](s []T) []T {
	m := make(map[T]struct{}, len(s))
	r := make([]T, 0, len(s))
	for _, v := range s {
		if _, ok := m[v]; !ok {
			m[v] = struct{}{}
			r = append(r, v)
		}
	}
	return r
}

func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
	origin := r.Header.Get("origin")
	if origin == "" {
		return true
	}

	u, err := url.Parse(origin)
	if err != nil {
		return false
	}

	if strings.EqualFold(u.Host, r.Host) {
		return true
	}

	for _, allowedOrigin := range allowedOrigins {
		if allowedOrigin.MatchString(strings.ToLower(u.Hostname())) {
			return true
		}
	}

	return false
}

func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {
	compiledAllowedOrigins := compileAllowedWebSocketOrigins(allowedWebSocketOrigins)
	return &websocket.Upgrader{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
		CheckOrigin: func(r *http.Request) bool {
			if mode.IsDev() {
				return true
			}
			return isAllowedOrigin(r, compiledAllowedOrigins)
		},
	}
}

func compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.Regexp {
	var compiledAllowedOrigins []*regexp.Regexp
	for _, origin := range allowedOrigins {
		compiledAllowedOrigins = append(compiledAllowedOrigins, regexp.MustCompile(origin))
	}

	return compiledAllowedOrigins
}


================================================
FILE: api/stream/stream_test.go
================================================
package stream

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/fortytw2/leaktest"
	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/stretchr/testify/assert"
)

func TestFailureOnNormalHttpRequest(t *testing.T) {
	mode.Set(mode.TestDev)

	defer leaktest.Check(t)()

	server, api := bootTestServer(staticUserID())
	defer server.Close()
	defer api.Close()

	resp, err := http.Get(server.URL)
	assert.Nil(t, err)
	assert.Equal(t, 400, resp.StatusCode)
	resp.Body.Close()
}

func TestWriteMessageFails(t *testing.T) {
	mode.Set(mode.TestDev)
	oldWrite := writeJSON
	// try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know.
	writeJSON = func(conn *websocket.Conn, v interface{}) error {
		return errors.New("asd")
	}
	defer func() {
		writeJSON = oldWrite
	}()
	defer leaktest.Check(t)()

	server, api := bootTestServer(func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, 1, "")
	})
	defer server.Close()
	defer api.Close()

	wsURL := wsURL(server.URL)
	user := testClient(t, wsURL)

	waitForConnectedClients(api, 1)

	clients := clients(api, 1)
	assert.NotEmpty(t, clients)

	api.Notify(1, &model.MessageExternal{Message: "HI"})
	user.expectNoMessage()
}

func TestWritePingFails(t *testing.T) {
	mode.Set(mode.TestDev)
	oldPing := ping
	// try emulate an write error, mostly this should kill the ReadMessage gorouting first but you'll never know.
	ping = func(conn *websocket.Conn) error {
		return errors.New("asd")
	}
	defer func() {
		ping = oldPing
	}()

	defer leaktest.CheckTimeout(t, 10*time.Second)()

	server, api := bootTestServer(staticUserID())
	defer api.Close()
	defer server.Close()

	wsURL := wsURL(server.URL)
	user := testClient(t, wsURL)
	defer user.conn.Close()

	waitForConnectedClients(api, 1)

	clients := clients(api, 1)

	assert.NotEmpty(t, clients)

	time.Sleep(api.pingPeriod + (50 * time.Millisecond)) // waiting for ping

	api.Notify(1, &model.MessageExternal{Message: "HI"})
	user.expectNoMessage()
}

func TestPing(t *testing.T) {
	mode.Set(mode.TestDev)

	server, api := bootTestServer(staticUserID())
	defer server.Close()
	defer api.Close()

	wsURL := wsURL(server.URL)

	user := createClient(t, wsURL)
	defer user.conn.Close()

	ping := make(chan bool)
	oldPingHandler := user.conn.PingHandler()
	user.conn.SetPingHandler(func(appData string) error {
		err := oldPingHandler(appData)
		ping <- true
		return err
	})

	startReading(user)

	expectNoMessage(user)

	select {
	case <-time.After(2 * time.Second):
		assert.Fail(t, "Expected ping but there was one :(")
	case <-ping:
		// expected
	}

	expectNoMessage(user)
	api.Notify(1, &model.MessageExternal{Message: "HI"})
	user.expectMessage(&model.MessageExternal{Message: "HI"})
}

func TestCloseClientOnNotReading(t *testing.T) {
	mode.Set(mode.TestDev)

	server, api := bootTestServer(staticUserID())
	defer server.Close()
	defer api.Close()

	wsURL := wsURL(server.URL)

	ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	assert.Nil(t, err)
	defer ws.Close()

	waitForConnectedClients(api, 1)

	assert.NotEmpty(t, clients(api, 1))

	time.Sleep(api.pingPeriod + api.pongTimeout)

	assert.Empty(t, clients(api, 1))
}

func TestMessageDirectlyAfterConnect(t *testing.T) {
	mode.Set(mode.Prod)
	defer leaktest.Check(t)()
	server, api := bootTestServer(staticUserID())
	defer server.Close()
	defer api.Close()

	wsURL := wsURL(server.URL)

	user := testClient(t, wsURL)
	defer user.conn.Close()

	waitForConnectedClients(api, 1)

	api.Notify(1, &model.MessageExternal{Message: "msg"})
	user.expectMessage(&model.MessageExternal{Message: "msg"})
}

func TestDeleteClientShouldCloseConnection(t *testing.T) {
	mode.Set(mode.Prod)
	defer leaktest.Check(t)()
	server, api := bootTestServer(staticUserID())
	defer server.Close()
	defer api.Close()

	wsURL := wsURL(server.URL)

	user := testClient(t, wsURL)
	defer user.conn.Close()

	waitForConnectedClients(api, 1)

	api.Notify(1, &model.MessageExternal{Message: "msg"})
	user.expectMessage(&model.MessageExternal{Message: "msg"})

	api.NotifyDeletedClient(1, "customtoken")

	api.Notify(1, &model.MessageExternal{Message: "msg"})
	user.expectNoMessage()
}

func TestDeleteMultipleClients(t *testing.T) {
	mode.Set(mode.TestDev)

	defer leaktest.Check(t)()
	userIDs := []uint{1, 1, 1, 1, 2, 2, 3}
	tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"}
	i := 0
	server, api := bootTestServer(func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
		i++
	})
	defer server.Close()

	wsURL := wsURL(server.URL)

	userOneIPhone := testClient(t, wsURL)
	defer userOneIPhone.conn.Close()
	userOneAndroid := testClient(t, wsURL)
	defer userOneAndroid.conn.Close()
	userOneBrowser := testClient(t, wsURL)
	defer userOneBrowser.conn.Close()
	userOneOther := testClient(t, wsURL)
	defer userOneOther.conn.Close()
	userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}

	userTwoBrowser := testClient(t, wsURL)
	defer userTwoBrowser.conn.Close()
	userTwoAndroid := testClient(t, wsURL)
	defer userTwoAndroid.conn.Close()
	userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}

	userThreeAndroid := testClient(t, wsURL)
	defer userThreeAndroid.conn.Close()
	userThree := []*testingClient{userThreeAndroid}

	waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))

	api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"})
	expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.NotifyDeletedClient(1, "1-2")

	api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"})
	expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userOneIPhone, userOneOther)
	expectNoMessage(userOneBrowser, userOneAndroid)
	expectNoMessage(userThree...)
	expectNoMessage(userTwo...)

	api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
	expectNoMessage(userOne...)
	expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
	expectNoMessage(userThree...)

	api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"})
	expectNoMessage(userOne...)
	expectNoMessage(userTwo...)
	expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...)

	api.Close()
}

func TestDeleteUser(t *testing.T) {
	mode.Set(mode.TestDev)

	defer leaktest.Check(t)()
	userIDs := []uint{1, 1, 1, 1, 2, 2, 3}
	tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"}
	i := 0
	server, api := bootTestServer(func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
		i++
	})
	defer server.Close()

	wsURL := wsURL(server.URL)

	userOneIPhone := testClient(t, wsURL)
	defer userOneIPhone.conn.Close()
	userOneAndroid := testClient(t, wsURL)
	defer userOneAndroid.conn.Close()
	userOneBrowser := testClient(t, wsURL)
	defer userOneBrowser.conn.Close()
	userOneOther := testClient(t, wsURL)
	defer userOneOther.conn.Close()
	userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone, userOneOther}

	userTwoBrowser := testClient(t, wsURL)
	defer userTwoBrowser.conn.Close()
	userTwoAndroid := testClient(t, wsURL)
	defer userTwoAndroid.conn.Close()
	userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}

	userThreeAndroid := testClient(t, wsURL)
	defer userThreeAndroid.conn.Close()
	userThree := []*testingClient{userThreeAndroid}

	waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))

	api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"})
	expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.NotifyDeletedUser(1)

	api.Notify(1, &model.MessageExternal{ID: 2, Message: "there"})
	expectNoMessage(userOne...)
	expectNoMessage(userThree...)
	expectNoMessage(userTwo...)

	api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
	expectNoMessage(userOne...)
	expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
	expectNoMessage(userThree...)

	api.Notify(3, &model.MessageExternal{ID: 5, Message: "there"})
	expectNoMessage(userOne...)
	expectNoMessage(userTwo...)
	expectMessage(&model.MessageExternal{ID: 5, Message: "there"}, userThree...)

	api.Close()
}

func TestCollectConnectedClientTokens(t *testing.T) {
	mode.Set(mode.TestDev)

	defer leaktest.Check(t)()
	userIDs := []uint{1, 1, 1, 2, 2}
	tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"}
	i := 0
	server, api := bootTestServer(func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
		i++
	})
	defer server.Close()

	wsURL := wsURL(server.URL)
	userOneConnOne := testClient(t, wsURL)
	defer userOneConnOne.conn.Close()
	userOneConnTwo := testClient(t, wsURL)
	defer userOneConnTwo.conn.Close()
	userOneConnThree := testClient(t, wsURL)
	defer userOneConnThree.conn.Close()
	waitForConnectedClients(api, 3)

	ret := api.CollectConnectedClientTokens()
	sort.Strings(ret)
	assert.Equal(t, []string{"1-1", "1-2"}, ret)

	userTwoConnOne := testClient(t, wsURL)
	defer userTwoConnOne.conn.Close()
	userTwoConnTwo := testClient(t, wsURL)
	defer userTwoConnTwo.conn.Close()
	waitForConnectedClients(api, 5)

	ret = api.CollectConnectedClientTokens()
	sort.Strings(ret)
	assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret)
}

func TestMultipleClients(t *testing.T) {
	mode.Set(mode.TestDev)

	defer leaktest.Check(t)()
	userIDs := []uint{1, 1, 1, 2, 2, 3}
	i := 0
	server, api := bootTestServer(func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, userIDs[i], "t"+fmt.Sprint(userIDs[i]))
		i++
	})
	defer server.Close()

	wsURL := wsURL(server.URL)

	userOneIPhone := testClient(t, wsURL)
	defer userOneIPhone.conn.Close()
	userOneAndroid := testClient(t, wsURL)
	defer userOneAndroid.conn.Close()
	userOneBrowser := testClient(t, wsURL)
	defer userOneBrowser.conn.Close()
	userOne := []*testingClient{userOneAndroid, userOneBrowser, userOneIPhone}

	userTwoBrowser := testClient(t, wsURL)
	defer userTwoBrowser.conn.Close()
	userTwoAndroid := testClient(t, wsURL)
	defer userTwoAndroid.conn.Close()
	userTwo := []*testingClient{userTwoAndroid, userTwoBrowser}

	userThreeAndroid := testClient(t, wsURL)
	defer userThreeAndroid.conn.Close()
	userThree := []*testingClient{userThreeAndroid}

	waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree))

	// there should not be messages at the beginning
	expectNoMessage(userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.Notify(1, &model.MessageExternal{ID: 1, Message: "hello"})
	time.Sleep(500 * time.Millisecond)
	expectMessage(&model.MessageExternal{ID: 1, Message: "hello"}, userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.Notify(2, &model.MessageExternal{ID: 2, Message: "there"})
	expectNoMessage(userOne...)
	expectMessage(&model.MessageExternal{ID: 2, Message: "there"}, userTwo...)
	expectNoMessage(userThree...)

	userOneIPhone.conn.Close()

	expectNoMessage(userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.Notify(1, &model.MessageExternal{ID: 3, Message: "how"})
	expectMessage(&model.MessageExternal{ID: 3, Message: "how"}, userOneAndroid, userOneBrowser)
	expectNoMessage(userOneIPhone)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)

	api.Notify(2, &model.MessageExternal{ID: 4, Message: "are"})

	expectNoMessage(userOne...)
	expectMessage(&model.MessageExternal{ID: 4, Message: "are"}, userTwo...)
	expectNoMessage(userThree...)

	api.Close()

	api.Notify(2, &model.MessageExternal{ID: 5, Message: "you"})

	expectNoMessage(userOne...)
	expectNoMessage(userTwo...)
	expectNoMessage(userThree...)
}

func Test_sameOrigin_returnsTrue(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com/stream", nil)
	req.Header.Set("Origin", "http://example.com")
	actual := isAllowedOrigin(req, nil)
	assert.True(t, actual)
}

func Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com:8080/stream", nil)
	req.Header.Set("Origin", "http://example.com:8080")
	actual := isAllowedOrigin(req, nil)
	assert.True(t, actual)
}

func Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com/stream", nil)
	req.Header.Set("Origin", "http://gorify.example.com")
	actual := isAllowedOrigin(req, nil)
	assert.False(t, actual)
}

func Test_isAllowedOriginMatching(t *testing.T) {
	mode.Set(mode.Prod)
	compiledAllowedOrigins := compileAllowedWebSocketOrigins([]string{"go.{4}\\.example\\.com", "go\\.example\\.com"})

	req := httptest.NewRequest("GET", "http://example.me/stream", nil)
	req.Header.Set("Origin", "http://gorify.example.com")
	assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))

	req.Header.Set("Origin", "http://go.example.com")
	assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))

	req.Header.Set("Origin", "http://hello.example.com")
	assert.False(t, isAllowedOrigin(req, compiledAllowedOrigins))
}

func Test_emptyOrigin_returnsTrue(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com/stream", nil)
	actual := isAllowedOrigin(req, nil)
	assert.True(t, actual)
}

func Test_otherOrigin_returnsFalse(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com/stream", nil)
	req.Header.Set("Origin", "http://otherexample.de")
	actual := isAllowedOrigin(req, nil)
	assert.False(t, actual)
}

func Test_invalidOrigin_returnsFalse(t *testing.T) {
	mode.Set(mode.Prod)
	req := httptest.NewRequest("GET", "http://example.com/stream", nil)
	req.Header.Set("Origin", "http\\://otherexample.de")
	actual := isAllowedOrigin(req, nil)
	assert.False(t, actual)
}

func Test_compileAllowedWebSocketOrigins(t *testing.T) {
	assert.Equal(t, 0, len(compileAllowedWebSocketOrigins([]string{})))
	assert.Equal(t, 3, len(compileAllowedWebSocketOrigins([]string{"^.*$", "", "abc"})))
}

func clients(api *API, user uint) []*client {
	api.lock.RLock()
	defer api.lock.RUnlock()

	return api.clients[user]
}

func countClients(a *API) int {
	a.lock.RLock()
	defer a.lock.RUnlock()

	var i int
	for _, clients := range a.clients {
		i += len(clients)
	}
	return i
}

func testClient(t *testing.T, url string) *testingClient {
	client := createClient(t, url)
	startReading(client)
	return client
}

func startReading(client *testingClient) {
	go func() {
		for {
			_, payload, err := client.conn.ReadMessage()
			if err != nil {
				return
			}

			actual := &model.MessageExternal{}
			json.NewDecoder(bytes.NewBuffer(payload)).Decode(actual)
			client.readMessage <- *actual
		}
	}()
}

func createClient(t *testing.T, url string) *testingClient {
	ws, _, err := websocket.DefaultDialer.Dial(url, nil)
	assert.Nil(t, err)

	readMessages := make(chan model.MessageExternal)

	return &testingClient{conn: ws, readMessage: readMessages, t: t}
}

type testingClient struct {
	conn        *websocket.Conn
	readMessage chan model.MessageExternal
	t           *testing.T
}

func (c *testingClient) expectMessage(expected *model.MessageExternal) {
	select {
	case <-time.After(50 * time.Millisecond):
		assert.Fail(c.t, "Expected message but none was send :(")
	case actual := <-c.readMessage:
		assert.Equal(c.t, *expected, actual)
	}
}

func expectMessage(expected *model.MessageExternal, clients ...*testingClient) {
	for _, client := range clients {
		client.expectMessage(expected)
	}
}

func expectNoMessage(clients ...*testingClient) {
	for _, client := range clients {
		client.expectNoMessage()
	}
}

func (c *testingClient) expectNoMessage() {
	select {
	case <-time.After(50 * time.Millisecond):
		// no message == as expected
	case msg := <-c.readMessage:
		assert.Fail(c.t, "Expected NO message but there was one :(", fmt.Sprint(msg))
	}
}

func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) {
	r := gin.New()
	r.Use(handlerFunc)
	// ping every 500 ms, and the client has 500 ms to respond
	api := New(500*time.Millisecond, 500*time.Millisecond, []string{})

	r.GET("/", api.Handle)
	server := httptest.NewServer(r)
	return server, api
}

func wsURL(httpURL string) string {
	return "ws" + strings.TrimPrefix(httpURL, "http")
}

func staticUserID() gin.HandlerFunc {
	return func(context *gin.Context) {
		auth.RegisterAuthentication(context, nil, 1, "customtoken")
	}
}

func waitForConnectedClients(api *API, count int) {
	for i := 0; i < 10; i++ {
		if countClients(api) == count {
			// ok
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
}


================================================
FILE: api/tokens.go
================================================
package api

import (
	"github.com/gotify/server/v2/auth"
)

var generateApplicationToken = auth.GenerateApplicationToken

var generateClientToken = auth.GenerateClientToken

var generateImageName = auth.GenerateImageName


================================================
FILE: api/tokens_test.go
================================================
package api

import (
	"regexp"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestTokenGeneration(t *testing.T) {
	assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken())
	assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken())
	assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName())
}


================================================
FILE: api/user.go
================================================
package api

import (
	"errors"
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/auth/password"
	"github.com/gotify/server/v2/model"
)

// The UserDatabase interface for encapsulating database access.
type UserDatabase interface {
	GetUsers() ([]*model.User, error)
	GetUserByID(id uint) (*model.User, error)
	GetUserByName(name string) (*model.User, error)
	DeleteUserByID(id uint) error
	UpdateUser(user *model.User) error
	CreateUser(user *model.User) error
	CountUser(condition ...interface{}) (int64, error)
}

// UserChangeNotifier notifies listeners for user changes.
type UserChangeNotifier struct {
	userDeletedCallbacks []func(uid uint) error
	userAddedCallbacks   []func(uid uint) error
}

// OnUserDeleted is called on user deletion.
func (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) {
	c.userDeletedCallbacks = append(c.userDeletedCallbacks, cb)
}

// OnUserAdded is called on user creation.
func (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) {
	c.userAddedCallbacks = append(c.userAddedCallbacks, cb)
}

func (c *UserChangeNotifier) fireUserDeleted(uid uint) error {
	for _, cb := range c.userDeletedCallbacks {
		if err := cb(uid); err != nil {
			return err
		}
	}
	return nil
}

func (c *UserChangeNotifier) fireUserAdded(uid uint) error {
	for _, cb := range c.userAddedCallbacks {
		if err := cb(uid); err != nil {
			return err
		}
	}
	return nil
}

// The UserAPI provides handlers for managing users.
type UserAPI struct {
	DB                 UserDatabase
	PasswordStrength   int
	UserChangeNotifier *UserChangeNotifier
	Registration       bool
}

// GetUsers returns all the users
// swagger:operation GET /user user getUsers
//
// Return all users.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	      type: array
//	      items:
//	        $ref: "#/definitions/User"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) GetUsers(ctx *gin.Context) {
	users, err := a.DB.GetUsers()
	if success := successOrAbort(ctx, 500, err); !success {
		return
	}
	var resp []*model.UserExternal
	for _, user := range users {
		resp = append(resp, toExternalUser(user))
	}

	ctx.JSON(200, resp)
}

// GetCurrentUser returns the current user
// swagger:operation GET /current/user user currentUser
//
// Return the current user.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/User"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) GetCurrentUser(ctx *gin.Context) {
	user, err := a.DB.GetUserByID(auth.GetUserID(ctx))
	if success := successOrAbort(ctx, 500, err); !success {
		return
	}
	ctx.JSON(200, toExternalUser(user))
}

// CreateUser create a user.
// swagger:operation POST /user user createUser
//
// Create a user.
//
// With enabled registration: non admin users can be created without authentication.
// With disabled registrations: users can only be created by admin users.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the user to add
//	  required: true
//	  schema:
//	    $ref: "#/definitions/CreateUserExternal"
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/User"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) CreateUser(ctx *gin.Context) {
	user := model.CreateUserExternal{}
	if err := ctx.Bind(&user); err == nil {
		internal := &model.User{
			Name:  user.Name,
			Admin: user.Admin,
			Pass:  password.CreatePassword(user.Pass, a.PasswordStrength),
		}
		existingUser, err := a.DB.GetUserByName(internal.Name)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}

		var requestedBy *model.User
		uid := auth.TryGetUserID(ctx)
		if uid != nil {
			requestedBy, err = a.DB.GetUserByID(*uid)
			if err != nil {
				ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("could not get user: %s", err))
				return
			}
		}

		if requestedBy == nil || !requestedBy.Admin {
			status := http.StatusUnauthorized
			if requestedBy != nil {
				status = http.StatusForbidden
			}
			if !a.Registration {
				ctx.AbortWithError(status, errors.New("you are not allowed to access this api"))
				return
			}
			if internal.Admin {
				ctx.AbortWithError(status, errors.New("you are not allowed to create an admin user"))
				return
			}
		}

		if existingUser == nil {
			if success := successOrAbort(ctx, 500, a.DB.CreateUser(internal)); !success {
				return
			}
			if err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil {
				ctx.AbortWithError(500, err)
				return
			}
			ctx.JSON(200, toExternalUser(internal))
		} else {
			ctx.AbortWithError(400, errors.New("username already exists"))
		}
	}
}

// GetUserByID returns the user by id
// swagger:operation GET /user/{id} user getUser
//
// Get a user.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the user id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/User"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) GetUserByID(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		user, err := a.DB.GetUserByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if user != nil {
			ctx.JSON(200, toExternalUser(user))
		} else {
			ctx.AbortWithError(404, errors.New("user does not exist"))
		}
	})
}

// DeleteUserByID deletes the user by id
// swagger:operation DELETE /user/{id} user deleteUser
//
// Deletes a user.
//
//	---
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the user id
//	  required: true
//	  type: integer
//	  format: int64
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) DeleteUserByID(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		user, err := a.DB.GetUserByID(id)
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		if user != nil {
			adminCount, err := a.DB.CountUser(&model.User{Admin: true})
			if success := successOrAbort(ctx, 500, err); !success {
				return
			}
			if user.Admin && adminCount == 1 {
				ctx.AbortWithError(400, errors.New("cannot delete last admin"))
				return
			}
			if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil {
				ctx.AbortWithError(500, err)
				return
			}
			successOrAbort(ctx, 500, a.DB.DeleteUserByID(id))
		} else {
			ctx.AbortWithError(404, errors.New("user does not exist"))
		}
	})
}

// ChangePassword changes the password from the current user
// swagger:operation POST /current/user/password user updateCurrentUser
//
// Update the password of the current user.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: body
//	  in: body
//	  description: the user
//	  required: true
//	  schema:
//	    $ref: "#/definitions/UserPass"
//	responses:
//	  200:
//	    description: Ok
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) ChangePassword(ctx *gin.Context) {
	pw := model.UserExternalPass{}
	if err := ctx.Bind(&pw); err == nil {
		user, err := a.DB.GetUserByID(auth.GetUserID(ctx))
		if success := successOrAbort(ctx, 500, err); !success {
			return
		}
		user.Pass = password.CreatePassword(pw.Pass, a.PasswordStrength)
		successOrAbort(ctx, 500, a.DB.UpdateUser(user))
	}
}

// UpdateUserByID updates and user by id
// swagger:operation POST /user/{id} user updateUser
//
// Update a user.
//
//	---
//	consumes: [application/json]
//	produces: [application/json]
//	security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
//	parameters:
//	- name: id
//	  in: path
//	  description: the user id
//	  required: true
//	  type: integer
//	  format: int64
//	- name: body
//	  in: body
//	  description: the updated user
//	  required: true
//	  schema:
//	    $ref: "#/definitions/UpdateUserExternal"
//	responses:
//	  200:
//	    description: Ok
//	    schema:
//	        $ref: "#/definitions/User"
//	  400:
//	    description: Bad Request
//	    schema:
//	        $ref: "#/definitions/Error"
//	  401:
//	    description: Unauthorized
//	    schema:
//	        $ref: "#/definitions/Error"
//	  403:
//	    description: Forbidden
//	    schema:
//	        $ref: "#/definitions/Error"
//	  404:
//	    description: Not Found
//	    schema:
//	        $ref: "#/definitions/Error"
func (a *UserAPI) UpdateUserByID(ctx *gin.Context) {
	withID(ctx, "id", func(id uint) {
		var user *model.UpdateUserExternal
		if err := ctx.Bind(&user); err == nil {
			oldUser, err := a.DB.GetUserByID(id)
			if success := successOrAbort(ctx, 500, err); !success {
				return
			}
			if oldUser != nil {
				adminCount, err := a.DB.CountUser(&model.User{Admin: true})
				if success := successOrAbort(ctx, 500, err); !success {
					return
				}
				if !user.Admin && oldUser.Admin && adminCount == 1 {
					ctx.AbortWithError(400, errors.New("cannot delete last admin"))
					return
				}
				internal := &model.User{
					ID:    oldUser.ID,
					Name:  user.Name,
					Admin: user.Admin,
					Pass:  oldUser.Pass,
				}
				if user.Pass != "" {
					internal.Pass = password.CreatePassword(user.Pass, a.PasswordStrength)
				}
				if success := successOrAbort(ctx, 500, a.DB.UpdateUser(internal)); !success {
					return
				}
				ctx.JSON(200, toExternalUser(internal))
			} else {
				ctx.AbortWithError(404, errors.New("user does not exist"))
			}
		}
	})
}

func toExternalUser(internal *model.User) *model.UserExternal {
	return &model.UserExternal{
		Name:  internal.Name,
		Admin: internal.Admin,
		ID:    internal.ID,
	}
}


================================================
FILE: api/user_test.go
================================================
package api

import (
	"errors"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth"
	"github.com/gotify/server/v2/auth/password"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/test"
	"github.com/gotify/server/v2/test/testdb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
)

func TestUserSuite(t *testing.T) {
	suite.Run(t, new(UserSuite))
}

type UserSuite struct {
	suite.Suite
	db             *testdb.Database
	a              *UserAPI
	ctx            *gin.Context
	recorder       *httptest.ResponseRecorder
	notifiedAdd    bool
	notifiedDelete bool
	notifier       *UserChangeNotifier
}

func (s *UserSuite) BeforeTest(suiteName, testName string) {
	mode.Set(mode.TestDev)
	s.recorder = httptest.NewRecorder()
	s.ctx, _ = gin.CreateTestContext(s.recorder)

	s.db = testdb.NewDB(s.T())

	s.notifier = new(UserChangeNotifier)
	s.notifier.OnUserDeleted(func(uint) error {
		s.notifiedDelete = true
		return nil
	})
	s.notifier.OnUserAdded(func(uint) error {
		s.notifiedAdd = true
		return nil
	})
	s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier}
}

func (s *UserSuite) AfterTest(suiteName, testName string) {
	s.db.Close()
}

func (s *UserSuite) Test_GetUsers() {
	first := s.db.NewUser(2)
	second := s.db.NewUser(5)

	s.a.GetUsers(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), []*model.UserExternal{externalOf(first), externalOf(second)}, s.recorder)
}

func (s *UserSuite) Test_GetCurrentUser() {
	user := s.db.NewUser(5)

	test.WithUser(s.ctx, 5)
	s.a.GetCurrentUser(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), externalOf(user), s.recorder)
}

func (s *UserSuite) Test_GetUserByID() {
	user := s.db.NewUser(2)

	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.GetUserByID(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	test.BodyEquals(s.T(), externalOf(user), s.recorder)
}

func (s *UserSuite) Test_GetUserByID_InvalidID() {
	s.db.User(2)
	s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}

	s.a.GetUserByID(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_GetUserByID_UnknownUser() {
	s.db.User(2)

	s.ctx.Params = gin.Params{{Key: "id", Value: "3"}}

	s.a.GetUserByID(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *UserSuite) Test_DeleteUserByID_LastAdmin_Expect400() {
	s.db.CreateUser(&model.User{
		ID:    7,
		Name:  "admin",
		Admin: true,
	})
	s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}

	s.a.DeleteUserByID(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_DeleteUserByID_InvalidID() {
	s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}

	s.a.DeleteUserByID(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_DeleteUserByID_UnknownUser() {
	s.db.User(2)

	s.ctx.Params = gin.Params{{Key: "id", Value: "3"}}

	s.a.DeleteUserByID(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *UserSuite) Test_DeleteUserByID() {
	assert.False(s.T(), s.notifiedDelete)

	s.db.User(2)

	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.a.DeleteUserByID(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	s.db.AssertUserNotExist(2)
	assert.True(s.T(), s.notifiedDelete)
}

func (s *UserSuite) Test_DeleteUserByID_NotifyFail() {
	s.db.User(5)
	s.notifier.OnUserDeleted(func(id uint) error {
		if id == 5 {
			return errors.New("some error")
		}
		return nil
	})

	s.ctx.Params = gin.Params{{Key: "id", Value: "5"}}

	s.a.DeleteUserByID(s.ctx)

	assert.Equal(s.T(), 500, s.recorder.Code)
}

func (s *UserSuite) Test_CreateUser() {
	s.loginAdmin()

	assert.False(s.T(), s.notifiedAdd)
	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	user := &model.UserExternal{ID: 2, Name: "tom", Admin: true}
	test.BodyEquals(s.T(), user, s.recorder)

	if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
		assert.NotNil(s.T(), created)
		assert.True(s.T(), password.ComparePassword(created.Pass, []byte("mylittlepony")))
	}
	assert.True(s.T(), s.notifiedAdd)
}

func (s *UserSuite) Test_CreateUser_ByNonAdmin() {
	s.loginUser()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 403, s.recorder.Code)
}

func (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() {
	s.loginUser()
	s.a.Registration = true

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
		assert.NotNil(s.T(), created)
	}
}

func (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() {
	s.a.Registration = true
	s.loginUser()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 403, s.recorder.Code)
	s.db.AssertUsernameNotExist("tom")
}

func (s *UserSuite) Test_CreateUser_Anonymous() {
	s.noLogin()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 401, s.recorder.Code)
	s.db.AssertUsernameNotExist("tom")
}

func (s *UserSuite) Test_CreateUser_Register_Anonymous() {
	s.a.Registration = true
	s.noLogin()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
		assert.NotNil(s.T(), created)
	}
}

func (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() {
	s.a.Registration = true
	s.noLogin()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 401, s.recorder.Code)
	s.db.AssertUsernameNotExist("tom")
}

func (s *UserSuite) Test_CreateUser_NotifyFail() {
	s.loginAdmin()

	s.notifier.OnUserAdded(func(id uint) error {
		user, err := s.db.GetUserByID(id)
		if err != nil {
			return err
		}
		if user.Name == "eva" {
			return errors.New("some error")
		}
		return nil
	})
	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "eva", "pass": "mylittlepony", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 500, s.recorder.Code)
}

func (s *UserSuite) Test_CreateUser_NoPassword() {
	s.loginAdmin()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_CreateUser_NoName() {
	s.loginAdmin()

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "", "pass": "asd", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_CreateUser_NameAlreadyExists() {
	s.loginAdmin()
	s.db.NewUserWithName(2, "tom")

	s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.CreateUser(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_InvalidID() {
	s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}}

	s.ctx.Request = httptest.NewRequest("POST", "/user/abc", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.UpdateUserByID(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_LastAdmin_Expect400() {
	s.db.CreateUser(&model.User{
		ID:    7,
		Name:  "admin",
		Admin: true,
	})

	s.ctx.Params = gin.Params{{Key: "id", Value: "7"}}

	s.ctx.Request = httptest.NewRequest("POST", "/user/7", strings.NewReader(`{"name": "admin", "pass": "", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")
	s.a.UpdateUserByID(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_UnknownUser() {
	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.UpdateUserByID(s.ctx)

	assert.Equal(s.T(), 404, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() {
	s.db.CreateUser(&model.User{ID: 2, Name: "nico", Pass: password.CreatePassword("old", 5)})

	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.UpdateUserByID(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	user, err := s.db.GetUserByID(2)
	assert.NoError(s.T(), err)
	assert.NotNil(s.T(), user)
	assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old")))
}

func (s *UserSuite) Test_UpdateUserByID_UpdatePassword() {
	s.db.CreateUser(&model.User{ID: 2, Name: "tom", Pass: password.CreatePassword("old", 5)})

	s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}

	s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "new", "admin": true}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.UpdateUserByID(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	user, err := s.db.GetUserByID(2)
	assert.NoError(s.T(), err)
	assert.NotNil(s.T(), user)
	assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new")))
}

func (s *UserSuite) Test_UpdatePassword() {
	s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)})

	test.WithUser(s.ctx, 1)
	s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass": "new"}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.ChangePassword(s.ctx)

	assert.Equal(s.T(), 200, s.recorder.Code)
	user, err := s.db.GetUserByID(1)
	assert.NoError(s.T(), err)
	assert.NotNil(s.T(), user)
	assert.True(s.T(), password.ComparePassword(user.Pass, []byte("new")))
}

func (s *UserSuite) Test_UpdatePassword_EmptyPassword() {
	s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: password.CreatePassword("old", 5)})

	test.WithUser(s.ctx, 1)
	s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass":""}`))
	s.ctx.Request.Header.Set("Content-Type", "application/json")

	s.a.ChangePassword(s.ctx)

	assert.Equal(s.T(), 400, s.recorder.Code)
	user, err := s.db.GetUserByID(1)
	assert.NoError(s.T(), err)
	assert.NotNil(s.T(), user)
	assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old")))
}

func (s *UserSuite) loginAdmin() {
	s.db.CreateUser(&model.User{ID: 1, Name: "admin", Admin: true})
	auth.RegisterAuthentication(s.ctx, nil, 1, "")
}

func (s *UserSuite) loginUser() {
	s.db.CreateUser(&model.User{ID: 1, Name: "user", Admin: false})
	auth.RegisterAuthentication(s.ctx, nil, 1, "")
}

func (s *UserSuite) noLogin() {
	auth.RegisterAuthentication(s.ctx, nil, 0, "")
}

func externalOf(user *model.User) *model.UserExternal {
	return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID}
}


================================================
FILE: app.go
================================================
package main

import (
	"fmt"
	"os"

	"github.com/gotify/server/v2/config"
	"github.com/gotify/server/v2/database"
	"github.com/gotify/server/v2/mode"
	"github.com/gotify/server/v2/model"
	"github.com/gotify/server/v2/router"
	"github.com/gotify/server/v2/runner"
)

var (
	// Version the version of Gotify.
	Version = "unknown"
	// Commit the git commit hash of this version.
	Commit = "unknown"
	// BuildDate the date on which this binary was build.
	BuildDate = "unknown"
	// Mode the build mode.
	Mode = mode.Dev
)

func main() {
	vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate}
	mode.Set(Mode)

	fmt.Println("Starting Gotify version", vInfo.Version+"@"+BuildDate)
	conf := config.Get()

	if conf.PluginsDir != "" {
		if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil {
			panic(err)
		}
	}
	if err := os.MkdirAll(conf.UploadedImagesDir, 0o755); err != nil {
		panic(err)
	}

	db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true)
	if err != nil {
		panic(err)
	}
	defer db.Close()

	engine, closeable := router.Create(db, vInfo, conf)
	defer closeable()

	if err := runner.Run(engine, conf); err != nil {
		fmt.Println("Server error: ", err)
		os.Exit(1)
	}
}


================================================
FILE: auth/authentication.go
================================================
package auth

import (
	"errors"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/gotify/server/v2/auth/password"
	"github.com/gotify/server/v2/model"
)

const (
	headerName = "X-Gotify-Key"
)

// The Database interface for encapsulating database access.
type Database interface {
	GetApplicationByToken(token string) (*model.Application, error)
	GetClientByToken(token string) (*model.Client, error)
	GetPluginConfByToken(token string) (*model.PluginConf, error)
	GetUserByName(name string) (*model.User, error)
	GetUserByID(id uint) (*model.User, error)
	UpdateClientTokensLastUsed(tokens []string, t *time.Time) error
	UpdateApplicationTokenLastUsed(token string, t *time.Time) error
}

// Auth is the provider for authentication middleware.
type Auth struct {
	DB Database
}

type authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error)

// RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied
// with the request. Also the authenticated user must be an administrator.
func (a *Auth) RequireAdmin() gin.HandlerFunc {
	return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
		if user != nil {
			return true, user.Admin, user.ID, nil
		}
		if token, err := a.DB.GetClientByToken(tokenID); err != nil {
			return false, false, 0, err
		} else if token != nil {
			user, err := a.DB.GetUserByID(token.UserID)
			if err != nil {
				return false, false, token.UserID, err
			}
			return true, user.Admin, token.UserID, nil
		}
		return false, false, 0, nil
	})
}

// RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied
// with the request.
func (a *Auth) RequireClient() gin.HandlerFunc {
	return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
		if user != nil {
			return true, true, user.ID, nil
		}
		if client, err := a.DB.GetClientByToken(tokenID); err != nil {
			return false, false, 0, err
		} else if client != nil {
			now := time.Now()
			if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) {
				if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil {
					return false, false, 0, err
				}
			}
			return true, true, client.UserID, nil
		}
		return false, false, 0, nil
	})
}

// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request.
func (a *Auth) RequireApplicationToken() gin.HandlerFunc {
	return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) {
		if user != nil {
			return true, false, 0, nil
		}
		if app, err := a.DB.GetApplicationByToken(tokenID); err != nil {
			return false, false, 0, err
		} else if app != nil {
			now := time.Now()
			if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) {
				if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil {
					return false, false, 0, err
				}
			}
			return true, true, app.UserID, nil
		}
		return false, false, 0, nil
	})
}

func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string {
	if token := a.tokenFromQuery(ctx); token != "" {
		return token
	} else if token := a.tokenFromXGotifyHeader(ctx); token != "" {
		return token
	} else if token := a.tokenFromAuthorizationHeader(ctx); token != "" {
		return token
	}
	return ""
}

func (a *Auth) tokenFromQuery(ctx *gin.Context) string {
	return ctx.Request.URL.Query().Get("token")
}

func (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string {
	return ctx.Request.Header.Get(headerName)
}

func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string {
	const prefix = "Bearer "

	authHeader := ctx.Request.Header.Get("Authorization")
	if authHeader == "" {
		return ""
	}

	if len(authHeader) < len(prefix) || !strings.EqualFold(prefix, authHeader[:len(prefix)]) {
		return ""
	}

	return authHeader[len(prefix):]
}

func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) {
	if name, pass, ok := ctx.Request.BasicAuth(); ok {
		if user, err := a.DB.GetUserByName(name); err != nil {
			return nil, err
		} else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) {
			return user, nil
		}
	}
	return nil, nil
}

func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		token := a.tokenFromQueryOrHeader(ctx)
		user, err := a.userFromBasicAuth(ctx)
		if err != nil {
			ctx.AbortWithError(500, errors.New("an error occurred while authenticating user"))
			return
		}

		if user != nil || token != "" {
			authenticated, ok, userID, err := auth(token, user)
			if err != nil {
				ctx.AbortWithError(500, errors.N
Download .txt
gitextract_5ctt_wna/

├── .dockerignore
├── .editorconfig
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── feature_request.md
│   │   └── questions.md
│   └── workflows/
│       └── build.yml
├── .gitignore
├── .golangci.yml
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── GO_VERSION
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── api/
│   ├── application.go
│   ├── application_test.go
│   ├── client.go
│   ├── client_test.go
│   ├── errorHandling.go
│   ├── errorHandling_test.go
│   ├── health.go
│   ├── health_test.go
│   ├── internalutil.go
│   ├── message.go
│   ├── message_test.go
│   ├── plugin.go
│   ├── plugin_test.go
│   ├── stream/
│   │   ├── client.go
│   │   ├── once.go
│   │   ├── once_test.go
│   │   ├── stream.go
│   │   └── stream_test.go
│   ├── tokens.go
│   ├── tokens_test.go
│   ├── user.go
│   └── user_test.go
├── app.go
├── auth/
│   ├── authentication.go
│   ├── authentication_test.go
│   ├── cors.go
│   ├── cors_test.go
│   ├── password/
│   │   ├── password.go
│   │   └── password_test.go
│   ├── token.go
│   ├── token_test.go
│   ├── util.go
│   └── util_test.go
├── config/
│   ├── config.go
│   └── config_test.go
├── config.example.yml
├── database/
│   ├── application.go
│   ├── application_test.go
│   ├── client.go
│   ├── client_test.go
│   ├── database.go
│   ├── database_test.go
│   ├── message.go
│   ├── message_test.go
│   ├── migration_test.go
│   ├── ping.go
│   ├── ping_test.go
│   ├── plugin.go
│   ├── plugin_test.go
│   ├── user.go
│   └── user_test.go
├── docker/
│   └── Dockerfile
├── docs/
│   ├── package.go
│   ├── spec.json
│   ├── swagger.go
│   ├── swagger_test.go
│   ├── ui.go
│   └── ui_test.go
├── error/
│   ├── handler.go
│   ├── handler_test.go
│   ├── notfound.go
│   └── notfound_test.go
├── fracdex/
│   ├── fracdex.go
│   └── fracdex_test.go
├── go.mod
├── go.sum
├── mode/
│   ├── mode.go
│   └── mode_test.go
├── model/
│   ├── application.go
│   ├── client.go
│   ├── error.go
│   ├── health.go
│   ├── message.go
│   ├── paging.go
│   ├── pluginconf.go
│   ├── user.go
│   └── version.go
├── plugin/
│   ├── compat/
│   │   ├── instance.go
│   │   ├── plugin.go
│   │   ├── plugin_test.go
│   │   ├── v1.go
│   │   ├── v1_test.go
│   │   ├── wrap.go
│   │   ├── wrap_test.go
│   │   ├── wrap_test_norace.go
│   │   └── wrap_test_race.go
│   ├── example/
│   │   ├── clock/
│   │   │   └── main.go
│   │   ├── echo/
│   │   │   └── echo.go
│   │   └── minimal/
│   │       └── main.go
│   ├── manager.go
│   ├── manager_test.go
│   ├── manager_test_norace.go
│   ├── manager_test_race.go
│   ├── messagehandler.go
│   ├── pluginenabled.go
│   ├── pluginenabled_test.go
│   ├── storagehandler.go
│   └── testing/
│       ├── broken/
│       │   ├── cantinstantiate/
│       │   │   └── main.go
│       │   ├── malformedconstructor/
│       │   │   └── main.go
│       │   ├── noinstance/
│       │   │   └── main.go
│       │   ├── nothing/
│       │   │   └── main.go
│       │   └── unknowninfo/
│       │       └── main.go
│       └── mock/
│           └── mock.go
├── renovate.json
├── router/
│   ├── router.go
│   └── router_test.go
├── runner/
│   ├── runner.go
│   ├── umask.go
│   └── umask_fallback.go
├── test/
│   ├── asserts.go
│   ├── asserts_test.go
│   ├── assets/
│   │   ├── image-header-with.html
│   │   └── text.txt
│   ├── auth.go
│   ├── auth_test.go
│   ├── filepath.go
│   ├── filepath_test.go
│   ├── testdb/
│   │   ├── database.go
│   │   └── database_test.go
│   ├── tmpdir.go
│   ├── tmpdir_test.go
│   ├── token.go
│   └── token_test.go
└── ui/
    ├── .gitignore
    ├── .prettierrc
    ├── .yarnrc
    ├── eslint.config.mjs
    ├── index.html
    ├── package.json
    ├── public/
    │   ├── manifest.json
    │   └── static/
    │       └── notification.ogg
    ├── serve.go
    ├── src/
    │   ├── CurrentUser.ts
    │   ├── apiAuth.ts
    │   ├── application/
    │   │   ├── AddApplicationDialog.tsx
    │   │   ├── AppStore.ts
    │   │   ├── Applications.tsx
    │   │   └── UpdateApplicationDialog.tsx
    │   ├── client/
    │   │   ├── AddClientDialog.tsx
    │   │   ├── ClientStore.ts
    │   │   ├── Clients.tsx
    │   │   └── UpdateClientDialog.tsx
    │   ├── common/
    │   │   ├── BaseStore.ts
    │   │   ├── ConfirmDialog.tsx
    │   │   ├── ConnectionErrorBanner.tsx
    │   │   ├── Container.tsx
    │   │   ├── CopyableSecret.tsx
    │   │   ├── DefaultPage.tsx
    │   │   ├── LastUsedCell.tsx
    │   │   ├── LoadingSpinner.tsx
    │   │   ├── Markdown.tsx
    │   │   ├── NumberField.tsx
    │   │   ├── ScrollUpButton.tsx
    │   │   ├── SettingsDialog.tsx
    │   │   └── TimeAgoFormatter.ts
    │   ├── config.ts
    │   ├── index.tsx
    │   ├── layout/
    │   │   ├── Header.tsx
    │   │   ├── Layout.tsx
    │   │   ├── Navigation.tsx
    │   │   └── theme.ts
    │   ├── message/
    │   │   ├── Message.tsx
    │   │   ├── Messages.tsx
    │   │   ├── MessagesStore.ts
    │   │   ├── PushMessageDialog.tsx
    │   │   ├── WebSocketStore.ts
    │   │   └── extras.ts
    │   ├── plugin/
    │   │   ├── PluginDetailView.tsx
    │   │   ├── PluginStore.ts
    │   │   └── Plugins.tsx
    │   ├── react-app-env.d.ts
    │   ├── reactions.ts
    │   ├── registerServiceWorker.ts
    │   ├── snack/
    │   │   ├── SnackManager.ts
    │   │   └── browserNotification.ts
    │   ├── stores.tsx
    │   ├── tests/
    │   │   ├── application.test.ts
    │   │   ├── authentication.ts
    │   │   ├── client.test.ts
    │   │   ├── message.test.ts
    │   │   ├── plugin.test.ts
    │   │   ├── selector.ts
    │   │   ├── setup.ts
    │   │   ├── user.test.ts
    │   │   └── utils.ts
    │   ├── typedef/
    │   │   ├── notifyjs.d.ts
    │   │   └── react-timeago.d.ts
    │   ├── types.ts
    │   └── user/
    │       ├── AddEditUserDialog.tsx
    │       ├── Login.tsx
    │       ├── Register.tsx
    │       ├── UserStore.ts
    │       └── Users.tsx
    ├── tsconfig.json
    ├── tsconfig.prod.json
    ├── tsconfig.test.json
    ├── vite-env.d.ts
    ├── vite.config.ts
    └── vitest.config.js
Download .txt
SYMBOL INDEX (914 symbols across 157 files)

FILE: api/application.go
  type ApplicationDatabase (line 19) | type ApplicationDatabase interface
  type ApplicationAPI (line 29) | type ApplicationAPI struct
    method CreateApplication (line 92) | func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
    method GetApplications (line 137) | func (a *ApplicationAPI) GetApplications(ctx *gin.Context) {
    method DeleteApplication (line 184) | func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
    method UpdateApplication (line 250) | func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
    method UploadApplicationImage (line 325) | func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) {
    method RemoveApplicationImage (line 418) | func (a *ApplicationAPI) RemoveApplicationImage(ctx *gin.Context) {
    method applicationExists (line 453) | func (a *ApplicationAPI) applicationExists(token string) bool {
  type ApplicationParams (line 39) | type ApplicationParams struct
  function withResolvedImage (line 443) | func withResolvedImage(app *model.Application) *model.Application {
  function exist (line 458) | func exist(path string) bool {
  function generateNonExistingImageName (line 465) | func generateNonExistingImageName(imgDir string, gen func() string) stri...
  function ValidApplicationImageExt (line 474) | func ValidApplicationImageExt(ext string) bool {
  function handleApplicationError (line 483) | func handleApplicationError(ctx *gin.Context, err error) {

FILE: api/application_test.go
  function TestApplicationSuite (line 31) | func TestApplicationSuite(t *testing.T) {
  type ApplicationSuite (line 35) | type ApplicationSuite struct
    method BeforeTest (line 48) | func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 61) | func (s *ApplicationSuite) AfterTest(suiteName, testName string) {
    method Test_CreateApplication_mapAllParameters (line 67) | func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
    method Test_ensureApplicationHasCorrectJsonRepresentation (line 88) | func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonReprese...
    method Test_CreateApplication_expectBadRequestOnEmptyName (line 103) | func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEm...
    method Test_CreateApplication_ignoresReadOnlyPropertiesInParams (line 116) | func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPrope...
    method Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner (line 147) | func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurr...
    method Test_CreateApplication_onlyRequiredParameters (line 161) | func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParamete...
    method Test_CreateApplication_returnsApplicationWithID (line 175) | func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWi...
    method Test_CreateApplication_withExistingToken (line 195) | func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {
    method Test_Sorting (line 211) | func (s *ApplicationSuite) Test_Sorting() {
    method Test_GetApplications (line 251) | func (s *ApplicationSuite) Test_GetApplications() {
    method Test_GetApplications_WithImage (line 267) | func (s *ApplicationSuite) Test_GetApplications_WithImage() {
    method Test_DeleteApplication_internal_expectBadRequest (line 285) | func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRe...
    method Test_DeleteApplication_expectNotFound (line 297) | func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() {
    method Test_DeleteApplication (line 309) | func (s *ApplicationSuite) Test_DeleteApplication() {
    method Test_UploadAppImage_NoImageProvided_expectBadRequest (line 322) | func (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectB...
    method Test_UploadAppImage_OtherErrors_expectServerError (line 339) | func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServe...
    method Test_UploadAppImage_WithImageFile_expectSuccess (line 356) | func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuc...
    method Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName (line 382) | func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExs...
    method Test_UploadAppImage_WithImageFile_DeleteExistingImage (line 411) | func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExi...
    method Test_UploadAppImage_WithTextFile_expectBadRequest (line 433) | func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadR...
    method Test_UploadAppImage_WithHtmlFileHavingImageHeader (line 449) | func (s *ApplicationSuite) Test_UploadAppImage_WithHtmlFileHavingImage...
    method Test_UploadAppImage_expectNotFound (line 465) | func (s *ApplicationSuite) Test_UploadAppImage_expectNotFound() {
    method Test_RemoveAppImage_expectNotFound (line 477) | func (s *ApplicationSuite) Test_RemoveAppImage_expectNotFound() {
    method Test_RemoveAppImage_noCustomizedImage (line 489) | func (s *ApplicationSuite) Test_RemoveAppImage_noCustomizedImage() {
    method Test_RemoveAppImage_expectSuccess (line 500) | func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() {
    method Test_UpdateApplicationNameAndDescription_expectSuccess (line 518) | func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_ex...
    method Test_UpdateApplicationName_expectSuccess (line 541) | func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {
    method Test_UpdateApplicationDefaultPriority_expectSuccess (line 564) | func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expec...
    method Test_UpdateApplication_preservesImageAndSortKey (line 588) | func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSor...
    method Test_UpdateApplication_setEmptyDescription (line 607) | func (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() {
    method Test_UpdateApplication_expectNotFound (line 624) | func (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() {
    method Test_UpdateApplication_WithMissingAttributes_expectBadRequest (line 632) | func (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttribute...
    method Test_UpdateApplication_WithoutPermission_expectNotFound (line 639) | func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_ex...
    method Test_UpdateApplication_duplicateSortKey (line 650) | func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() {
    method withFormData (line 665) | func (s *ApplicationSuite) withFormData(formData string) {
    method withJSON (line 670) | func (s *ApplicationSuite) withJSON(value interface{}) {
  function upload (line 677) | func upload(values map[string]*os.File) (contentType string, buffer byte...
  function mustOpen (line 694) | func mustOpen(f string) *os.File {
  function fakeImage (line 702) | func fakeImage(t *testing.T, path string) {

FILE: api/client.go
  type ClientDatabase (line 12) | type ClientDatabase interface
  type ClientAPI (line 22) | type ClientAPI struct
    method UpdateClient (line 84) | func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
    method CreateClient (line 139) | func (a *ClientAPI) CreateClient(ctx *gin.Context) {
    method GetClients (line 179) | func (a *ClientAPI) GetClients(ctx *gin.Context) {
    method DeleteClient (line 223) | func (a *ClientAPI) DeleteClient(ctx *gin.Context) {
    method clientExists (line 238) | func (a *ClientAPI) clientExists(token string) bool {
  type ClientParams (line 33) | type ClientParams struct

FILE: api/client_test.go
  function TestClientSuite (line 23) | func TestClientSuite(t *testing.T) {
  type ClientSuite (line 27) | type ClientSuite struct
    method BeforeTest (line 38) | func (s *ClientSuite) BeforeTest(suiteName, testName string) {
    method notify (line 50) | func (s *ClientSuite) notify(uint, string) {
    method AfterTest (line 54) | func (s *ClientSuite) AfterTest(suiteName, testName string) {
    method Test_ensureClientHasCorrectJsonRepresentation (line 59) | func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
    method Test_CreateClient_mapAllParameters (line 64) | func (s *ClientSuite) Test_CreateClient_mapAllParameters() {
    method Test_CreateClient_ignoresReadOnlyPropertiesInParams (line 79) | func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInPar...
    method Test_CreateClient_expectBadRequestOnEmptyName (line 94) | func (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() {
    method Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner (line 108) | func (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsN...
    method Test_CreateClient_returnsClientWithID (line 122) | func (s *ClientSuite) Test_CreateClient_returnsClientWithID() {
    method Test_CreateClient_withExistingToken (line 135) | func (s *ClientSuite) Test_CreateClient_withExistingToken() {
    method Test_GetClients (line 148) | func (s *ClientSuite) Test_GetClients() {
    method Test_DeleteClient_expectNotFound (line 162) | func (s *ClientSuite) Test_DeleteClient_expectNotFound() {
    method Test_DeleteClient (line 174) | func (s *ClientSuite) Test_DeleteClient() {
    method Test_UpdateClient_expectSuccess (line 190) | func (s *ClientSuite) Test_UpdateClient_expectSuccess() {
    method Test_UpdateClient_expectNotFound (line 211) | func (s *ClientSuite) Test_UpdateClient_expectNotFound() {
    method Test_UpdateClient_WithMissingAttributes_expectBadRequest (line 219) | func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBa...
    method withFormData (line 226) | func (s *ClientSuite) withFormData(formData string) {
  function withURL (line 231) | func withURL(ctx *gin.Context, scheme, host string) {

FILE: api/errorHandling.go
  function successOrAbort (line 5) | func successOrAbort(ctx *gin.Context, code int, err error) (success bool) {

FILE: api/errorHandling_test.go
  function TestErrorHandling (line 11) | func TestErrorHandling(t *testing.T) {

FILE: api/health.go
  type HealthDatabase (line 9) | type HealthDatabase interface
  type HealthAPI (line 14) | type HealthAPI struct
    method Health (line 34) | func (a *HealthAPI) Health(ctx *gin.Context) {

FILE: api/health_test.go
  function TestHealthSuite (line 15) | func TestHealthSuite(t *testing.T) {
  type HealthSuite (line 19) | type HealthSuite struct
    method BeforeTest (line 27) | func (s *HealthSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 36) | func (s *HealthSuite) AfterTest(suiteName, testName string) {
    method TestHealthSuccess (line 40) | func (s *HealthSuite) TestHealthSuccess() {
    method TestDatabaseFailure (line 45) | func (s *HealthSuite) TestDatabaseFailure() {

FILE: api/internalutil.go
  function withID (line 11) | func withID(ctx *gin.Context, name string, f func(id uint)) {

FILE: api/message.go
  type MessageDatabase (line 18) | type MessageDatabase interface
  type Notifier (line 33) | type Notifier interface
  type MessageAPI (line 38) | type MessageAPI struct
    method GetMessages (line 89) | func (a *MessageAPI) GetMessages(ctx *gin.Context) {
    method GetMessagesWithApplication (line 180) | func (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) {
    method DeleteMessages (line 220) | func (a *MessageAPI) DeleteMessages(ctx *gin.Context) {
    method DeleteMessageWithApplication (line 259) | func (a *MessageAPI) DeleteMessageWithApplication(ctx *gin.Context) {
    method DeleteMessage (line 307) | func (a *MessageAPI) DeleteMessage(ctx *gin.Context) {
    method CreateMessage (line 364) | func (a *MessageAPI) CreateMessage(ctx *gin.Context) {
  type pagingParams (line 43) | type pagingParams struct
  function buildWithPaging (line 101) | func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []...
  function withPaging (line 122) | func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) {
  function toInternalMessage (line 391) | func toInternalMessage(msg *model.MessageExternal) *model.Message {
  function toExternalMessage (line 409) | func toExternalMessage(msg *model.Message) *model.MessageExternal {
  function toExternalMessages (line 425) | func toExternalMessages(msg []*model.Message) []*model.MessageExternal {

FILE: api/message_test.go
  function TestMessageSuite (line 20) | func TestMessageSuite(t *testing.T) {
  type MessageSuite (line 24) | type MessageSuite struct
    method BeforeTest (line 33) | func (s *MessageSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 43) | func (s *MessageSuite) AfterTest(string, string) {
    method Notify (line 47) | func (s *MessageSuite) Notify(userID uint, msg *model.MessageExternal) {
    method Test_ensureCorrectJsonRepresentation (line 51) | func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() {
    method Test_GetMessages (line 67) | func (s *MessageSuite) Test_GetMessages() {
    method Test_GetMessages_WithLimit_ReturnsNext (line 85) | func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() {
    method Test_GetMessages_WithLimit_WithSince_ReturnsNext (line 109) | func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNex...
    method Test_GetMessages_BadRequestOnInvalidLimit (line 132) | func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() {
    method Test_GetMessages_BadRequestOnInvalidLimit_Negative (line 141) | func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negat...
    method Test_GetMessagesWithToken_InvalidLimit_BadRequest (line 150) | func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadReque...
    method Test_GetMessagesWithToken (line 161) | func (s *MessageSuite) Test_GetMessagesWithToken() {
    method Test_GetMessagesWithToken_WithLimit_ReturnsNext (line 176) | func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext...
    method Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext (line 199) | func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_R...
    method Test_GetMessagesWithToken_withWrongUser_expectNotFound (line 221) | func (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectN...
    method Test_DeleteMessage_invalidID (line 232) | func (s *MessageSuite) Test_DeleteMessage_invalidID() {
    method Test_DeleteMessage_notExistingID (line 240) | func (s *MessageSuite) Test_DeleteMessage_notExistingID() {
    method Test_DeleteMessage_existingIDButNotOwner (line 249) | func (s *MessageSuite) Test_DeleteMessage_existingIDButNotOwner() {
    method Test_DeleteMessage (line 260) | func (s *MessageSuite) Test_DeleteMessage() {
    method Test_DeleteMessageWithID (line 271) | func (s *MessageSuite) Test_DeleteMessageWithID() {
    method Test_DeleteMessageWithToken_notExistingID (line 282) | func (s *MessageSuite) Test_DeleteMessageWithToken_notExistingID() {
    method Test_DeleteMessageWithToken_notOwner (line 293) | func (s *MessageSuite) Test_DeleteMessageWithToken_notOwner() {
    method Test_DeleteMessages (line 305) | func (s *MessageSuite) Test_DeleteMessages() {
    method Test_CreateMessage_onJson_allParams (line 319) | func (s *MessageSuite) Test_CreateMessage_onJson_allParams() {
    method Test_CreateMessage_WithDefaultPriority (line 341) | func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() {
    method Test_CreateMessage_WithTitle (line 363) | func (s *MessageSuite) Test_CreateMessage_WithTitle() {
    method Test_CreateMessage_failWhenNoMessage (line 384) | func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() {
    method Test_CreateMessage_WithoutTitle (line 400) | func (s *MessageSuite) Test_CreateMessage_WithoutTitle() {
    method Test_CreateMessage_WithBlankTitle (line 417) | func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() {
    method Test_CreateMessage_IgnoreID (line 434) | func (s *MessageSuite) Test_CreateMessage_IgnoreID() {
    method Test_CreateMessage_WithExtras (line 450) | func (s *MessageSuite) Test_CreateMessage_WithExtras() {
    method Test_CreateMessage_failWhenPriorityNotNumber (line 489) | func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() {
    method Test_CreateMessage_onQueryData (line 505) | func (s *MessageSuite) Test_CreateMessage_onQueryData() {
    method Test_CreateMessage_onFormData (line 528) | func (s *MessageSuite) Test_CreateMessage_onFormData() {
    method withURL (line 550) | func (s *MessageSuite) withURL(scheme, host, path, query string) {
  function intPtr (line 555) | func intPtr(x int) *int {

FILE: api/plugin.go
  type PluginDatabase (line 18) | type PluginDatabase interface
  type PluginAPI (line 25) | type PluginAPI struct
    method GetPlugins (line 63) | func (c *PluginAPI) GetPlugins(ctx *gin.Context) {
    method EnablePlugin (line 124) | func (c *PluginAPI) EnablePlugin(ctx *gin.Context) {
    method DisablePlugin (line 182) | func (c *PluginAPI) DisablePlugin(ctx *gin.Context) {
    method GetDisplay (line 242) | func (c *PluginAPI) GetDisplay(ctx *gin.Context) {
    method GetConfig (line 303) | func (c *PluginAPI) GetConfig(ctx *gin.Context) {
    method UpdateConfig (line 367) | func (c *PluginAPI) UpdateConfig(ctx *gin.Context) {
  function isPluginOwner (line 406) | func isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool {
  function supportOrAbort (line 410) | func supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, mo...

FILE: api/plugin_test.go
  function TestPluginSuite (line 24) | func TestPluginSuite(t *testing.T) {
  type PluginSuite (line 28) | type PluginSuite struct
    method BeforeTest (line 38) | func (s *PluginSuite) BeforeTest(suiteName, testName string) {
    method getDanglingConf (line 64) | func (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf {
    method resetRecorder (line 70) | func (s *PluginSuite) resetRecorder() {
    method AfterTest (line 75) | func (s *PluginSuite) AfterTest(suiteName, testName string) {
    method Notify (line 79) | func (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) {
    method Test_GetPlugins (line 83) | func (s *PluginSuite) Test_GetPlugins() {
    method Test_EnableDisablePlugin (line 100) | func (s *PluginSuite) Test_EnableDisablePlugin() {
    method Test_EnableDisablePlugin_EnableReturnsError_expect500 (line 162) | func (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expe...
    method Test_EnableDisablePlugin_DisableReturnsError_expect500 (line 184) | func (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_exp...
    method Test_EnableDisablePlugin_incorrectUser_expectNotFound (line 207) | func (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNot...
    method Test_EnableDisablePlugin_nonExistPlugin_expectNotFound (line 239) | func (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNo...
    method Test_EnableDisablePlugin_danglingConf_expectNotFound (line 263) | func (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotF...
    method Test_GetDisplay (line 289) | func (s *PluginSuite) Test_GetDisplay() {
    method Test_GetDisplay_NotImplemented_expectEmptyString (line 310) | func (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString...
    method Test_GetDisplay_incorrectUser_expectNotFound (line 332) | func (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() {
    method Test_GetDisplay_danglingConf_expectNotFound (line 352) | func (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() {
    method Test_GetDisplay_nonExistPlugin_expectNotFound (line 366) | func (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() {
    method Test_GetConfig (line 378) | func (s *PluginSuite) Test_GetConfig() {
    method Test_GetConfg_notImplemeted_expect400 (line 400) | func (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() {
    method Test_GetConfig_incorrectUser_expectNotFound (line 421) | func (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() {
    method Test_GetConfig_danglingConf_expectNotFound (line 436) | func (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() {
    method Test_GetConfig_nonExistPlugin_expectNotFound (line 450) | func (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() {
    method Test_UpdateConfig (line 462) | func (s *PluginSuite) Test_UpdateConfig() {
    method Test_UpdateConfig_invalidConfig_expect400 (line 497) | func (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() {
    method Test_UpdateConfig_malformedYAML_expect400 (line 534) | func (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() {
    method Test_UpdateConfig_ioError_expect500 (line 566) | func (s *PluginSuite) Test_UpdateConfig_ioError_expect500() {
    method Test_UpdateConfig_notImplemented_expect400 (line 596) | func (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() {
    method Test_UpdateConfig_incorrectUser_expectNotFound (line 624) | func (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() {
    method Test_UpdateConfig_danglingConf_expectNotFound (line 660) | func (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() {
    method Test_UpdateConfig_nonExistPlugin_expectNotFound (line 681) | func (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() {

FILE: api/stream/client.go
  constant writeWait (line 12) | writeWait = 2 * time.Second
  type client (line 23) | type client struct
    method Close (line 43) | func (c *client) Close() {
    method NotifyClose (line 51) | func (c *client) NotifyClose() {
    method startReading (line 61) | func (c *client) startReading(pongWait time.Duration) {
    method startWriteHandler (line 81) | func (c *client) startWriteHandler(pingPeriod time.Duration) {
  function newClient (line 32) | func newClient(conn *websocket.Conn, userID uint, token string, onClose ...
  function printWebSocketError (line 110) | func printWebSocketError(prefix string, err error) {

FILE: api/stream/once.go
  type once (line 14) | type once struct
    method Do (line 19) | func (o *once) Do(f func()) {
    method mayExecute (line 28) | func (o *once) mayExecute() bool {

FILE: api/stream/once_test.go
  function Test_Execute (line 10) | func Test_Execute(t *testing.T) {

FILE: api/stream/stream.go
  type API (line 19) | type API struct
    method CollectConnectedClientTokens (line 41) | func (a *API) CollectConnectedClientTokens() []string {
    method NotifyDeletedUser (line 54) | func (a *API) NotifyDeletedUser(userID uint) error {
    method NotifyDeletedClient (line 67) | func (a *API) NotifyDeletedClient(userID uint, token string) {
    method Notify (line 83) | func (a *API) Notify(userID uint, msg *model.MessageExternal) {
    method remove (line 93) | func (a *API) remove(remove *client) {
    method register (line 106) | func (a *API) register(client *client) {
    method Handle (line 143) | func (a *API) Handle(ctx *gin.Context) {
    method Close (line 157) | func (a *API) Close() {
  function New (line 31) | func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins ...
  function uniq (line 171) | func uniq[T comparable](s []T) []T {
  function isAllowedOrigin (line 183) | func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) b...
  function newUpgrader (line 207) | func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {
  function compileAllowedWebSocketOrigins (line 221) | func compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.R...

FILE: api/stream/stream_test.go
  function TestFailureOnNormalHttpRequest (line 24) | func TestFailureOnNormalHttpRequest(t *testing.T) {
  function TestWriteMessageFails (line 39) | func TestWriteMessageFails(t *testing.T) {
  function TestWritePingFails (line 69) | func TestWritePingFails(t *testing.T) {
  function TestPing (line 102) | func TestPing(t *testing.T) {
  function TestCloseClientOnNotReading (line 138) | func TestCloseClientOnNotReading(t *testing.T) {
  function TestMessageDirectlyAfterConnect (line 160) | func TestMessageDirectlyAfterConnect(t *testing.T) {
  function TestDeleteClientShouldCloseConnection (line 178) | func TestDeleteClientShouldCloseConnection(t *testing.T) {
  function TestDeleteMultipleClients (line 201) | func TestDeleteMultipleClients(t *testing.T) {
  function TestDeleteUser (line 264) | func TestDeleteUser(t *testing.T) {
  function TestCollectConnectedClientTokens (line 326) | func TestCollectConnectedClientTokens(t *testing.T) {
  function TestMultipleClients (line 363) | func TestMultipleClients(t *testing.T) {
  function Test_sameOrigin_returnsTrue (line 440) | func Test_sameOrigin_returnsTrue(t *testing.T) {
  function Test_sameOrigin_returnsTrue_withCustomPort (line 448) | func Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) {
  function Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin (line 456) | func Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t...
  function Test_isAllowedOriginMatching (line 464) | func Test_isAllowedOriginMatching(t *testing.T) {
  function Test_emptyOrigin_returnsTrue (line 479) | func Test_emptyOrigin_returnsTrue(t *testing.T) {
  function Test_otherOrigin_returnsFalse (line 486) | func Test_otherOrigin_returnsFalse(t *testing.T) {
  function Test_invalidOrigin_returnsFalse (line 494) | func Test_invalidOrigin_returnsFalse(t *testing.T) {
  function Test_compileAllowedWebSocketOrigins (line 502) | func Test_compileAllowedWebSocketOrigins(t *testing.T) {
  function clients (line 507) | func clients(api *API, user uint) []*client {
  function countClients (line 514) | func countClients(a *API) int {
  function testClient (line 525) | func testClient(t *testing.T, url string) *testingClient {
  function startReading (line 531) | func startReading(client *testingClient) {
  function createClient (line 546) | func createClient(t *testing.T, url string) *testingClient {
  type testingClient (line 555) | type testingClient struct
    method expectMessage (line 561) | func (c *testingClient) expectMessage(expected *model.MessageExternal) {
    method expectNoMessage (line 582) | func (c *testingClient) expectNoMessage() {
  function expectMessage (line 570) | func expectMessage(expected *model.MessageExternal, clients ...*testingC...
  function expectNoMessage (line 576) | func expectNoMessage(clients ...*testingClient) {
  function bootTestServer (line 591) | func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) {
  function wsURL (line 602) | func wsURL(httpURL string) string {
  function staticUserID (line 606) | func staticUserID() gin.HandlerFunc {
  function waitForConnectedClients (line 612) | func waitForConnectedClients(api *API, count int) {

FILE: api/tokens_test.go
  function TestTokenGeneration (line 10) | func TestTokenGeneration(t *testing.T) {

FILE: api/user.go
  type UserDatabase (line 15) | type UserDatabase interface
  type UserChangeNotifier (line 26) | type UserChangeNotifier struct
    method OnUserDeleted (line 32) | func (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) {
    method OnUserAdded (line 37) | func (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) {
    method fireUserDeleted (line 41) | func (c *UserChangeNotifier) fireUserDeleted(uid uint) error {
    method fireUserAdded (line 50) | func (c *UserChangeNotifier) fireUserAdded(uid uint) error {
  type UserAPI (line 60) | type UserAPI struct
    method GetUsers (line 90) | func (a *UserAPI) GetUsers(ctx *gin.Context) {
    method GetCurrentUser (line 124) | func (a *UserAPI) GetCurrentUser(ctx *gin.Context) {
    method CreateUser (line 168) | func (a *UserAPI) CreateUser(ctx *gin.Context) {
    method GetUserByID (line 258) | func (a *UserAPI) GetUserByID(ctx *gin.Context) {
    method DeleteUserByID (line 306) | func (a *UserAPI) DeleteUserByID(ctx *gin.Context) {
    method ChangePassword (line 363) | func (a *UserAPI) ChangePassword(ctx *gin.Context) {
    method UpdateUserByID (line 418) | func (a *UserAPI) UpdateUserByID(ctx *gin.Context) {
  function toExternalUser (line 455) | func toExternalUser(internal *model.User) *model.UserExternal {

FILE: api/user_test.go
  function TestUserSuite (line 20) | func TestUserSuite(t *testing.T) {
  type UserSuite (line 24) | type UserSuite struct
    method BeforeTest (line 35) | func (s *UserSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 54) | func (s *UserSuite) AfterTest(suiteName, testName string) {
    method Test_GetUsers (line 58) | func (s *UserSuite) Test_GetUsers() {
    method Test_GetCurrentUser (line 68) | func (s *UserSuite) Test_GetCurrentUser() {
    method Test_GetUserByID (line 78) | func (s *UserSuite) Test_GetUserByID() {
    method Test_GetUserByID_InvalidID (line 89) | func (s *UserSuite) Test_GetUserByID_InvalidID() {
    method Test_GetUserByID_UnknownUser (line 98) | func (s *UserSuite) Test_GetUserByID_UnknownUser() {
    method Test_DeleteUserByID_LastAdmin_Expect400 (line 108) | func (s *UserSuite) Test_DeleteUserByID_LastAdmin_Expect400() {
    method Test_DeleteUserByID_InvalidID (line 121) | func (s *UserSuite) Test_DeleteUserByID_InvalidID() {
    method Test_DeleteUserByID_UnknownUser (line 129) | func (s *UserSuite) Test_DeleteUserByID_UnknownUser() {
    method Test_DeleteUserByID (line 139) | func (s *UserSuite) Test_DeleteUserByID() {
    method Test_DeleteUserByID_NotifyFail (line 153) | func (s *UserSuite) Test_DeleteUserByID_NotifyFail() {
    method Test_CreateUser (line 169) | func (s *UserSuite) Test_CreateUser() {
    method Test_CreateUser_ByNonAdmin (line 189) | func (s *UserSuite) Test_CreateUser_ByNonAdmin() {
    method Test_CreateUser_Register_ByNonAdmin (line 200) | func (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() {
    method Test_CreateUser_Register_Admin_ByNonAdmin (line 215) | func (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() {
    method Test_CreateUser_Anonymous (line 228) | func (s *UserSuite) Test_CreateUser_Anonymous() {
    method Test_CreateUser_Register_Anonymous (line 240) | func (s *UserSuite) Test_CreateUser_Register_Anonymous() {
    method Test_CreateUser_Register_Admin_Anonymous (line 255) | func (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() {
    method Test_CreateUser_NotifyFail (line 268) | func (s *UserSuite) Test_CreateUser_NotifyFail() {
    method Test_CreateUser_NoPassword (line 289) | func (s *UserSuite) Test_CreateUser_NoPassword() {
    method Test_CreateUser_NoName (line 300) | func (s *UserSuite) Test_CreateUser_NoName() {
    method Test_CreateUser_NameAlreadyExists (line 311) | func (s *UserSuite) Test_CreateUser_NameAlreadyExists() {
    method Test_UpdateUserByID_InvalidID (line 323) | func (s *UserSuite) Test_UpdateUserByID_InvalidID() {
    method Test_UpdateUserByID_LastAdmin_Expect400 (line 334) | func (s *UserSuite) Test_UpdateUserByID_LastAdmin_Expect400() {
    method Test_UpdateUserByID_UnknownUser (line 350) | func (s *UserSuite) Test_UpdateUserByID_UnknownUser() {
    method Test_UpdateUserByID_UpdateNotPassword (line 361) | func (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() {
    method Test_UpdateUserByID_UpdatePassword (line 378) | func (s *UserSuite) Test_UpdateUserByID_UpdatePassword() {
    method Test_UpdatePassword (line 395) | func (s *UserSuite) Test_UpdatePassword() {
    method Test_UpdatePassword_EmptyPassword (line 411) | func (s *UserSuite) Test_UpdatePassword_EmptyPassword() {
    method loginAdmin (line 427) | func (s *UserSuite) loginAdmin() {
    method loginUser (line 432) | func (s *UserSuite) loginUser() {
    method noLogin (line 437) | func (s *UserSuite) noLogin() {
  function externalOf (line 441) | func externalOf(user *model.User) *model.UserExternal {

FILE: app.go
  function main (line 26) | func main() {

FILE: auth/authentication.go
  constant headerName (line 14) | headerName = "X-Gotify-Key"
  type Database (line 18) | type Database interface
  type Auth (line 29) | type Auth struct
    method RequireAdmin (line 37) | func (a *Auth) RequireAdmin() gin.HandlerFunc {
    method RequireClient (line 57) | func (a *Auth) RequireClient() gin.HandlerFunc {
    method RequireApplicationToken (line 78) | func (a *Auth) RequireApplicationToken() gin.HandlerFunc {
    method tokenFromQueryOrHeader (line 98) | func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string {
    method tokenFromQuery (line 109) | func (a *Auth) tokenFromQuery(ctx *gin.Context) string {
    method tokenFromXGotifyHeader (line 113) | func (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string {
    method tokenFromAuthorizationHeader (line 117) | func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string {
    method userFromBasicAuth (line 132) | func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) {
    method requireToken (line 143) | func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc {
    method Optional (line 170) | func (a *Auth) Optional() gin.HandlerFunc {
  type authenticate (line 33) | type authenticate

FILE: auth/authentication_test.go
  function TestSuite (line 17) | func TestSuite(t *testing.T) {
  type AuthenticationSuite (line 21) | type AuthenticationSuite struct
    method SetupSuite (line 27) | func (s *AuthenticationSuite) SetupSuite() {
    method TearDownSuite (line 49) | func (s *AuthenticationSuite) TearDownSuite() {
    method TestQueryToken (line 53) | func (s *AuthenticationSuite) TestQueryToken() {
    method assertQueryRequest (line 81) | func (s *AuthenticationSuite) assertQueryRequest(key, value string, f ...
    method TestNothingProvided (line 90) | func (s *AuthenticationSuite) TestNothingProvided() {
    method TestHeaderApiKeyToken (line 98) | func (s *AuthenticationSuite) TestHeaderApiKeyToken() {
    method TestAuthorizationHeaderApiKeyToken (line 126) | func (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() {
    method TestBasicAuth (line 159) | func (s *AuthenticationSuite) TestBasicAuth() {
    method TestOptionalAuth (line 185) | func (s *AuthenticationSuite) TestOptionalAuth() {
    method assertHeaderRequest (line 213) | func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f...
  type fMiddleware (line 223) | type fMiddleware

FILE: auth/cors.go
  function CorsConfig (line 14) | func CorsConfig(conf *config.Configuration) cors.Config {
  function headerIgnoreCase (line 46) | func headerIgnoreCase(conf *config.Configuration, search string) (value ...
  function compileAllowedCORSOrigins (line 55) | func compileAllowedCORSOrigins(allowedOrigins []string) []*regexp.Regexp {

FILE: auth/cors_test.go
  function TestCorsConfig (line 13) | func TestCorsConfig(t *testing.T) {
  function TestEmptyCorsConfigWithResponseHeaders (line 37) | func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) {
  function TestDevCorsConfig (line 54) | func TestDevCorsConfig(t *testing.T) {

FILE: auth/password/password.go
  function CreatePassword (line 6) | func CreatePassword(pw string, strength int) []byte {
  function ComparePassword (line 15) | func ComparePassword(hashedPassword, password []byte) bool {

FILE: auth/password/password_test.go
  function TestPasswordSuccess (line 9) | func TestPasswordSuccess(t *testing.T) {
  function TestPasswordFailure (line 14) | func TestPasswordFailure(t *testing.T) {
  function TestBCryptFailure (line 19) | func TestBCryptFailure(t *testing.T) {

FILE: auth/token.go
  function randIntn (line 18) | func randIntn(n int) int {
  function GenerateNotExistingToken (line 28) | func GenerateNotExistingToken(generateToken func() string, tokenExists f...
  function GenerateApplicationToken (line 38) | func GenerateApplicationToken() string {
  function GenerateClientToken (line 43) | func GenerateClientToken() string {
  function GeneratePluginToken (line 48) | func GeneratePluginToken() string {
  function GenerateImageName (line 53) | func GenerateImageName() string {
  function generateRandomToken (line 57) | func generateRandomToken(prefix string) string {
  function generateRandomString (line 61) | func generateRandomString(length int) string {
  function init (line 70) | func init() {

FILE: auth/token_test.go
  function TestTokenHavePrefix (line 13) | func TestTokenHavePrefix(t *testing.T) {
  function TestGenerateNotExistingToken (line 22) | func TestGenerateNotExistingToken(t *testing.T) {
  function TestBadCryptoReaderPanics (line 33) | func TestBadCryptoReaderPanics(t *testing.T) {

FILE: auth/util.go
  function RegisterAuthentication (line 9) | func RegisterAuthentication(ctx *gin.Context, user *model.User, userID u...
  function GetUserID (line 16) | func GetUserID(ctx *gin.Context) uint {
  function TryGetUserID (line 25) | func TryGetUserID(ctx *gin.Context) *uint {
  function GetTokenID (line 39) | func GetTokenID(ctx *gin.Context) string {

FILE: auth/util_test.go
  function TestUtilSuite (line 14) | func TestUtilSuite(t *testing.T) {
  type UtilSuite (line 18) | type UtilSuite struct
    method BeforeTest (line 22) | func (s *UtilSuite) BeforeTest(suiteName, testName string) {
    method Test_getID (line 26) | func (s *UtilSuite) Test_getID() {
    method Test_getToken (line 35) | func (s *UtilSuite) Test_getToken() {
    method expectUserIDWith (line 42) | func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, ex...
    method expectTryUserIDWith (line 49) | func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID ...

FILE: config/config.go
  type Configuration (line 12) | type Configuration struct
  function configFiles (line 60) | func configFiles() []string {
  function Get (line 68) | func Get() *Configuration {
  function addTrailingSlashToPaths (line 78) | func addTrailingSlashToPaths(conf *Configuration) {

FILE: config/config_test.go
  function TestConfigEnv (line 12) | func TestConfigEnv(t *testing.T) {
  function TestAddSlash (line 44) | func TestAddSlash(t *testing.T) {
  function TestNotAddSlash (line 52) | func TestNotAddSlash(t *testing.T) {
  function TestFileWithSyntaxErrors (line 60) | func TestFileWithSyntaxErrors(t *testing.T) {
  function TestConfigFile (line 79) | func TestConfigFile(t *testing.T) {

FILE: database/application.go
  method GetApplicationByToken (line 13) | func (d *GormDatabase) GetApplicationByToken(token string) (*model.Appli...
  method GetApplicationByID (line 26) | func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, ...
  method CreateApplication (line 39) | func (d *GormDatabase) CreateApplication(application *model.Application)...
  method DeleteApplicationByID (line 58) | func (d *GormDatabase) DeleteApplicationByID(id uint) error {
  method GetApplicationsByUser (line 64) | func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Appl...
  method UpdateApplication (line 74) | func (d *GormDatabase) UpdateApplication(app *model.Application) error {
  method UpdateApplicationTokenLastUsed (line 79) | func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *t...

FILE: database/application_test.go
  method TestApplication (line 10) | func (s *DatabaseSuite) TestApplication() {
  method TestDeleteAppDeletesMessages (line 72) | func (s *DatabaseSuite) TestDeleteAppDeletesMessages() {

FILE: database/client.go
  method GetClientByID (line 11) | func (d *GormDatabase) GetClientByID(id uint) (*model.Client, error) {
  method GetClientByToken (line 24) | func (d *GormDatabase) GetClientByToken(token string) (*model.Client, er...
  method CreateClient (line 37) | func (d *GormDatabase) CreateClient(client *model.Client) error {
  method GetClientsByUser (line 42) | func (d *GormDatabase) GetClientsByUser(userID uint) ([]*model.Client, e...
  method DeleteClientByID (line 52) | func (d *GormDatabase) DeleteClientByID(id uint) error {
  method UpdateClient (line 57) | func (d *GormDatabase) UpdateClient(client *model.Client) error {
  method UpdateClientTokensLastUsed (line 62) | func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *ti...

FILE: database/client_test.go
  method TestClient (line 10) | func (s *DatabaseSuite) TestClient() {

FILE: database/database.go
  function New (line 27) | func New(dialect, connection, defaultUser, defaultPass string, strength ...
  function fillMissingSortKeys (line 100) | func fillMissingSortKeys(db *gorm.DB) error {
  function createDirectoryIfSqlite (line 133) | func createDirectoryIfSqlite(dialect, connection string) {
  type GormDatabase (line 144) | type GormDatabase struct
    method Close (line 149) | func (d *GormDatabase) Close() {

FILE: database/database_test.go
  function TestDatabaseSuite (line 17) | func TestDatabaseSuite(t *testing.T) {
  type DatabaseSuite (line 21) | type DatabaseSuite struct
    method BeforeTest (line 27) | func (s *DatabaseSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 34) | func (s *DatabaseSuite) AfterTest(suiteName, testName string) {
  function TestInvalidDialect (line 39) | func TestInvalidDialect(t *testing.T) {
  function TestCreateSqliteFolder (line 46) | func TestCreateSqliteFolder(t *testing.T) {
  function TestWithAlreadyExistingSqliteFolder (line 56) | func TestWithAlreadyExistingSqliteFolder(t *testing.T) {
  function TestPanicsOnMkdirError (line 66) | func TestPanicsOnMkdirError(t *testing.T) {
  function TestMigrateSortKey (line 77) | func TestMigrateSortKey(t *testing.T) {

FILE: database/message.go
  method GetMessageByID (line 9) | func (d *GormDatabase) GetMessageByID(id uint) (*model.Message, error) {
  method CreateMessage (line 22) | func (d *GormDatabase) CreateMessage(message *model.Message) error {
  method GetMessagesByUser (line 27) | func (d *GormDatabase) GetMessagesByUser(userID uint) ([]*model.Message,...
  method GetMessagesByUserSince (line 39) | func (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, si...
  method GetMessagesByApplication (line 54) | func (d *GormDatabase) GetMessagesByApplication(tokenID uint) ([]*model....
  method GetMessagesByApplicationSince (line 65) | func (d *GormDatabase) GetMessagesByApplicationSince(appID uint, limit i...
  method DeleteMessageByID (line 79) | func (d *GormDatabase) DeleteMessageByID(id uint) error {
  method DeleteMessagesByApplication (line 84) | func (d *GormDatabase) DeleteMessagesByApplication(applicationID uint) e...
  method DeleteMessagesByUser (line 89) | func (d *GormDatabase) DeleteMessagesByUser(userID uint) error {

FILE: database/message_test.go
  method TestMessage (line 12) | func (s *DatabaseSuite) TestMessage() {
  method TestGetMessagesSince (line 150) | func (s *DatabaseSuite) TestGetMessagesSince() {
  function hasIDInclusiveBetween (line 226) | func hasIDInclusiveBetween(t *testing.T, msgs []*model.Message, from, to...
  function assertEquals (line 238) | func assertEquals(t *testing.T, left, right *model.Message) {

FILE: database/migration_test.go
  function TestMigration (line 14) | func TestMigration(t *testing.T) {
  type MigrationSuite (line 18) | type MigrationSuite struct
    method BeforeTest (line 23) | func (s *MigrationSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 41) | func (s *MigrationSuite) AfterTest(suiteName, testName string) {
    method TestMigration (line 45) | func (s *MigrationSuite) TestMigration() {

FILE: database/ping.go
  method Ping (line 4) | func (d *GormDatabase) Ping() error {

FILE: database/ping_test.go
  method TestPing_onValidDB (line 7) | func (s *DatabaseSuite) TestPing_onValidDB() {
  method TestPing_onClosedDB (line 12) | func (s *DatabaseSuite) TestPing_onClosedDB() {

FILE: database/plugin.go
  method GetPluginConfByUser (line 9) | func (d *GormDatabase) GetPluginConfByUser(userid uint) ([]*model.Plugin...
  method GetPluginConfByUserAndPath (line 19) | func (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path stri...
  method GetPluginConfByApplicationID (line 32) | func (d *GormDatabase) GetPluginConfByApplicationID(appid uint) (*model....
  method CreatePluginConf (line 45) | func (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error {
  method GetPluginConfByToken (line 50) | func (d *GormDatabase) GetPluginConfByToken(token string) (*model.Plugin...
  method GetPluginConfByID (line 63) | func (d *GormDatabase) GetPluginConfByID(id uint) (*model.PluginConf, er...
  method UpdatePluginConf (line 76) | func (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error {
  method DeletePluginConfByID (line 81) | func (d *GormDatabase) DeletePluginConfByID(id uint) error {

FILE: database/plugin_test.go
  method TestPluginConf (line 9) | func (s *DatabaseSuite) TestPluginConf() {

FILE: database/user.go
  method GetUserByName (line 9) | func (d *GormDatabase) GetUserByName(name string) (*model.User, error) {
  method GetUserByID (line 22) | func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) {
  method CountUser (line 35) | func (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) {
  method GetUsers (line 48) | func (d *GormDatabase) GetUsers() ([]*model.User, error) {
  method DeleteUserByID (line 55) | func (d *GormDatabase) DeleteUserByID(id uint) error {
  method UpdateUser (line 72) | func (d *GormDatabase) UpdateUser(user *model.User) error {
  method CreateUser (line 77) | func (d *GormDatabase) CreateUser(user *model.User) error {

FILE: database/user_test.go
  method TestUser (line 9) | func (s *DatabaseSuite) TestUser() {
  method TestUserPlugins (line 77) | func (s *DatabaseSuite) TestUserPlugins() {
  method TestDeleteUserDeletesApplicationsAndClientsAndPluginConfs (line 104) | func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClientsAndPl...

FILE: docs/swagger.go
  function Serve (line 15) | func Serve(ctx *gin.Context) {
  function getSwaggerJSON (line 23) | func getSwaggerJSON(base string) string {

FILE: docs/swagger_test.go
  function TestServe (line 13) | func TestServe(t *testing.T) {
  function withURL (line 28) | func withURL(ctx *gin.Context, scheme, host string) {

FILE: docs/ui.go
  function UI (line 71) | func UI(ctx *gin.Context) {

FILE: docs/ui_test.go
  function TestUI (line 12) | func TestUI(t *testing.T) {

FILE: error/handler.go
  function Handler (line 15) | func Handler() gin.HandlerFunc {
  function validationErrorToText (line 43) | func validationErrorToText(e validator.FieldError) string {
  function writeError (line 58) | func writeError(ctx *gin.Context, errString string) {

FILE: error/handler_test.go
  function TestDefaultErrorInternal (line 16) | func TestDefaultErrorInternal(t *testing.T) {
  function TestBindingErrorDefault (line 27) | func TestBindingErrorDefault(t *testing.T) {
  function TestDefaultErrorBadRequest (line 38) | func TestDefaultErrorBadRequest(t *testing.T) {
  type testValidate (line 49) | type testValidate struct
  function TestValidationError (line 56) | func TestValidationError(t *testing.T) {
  function assertJSONResponse (line 76) | func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, co...

FILE: error/notfound.go
  function NotFound (line 11) | func NotFound() gin.HandlerFunc {

FILE: error/notfound_test.go
  function TestNotFound (line 11) | func TestNotFound(t *testing.T) {

FILE: fracdex/fracdex.go
  constant base62Digits (line 12) | base62Digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst...
  constant smallestInt (line 13) | smallestInt  = "A00000000000000000000000000"
  constant zero (line 14) | zero         = "a0"
  function KeyBetween (line 21) | func KeyBetween(a, b string) (string, error) {
  function midpoint (line 108) | func midpoint(a, b string) string {
  function validateInt (line 163) | func validateInt(i string) error {
  function getIntLen (line 174) | func getIntLen(head byte) (int, error) {
  function getIntPart (line 184) | func getIntPart(key string) (string, error) {
  function validateOrderKey (line 195) | func validateOrderKey(key string) error {
  function incrementInt (line 214) | func incrementInt(x string) (string, error) {
  function decrementInt (line 250) | func decrementInt(x string) (string, error) {
  function Float64Approx (line 292) | func Float64Approx(key string) (float64, error) {
  function NKeysBetween (line 340) | func NKeysBetween(a, b string, n uint) ([]string, error) {
  function reverse (line 408) | func reverse(values []string) {

FILE: fracdex/fracdex_test.go
  function TestKeys (line 12) | func TestKeys(t *testing.T) {
  function TestNKeys (line 60) | func TestNKeys(t *testing.T) {
  function TestToFloat64Approx (line 85) | func TestToFloat64Approx(t *testing.T) {

FILE: mode/mode.go
  constant Dev (line 7) | Dev = "dev"
  constant Prod (line 9) | Prod = "prod"
  constant TestDev (line 11) | TestDev = "testdev"
  function Set (line 17) | func Set(newMode string) {
  function Get (line 23) | func Get() string {
  function IsDev (line 28) | func IsDev() bool {
  function updateGinMode (line 32) | func updateGinMode() {

FILE: mode/mode_test.go
  function TestDevMode (line 10) | func TestDevMode(t *testing.T) {
  function TestTestDevMode (line 17) | func TestTestDevMode(t *testing.T) {
  function TestProdMode (line 24) | func TestProdMode(t *testing.T) {
  function TestInvalidMode (line 31) | func TestInvalidMode(t *testing.T) {

FILE: model/application.go
  type Application (line 10) | type Application struct

FILE: model/client.go
  type Client (line 10) | type Client struct

FILE: model/error.go
  type Error (line 8) | type Error struct

FILE: model/health.go
  type Health (line 8) | type Health struct
  constant StatusGreen (line 23) | StatusGreen = "green"
  constant StatusOrange (line 25) | StatusOrange = "orange"
  constant StatusRed (line 27) | StatusRed = "red"

FILE: model/message.go
  type Message (line 8) | type Message struct
  type MessageExternal (line 23) | type MessageExternal struct

FILE: model/paging.go
  type Paging (line 8) | type Paging struct
  type PagedMessages (line 43) | type PagedMessages struct

FILE: model/pluginconf.go
  type PluginConf (line 4) | type PluginConf struct
  type PluginConfExternal (line 20) | type PluginConfExternal struct

FILE: model/user.go
  type User (line 4) | type User struct
  type UserExternal (line 19) | type UserExternal struct
  type CreateUserExternal (line 43) | type CreateUserExternal struct
  type UpdateUserExternal (line 66) | type UpdateUserExternal struct
  type UserExternalPass (line 88) | type UserExternalPass struct

FILE: model/version.go
  type VersionInfo (line 6) | type VersionInfo struct

FILE: plugin/compat/instance.go
  type Capability (line 10) | type Capability
  constant Messenger (line 14) | Messenger = Capability("messenger")
  constant Configurer (line 16) | Configurer = Capability("configurer")
  constant Storager (line 18) | Storager = Capability("storager")
  constant Webhooker (line 20) | Webhooker = Capability("webhooker")
  constant Displayer (line 22) | Displayer = Capability("displayer")
  type PluginInstance (line 26) | type PluginInstance interface
  function HasSupport (line 52) | func HasSupport(p PluginInstance, toCheck Capability) bool {
  type Capabilities (line 62) | type Capabilities
    method Strings (line 65) | func (m Capabilities) Strings() []string {
  type MessageHandler (line 74) | type MessageHandler interface
  type StorageHandler (line 80) | type StorageHandler interface
  type Message (line 86) | type Message struct

FILE: plugin/compat/plugin.go
  type Plugin (line 4) | type Plugin interface
  type Info (line 11) | type Info struct
    method String (line 21) | func (c Info) String() string {
  type UserContext (line 29) | type UserContext struct

FILE: plugin/compat/plugin_test.go
  constant examplePluginPath (line 9) | examplePluginPath = "github.com/gotify/server/v2/plugin/example/echo"
  function TestPluginInfoStringer (line 11) | func TestPluginInfoStringer(t *testing.T) {

FILE: plugin/compat/v1.go
  type PluginV1 (line 11) | type PluginV1 struct
    method APIVersion (line 17) | func (c PluginV1) APIVersion() string {
    method PluginInfo (line 22) | func (c PluginV1) PluginInfo() Info {
    method NewPluginInstance (line 35) | func (c PluginV1) NewPluginInstance(ctx UserContext) PluginInstance {
  type PluginV1Instance (line 70) | type PluginV1Instance struct
    method DefaultConfig (line 80) | func (c *PluginV1Instance) DefaultConfig() interface{} {
    method ValidateAndSetConfig (line 88) | func (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) er...
    method GetDisplay (line 96) | func (c *PluginV1Instance) GetDisplay(location *url.URL) string {
    method SetMessageHandler (line 104) | func (c *PluginV1Instance) SetMessageHandler(h MessageHandler) {
    method RegisterWebhook (line 111) | func (c *PluginV1Instance) RegisterWebhook(basePath string, mux *gin.R...
    method SetStorageHandler (line 118) | func (c *PluginV1Instance) SetStorageHandler(handler StorageHandler) {
    method Supports (line 125) | func (c *PluginV1Instance) Supports() Capabilities {
    method Enable (line 161) | func (c *PluginV1Instance) Enable() error {
    method Disable (line 166) | func (c *PluginV1Instance) Disable() error {
  type PluginV1MessageHandler (line 146) | type PluginV1MessageHandler struct
    method SendMessage (line 151) | func (c *PluginV1MessageHandler) SendMessage(msg papiv1.Message) error {
  type PluginV1StorageHandler (line 171) | type PluginV1StorageHandler struct
    method Save (line 176) | func (c *PluginV1StorageHandler) Save(b []byte) error {
    method Load (line 181) | func (c *PluginV1StorageHandler) Load() ([]byte, error) {

FILE: plugin/compat/v1_test.go
  type v1MockInstance (line 11) | type v1MockInstance struct
    method Enable (line 15) | func (c *v1MockInstance) Enable() error {
    method Disable (line 20) | func (c *v1MockInstance) Disable() error {
  type V1WrapperSuite (line 25) | type V1WrapperSuite struct
    method SetupSuite (line 30) | func (s *V1WrapperSuite) SetupSuite() {
    method TestConfigurer_notSupported_expectEmpty (line 35) | func (s *V1WrapperSuite) TestConfigurer_notSupported_expectEmpty() {
    method TestDisplayer_notSupported_expectEmpty (line 40) | func (s *V1WrapperSuite) TestDisplayer_notSupported_expectEmpty() {
    method TestStorager (line 73) | func (s *V1WrapperSuite) TestStorager() {
    method TestMessenger_sendMessageWithExtras (line 110) | func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() {
    method TestMessenger_sendMessageWithoutExtras (line 136) | func (s *V1WrapperSuite) TestMessenger_sendMessageWithoutExtras() {
  type v1StorageHandler (line 44) | type v1StorageHandler struct
    method Save (line 48) | func (c *v1StorageHandler) Save(b []byte) error {
    method Load (line 53) | func (c *v1StorageHandler) Load() ([]byte, error) {
  type v1Storager (line 57) | type v1Storager struct
    method Enable (line 61) | func (c *v1Storager) Enable() error {
    method Disable (line 65) | func (c *v1Storager) Disable() error {
    method SetStorageHandler (line 69) | func (c *v1Storager) SetStorageHandler(h papiv1.StorageHandler) {
  type v1MessengerHandler (line 85) | type v1MessengerHandler struct
    method SendMessage (line 89) | func (c *v1MessengerHandler) SendMessage(msg Message) error {
  type v1Messenger (line 94) | type v1Messenger struct
    method Enable (line 98) | func (c *v1Messenger) Enable() error {
    method Disable (line 102) | func (c *v1Messenger) Disable() error {
    method SetMessageHandler (line 106) | func (c *v1Messenger) SetMessageHandler(h papiv1.MessageHandler) {
  function TestV1Wrapper (line 158) | func TestV1Wrapper(t *testing.T) {

FILE: plugin/compat/wrap.go
  function Wrap (line 12) | func Wrap(p *plugin.Plugin) (Plugin, error) {

FILE: plugin/compat/wrap_test.go
  type CompatSuite (line 20) | type CompatSuite struct
    method SetupSuite (line 27) | func (s *CompatSuite) SetupSuite() {
    method TearDownSuite (line 49) | func (s *CompatSuite) TearDownSuite() {
    method TestGetPluginAPIVersion (line 53) | func (s *CompatSuite) TestGetPluginAPIVersion() {
    method TestGetPluginInfo (line 57) | func (s *CompatSuite) TestGetPluginInfo() {
    method TestInstantiatePlugin (line 64) | func (s *CompatSuite) TestInstantiatePlugin() {
    method TestGetCapabilities (line 73) | func (s *CompatSuite) TestGetCapabilities() {
    method TestSetConfig (line 87) | func (s *CompatSuite) TestSetConfig() {
    method TestRegisterWebhook (line 97) | func (s *CompatSuite) TestRegisterWebhook() {
    method TestEnableDisable (line 110) | func (s *CompatSuite) TestEnableDisable() {
    method TestGetDisplay (line 119) | func (s *CompatSuite) TestGetDisplay() {
  function TestCompatSuite (line 128) | func TestCompatSuite(t *testing.T) {
  function TestWrapIncompatiblePlugins (line 132) | func TestWrapIncompatiblePlugins(t *testing.T) {

FILE: plugin/example/clock/main.go
  function GetGotifyPluginInfo (line 11) | func GetGotifyPluginInfo() plugin.Info {
  type Plugin (line 20) | type Plugin struct
    method Enable (line 27) | func (c *Plugin) Enable() error {
    method Disable (line 41) | func (c *Plugin) Disable() error {
    method SetMessageHandler (line 50) | func (c *Plugin) SetMessageHandler(h plugin.MessageHandler) {
  function NewGotifyPluginInstance (line 55) | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
  function main (line 61) | func main() {

FILE: plugin/example/echo/echo.go
  function GetGotifyPluginInfo (line 14) | func GetGotifyPluginInfo() plugin.Info {
  type EchoPlugin (line 22) | type EchoPlugin struct
    method SetStorageHandler (line 30) | func (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) {
    method SetMessageHandler (line 35) | func (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) {
    method DefaultConfig (line 50) | func (c *EchoPlugin) DefaultConfig() interface{} {
    method ValidateAndSetConfig (line 57) | func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error {
    method Enable (line 63) | func (c *EchoPlugin) Enable() error {
    method Disable (line 69) | func (c *EchoPlugin) Disable() error {
    method RegisterWebhook (line 75) | func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGrou...
    method GetDisplay (line 98) | func (c *EchoPlugin) GetDisplay(location *url.URL) string {
  type Storage (line 40) | type Storage struct
  type Config (line 45) | type Config struct
  function NewGotifyPluginInstance (line 113) | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
  function main (line 117) | func main() {

FILE: plugin/example/minimal/main.go
  function GetGotifyPluginInfo (line 8) | func GetGotifyPluginInfo() plugin.Info {
  type Plugin (line 16) | type Plugin struct
    method Enable (line 19) | func (c *Plugin) Enable() error {
    method Disable (line 24) | func (c *Plugin) Disable() error {
  function NewGotifyPluginInstance (line 29) | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
  function main (line 33) | func main() {

FILE: plugin/manager.go
  type Database (line 25) | type Database interface
  type Notifier (line 42) | type Notifier interface
  type Manager (line 47) | type Manager struct
    method applicationExists (line 106) | func (m *Manager) applicationExists(token string) bool {
    method pluginConfExists (line 111) | func (m *Manager) pluginConfExists(token string) bool {
    method SetPluginEnabled (line 117) | func (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error {
    method PluginInfo (line 151) | func (m *Manager) PluginInfo(modulePath string) compat.Info {
    method Instance (line 167) | func (m *Manager) Instance(pluginID uint) (compat.PluginInstance, erro...
    method HasInstance (line 178) | func (m *Manager) HasInstance(pluginID uint) bool {
    method RemoveUser (line 184) | func (m *Manager) RemoveUser(userID uint) error {
    method loadPlugins (line 219) | func (m *Manager) loadPlugins(directory string) error {
    method LoadPlugin (line 257) | func (m *Manager) LoadPlugin(compatPlugin compat.Plugin) error {
    method InitializeForUserID (line 267) | func (m *Manager) InitializeForUserID(userID uint) error {
    method initializeForUser (line 281) | func (m *Manager) initializeForUser(user model.User) error {
    method initializeSingleUserPlugin (line 315) | func (m *Manager) initializeSingleUserPlugin(userCtx compat.UserContex...
    method initializeConfigurerForSingleUserPlugin (line 366) | func (m *Manager) initializeConfigurerForSingleUserPlugin(instance com...
    method createPluginConf (line 399) | func (m *Manager) createPluginConf(instance compat.PluginInstance, inf...
  function NewManager (line 57) | func NewManager(db Database, directory string, mux *gin.RouterGroup, not...
  type pluginFileLoadError (line 210) | type pluginFileLoadError struct
    method Error (line 215) | func (c pluginFileLoadError) Error() string {

FILE: plugin/manager_test.go
  constant examplePluginPath (line 28) | examplePluginPath  = "github.com/gotify/server/v2/plugin/example/echo"
  constant mockPluginPath (line 29) | mockPluginPath     = mock.ModulePath
  constant danglingPluginPath (line 30) | danglingPluginPath = "github.com/gotify/server/v2/plugin/testing/removed"
  type ManagerSuite (line 33) | type ManagerSuite struct
    method Notify (line 43) | func (s *ManagerSuite) Notify(uid uint, message *model.MessageExternal) {
    method SetupSuite (line 50) | func (s *ManagerSuite) SetupSuite() {
    method TearDownSuite (line 88) | func (s *ManagerSuite) TearDownSuite() {
    method getConfForExamplePlugin (line 92) | func (s *ManagerSuite) getConfForExamplePlugin(uid uint) *model.Plugin...
    method getConfForMockPlugin (line 98) | func (s *ManagerSuite) getConfForMockPlugin(uid uint) *model.PluginConf {
    method getMockPluginInstance (line 104) | func (s *ManagerSuite) getMockPluginInstance(uid uint) *mock.PluginIns...
    method makeDanglingPluginConf (line 109) | func (s *ManagerSuite) makeDanglingPluginConf(uid uint) *model.PluginC...
    method TestWebhook_blockedIfDisabled (line 120) | func (s *ManagerSuite) TestWebhook_blockedIfDisabled() {
    method TestWebhook_successIfEnabled (line 130) | func (s *ManagerSuite) TestWebhook_successIfEnabled() {
    method TestInitializePlugin_noOpIfEmpty (line 145) | func (s *ManagerSuite) TestInitializePlugin_noOpIfEmpty() {
    method TestInitializePlugin_noOpIfDotFile (line 149) | func (s *ManagerSuite) TestInitializePlugin_noOpIfDotFile() {
    method TestInitializePlugin_noOpIfSubDir (line 160) | func (s *ManagerSuite) TestInitializePlugin_noOpIfSubDir() {
    method TestInitializePlugin_directoryInvalid_expectError (line 167) | func (s *ManagerSuite) TestInitializePlugin_directoryInvalid_expectErr...
    method TestInitializePlugin_invalidPlugin_expectError (line 171) | func (s *ManagerSuite) TestInitializePlugin_invalidPlugin_expectError() {
    method TestInitializePlugin_brokenPlugin_expectError (line 175) | func (s *ManagerSuite) TestInitializePlugin_brokenPlugin_expectError() {
    method TestInitializePlugin_alreadyLoaded_expectError (line 191) | func (s *ManagerSuite) TestInitializePlugin_alreadyLoaded_expectError() {
    method TestInitializePlugin_alreadyEnabledInConf_expectAutoEnable (line 195) | func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_expec...
    method TestInitializePlugin_alreadyEnabledInConf_failedToLoadConfig_disableAutomatically (line 209) | func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_faile...
    method TestInitializePlugin_alreadyEnabled_cannotEnable_disabledAutomatically (line 224) | func (s *ManagerSuite) TestInitializePlugin_alreadyEnabled_cannotEnabl...
    method TestInitializePlugin_userIDNotExist_expectError (line 240) | func (s *ManagerSuite) TestInitializePlugin_userIDNotExist_expectError...
    method TestSetPluginEnabled (line 244) | func (s *ManagerSuite) TestSetPluginEnabled() {
    method TestSetPluginEnabled_EnableReturnsError_cannotEnable (line 251) | func (s *ManagerSuite) TestSetPluginEnabled_EnableReturnsError_cannotE...
    method TestSetPluginEnabled_DisableReturnsError_cannotDisable (line 265) | func (s *ManagerSuite) TestSetPluginEnabled_DisableReturnsError_cannot...
    method TestAddRemoveNewUser (line 279) | func (s *ManagerSuite) TestAddRemoveNewUser() {
    method TestRemoveUser_DisableFail_cannotRemove (line 293) | func (s *ManagerSuite) TestRemoveUser_DisableFail_cannotRemove() {
    method TestRemoveUser_danglingConf_expectSuccess (line 302) | func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() {
    method TestTriggerMessage (line 320) | func (s *ManagerSuite) TestTriggerMessage() {
    method TestStorage (line 332) | func (s *ManagerSuite) TestStorage() {
    method TestGetPluginInfo (line 341) | func (s *ManagerSuite) TestGetPluginInfo() {
    method TestGetPluginInfo_notFound_doNotPanic (line 345) | func (s *ManagerSuite) TestGetPluginInfo_notFound_doNotPanic() {
    method TestSetPluginEnabled_expectNotFound (line 351) | func (s *ManagerSuite) TestSetPluginEnabled_expectNotFound() {
  function TestManagerSuite (line 355) | func TestManagerSuite(t *testing.T) {
  function TestNewManager_CannotLoadDirectory_expectError (line 359) | func TestNewManager_CannotLoadDirectory_expectError(t *testing.T) {
  function TestNewManager_NonPluginFile_expectError (line 364) | func TestNewManager_NonPluginFile_expectError(t *testing.T) {
  function TestNewManager_InternalApplicationManagement (line 369) | func TestNewManager_InternalApplicationManagement(t *testing.T) {
  function TestPluginFileLoadError (line 447) | func TestPluginFileLoadError(t *testing.T) {

FILE: plugin/messagehandler.go
  type redirectToChannel (line 10) | type redirectToChannel struct
    method SendMessage (line 23) | func (c redirectToChannel) SendMessage(msg compat.Message) error {
  type MessageWithUserID (line 17) | type MessageWithUserID struct

FILE: plugin/pluginenabled.go
  function requirePluginEnabled (line 9) | func requirePluginEnabled(id uint, db Database) gin.HandlerFunc {

FILE: plugin/pluginenabled_test.go
  function TestRequirePluginEnabled (line 13) | func TestRequirePluginEnabled(t *testing.T) {

FILE: plugin/storagehandler.go
  type dbStorageHandler (line 3) | type dbStorageHandler struct
    method Save (line 8) | func (c dbStorageHandler) Save(b []byte) error {
    method Load (line 17) | func (c dbStorageHandler) Load() ([]byte, error) {

FILE: plugin/testing/broken/cantinstantiate/main.go
  function GetGotifyPluginInfo (line 10) | func GetGotifyPluginInfo() plugin.Info {
  type Plugin (line 17) | type Plugin struct
    method Enable (line 20) | func (c *Plugin) Enable() error {
    method Disable (line 25) | func (c *Plugin) Disable() error {
  function NewGotifyPluginInstance (line 30) | func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
  function main (line 34) | func main() {

FILE: plugin/testing/broken/malformedconstructor/main.go
  function GetGotifyPluginInfo (line 8) | func GetGotifyPluginInfo() plugin.Info {
  type Plugin (line 15) | type Plugin struct
    method Enable (line 18) | func (c *Plugin) Enable() error {
    method Disable (line 23) | func (c *Plugin) Disable() error {
  function NewGotifyPluginInstance (line 28) | func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} {
  function main (line 32) | func main() {

FILE: plugin/testing/broken/noinstance/main.go
  function GetGotifyPluginInfo (line 8) | func GetGotifyPluginInfo() plugin.Info {
  function main (line 14) | func main() {

FILE: plugin/testing/broken/nothing/main.go
  function main (line 3) | func main() {

FILE: plugin/testing/broken/unknowninfo/main.go
  function GetGotifyPluginInfo (line 4) | func GetGotifyPluginInfo() string {
  function main (line 8) | func main() {

FILE: plugin/testing/mock/mock.go
  constant ModulePath (line 13) | ModulePath = "github.com/gotify/server/v2/plugin/testing/mock"
  constant Name (line 16) | Name = "mock plugin"
  type Plugin (line 19) | type Plugin struct
    method PluginInfo (line 24) | func (c *Plugin) PluginInfo() compat.Info {
    method NewPluginInstance (line 32) | func (c *Plugin) NewPluginInstance(ctx compat.UserContext) compat.Plug...
    method APIVersion (line 39) | func (c *Plugin) APIVersion() string {
  type PluginInstance (line 44) | type PluginInstance struct
    method Enable (line 77) | func (c *PluginInstance) Enable() error {
    method Disable (line 86) | func (c *PluginInstance) Disable() error {
    method SetMessageHandler (line 95) | func (c *PluginInstance) SetMessageHandler(h compat.MessageHandler) {
    method SetStorageHandler (line 100) | func (c *PluginInstance) SetStorageHandler(handler compat.StorageHandl...
    method SetStorage (line 105) | func (c *PluginInstance) SetStorage(b []byte) error {
    method GetStorage (line 110) | func (c *PluginInstance) GetStorage() ([]byte, error) {
    method RegisterWebhook (line 115) | func (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.Rou...
    method SetCapability (line 120) | func (c *PluginInstance) SetCapability(p compat.Capability, enable boo...
    method Supports (line 141) | func (c *PluginInstance) Supports() compat.Capabilities {
    method DefaultConfig (line 146) | func (c *PluginInstance) DefaultConfig() interface{} {
    method ValidateAndSetConfig (line 154) | func (c *PluginInstance) ValidateAndSetConfig(config interface{}) error {
    method GetDisplay (line 163) | func (c *PluginInstance) GetDisplay(url *url.URL) string {
    method TriggerMessage (line 168) | func (c *PluginInstance) TriggerMessage() {
  type PluginConfig (line 56) | type PluginConfig struct
  function ReturnErrorOnEnableForUser (line 67) | func ReturnErrorOnEnableForUser(uid uint, err error) {
  function ReturnErrorOnDisableForUser (line 72) | func ReturnErrorOnDisableForUser(uid uint, err error) {

FILE: router/router.go
  function Create (line 27) | func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *c...
  function logFormatter (line 224) | func logFormatter(param gin.LogFormatterParams) string {
  type onlyImageFS (line 251) | type onlyImageFS struct
    method Open (line 255) | func (fs *onlyImageFS) Open(name string) (http.File, error) {

FILE: router/router_test.go
  function TestIntegrationSuite (line 25) | func TestIntegrationSuite(t *testing.T) {
  type IntegrationSuite (line 29) | type IntegrationSuite struct
    method BeforeTest (line 36) | func (s *IntegrationSuite) BeforeTest(string, string) {
    method AfterTest (line 50) | func (s *IntegrationSuite) AfterTest(string, string) {
    method TestVersionInfo (line 56) | func (s *IntegrationSuite) TestVersionInfo() {
    method TestHeaderInDev (line 62) | func (s *IntegrationSuite) TestHeaderInDev() {
    method TestHeaderInProd (line 73) | func (s *IntegrationSuite) TestHeaderInProd() {
    method TestOptionsRequest (line 318) | func (s *IntegrationSuite) TestOptionsRequest() {
    method TestSendMessage (line 326) | func (s *IntegrationSuite) TestSendMessage() {
    method TestPluginLoadFail_expectPanic (line 358) | func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() {
    method TestAuthentication (line 369) | func (s *IntegrationSuite) TestAuthentication() {
    method newRequest (line 404) | func (s *IntegrationSuite) newRequest(method, url, body string) *http....
  function TestHeadersFromConfiguration (line 82) | func TestHeadersFromConfiguration(t *testing.T) {
  function TestHeadersFromCORSConfig (line 114) | func TestHeadersFromCORSConfig(t *testing.T) {
  function TestInvalidOrigin (line 143) | func TestInvalidOrigin(t *testing.T) {
  function TestAllowedOriginFromResponseHeaders (line 172) | func TestAllowedOriginFromResponseHeaders(t *testing.T) {
  function TestAllowedWildcardOriginInHeader (line 210) | func TestAllowedWildcardOriginInHeader(t *testing.T) {
  function TestCORSHeaderRegex (line 242) | func TestCORSHeaderRegex(t *testing.T) {
  function TestCORSConfigOverride (line 272) | func TestCORSConfigOverride(t *testing.T) {
  function doRequestAndExpect (line 411) | func doRequestAndExpect(t *testing.T, req *http.Request, code int, json ...

FILE: runner/runner.go
  function Run (line 21) | func Run(router http.Handler, conf *config.Configuration) error {
  function doShutdownOnSignal (line 63) | func doShutdownOnSignal(shutdown chan<- error) {
  function doShutdown (line 70) | func doShutdown(shutdown chan<- error, err error) {
  function startListening (line 79) | func startListening(connectionType, listenAddr string, port, keepAlive i...
  function getNetworkAndAddr (line 93) | func getNetworkAndAddr(listenAddr string, port int) (string, string) {
  type LoggingRoundTripper (line 100) | type LoggingRoundTripper struct
    method RoundTrip (line 105) | func (l *LoggingRoundTripper) RoundTrip(r *http.Request) (resp *http.R...
  function applyLetsEncrypt (line 117) | func applyLetsEncrypt(s *http.Server, conf *config.Configuration) {

FILE: runner/umask_fallback.go
  function umask (line 5) | func umask(_ int) int {

FILE: test/asserts.go
  function BodyEquals (line 13) | func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.R...
  function JSONEquals (line 22) | func JSONEquals(t assert.TestingT, obj interface{}, expected string) {
  type unreadableReader (line 30) | type unreadableReader struct
    method Read (line 32) | func (c unreadableReader) Read([]byte) (int, error) {
  function UnreadableReader (line 37) | func UnreadableReader() io.Reader {

FILE: test/asserts_test.go
  type obj (line 12) | type obj struct
  type fakeTesting (line 17) | type fakeTesting struct
    method Errorf (line 21) | func (t *fakeTesting) Errorf(format string, args ...interface{}) {
  function Test_BodyEquals (line 25) | func Test_BodyEquals(t *testing.T) {
  function Test_BodyEquals_failing (line 35) | func Test_BodyEquals_failing(t *testing.T) {
  function Test_UnreaableReader (line 45) | func Test_UnreaableReader(t *testing.T) {

FILE: test/auth.go
  function WithUser (line 9) | func WithUser(ctx *gin.Context, userID uint) {

FILE: test/auth_test.go
  function TestFakeAuth (line 13) | func TestFakeAuth(t *testing.T) {

FILE: test/filepath.go
  function GetProjectDir (line 11) | func GetProjectDir() string {
  function WithWd (line 18) | func WithWd(chDir string, f func(origWd string)) {

FILE: test/filepath_test.go
  function TestProjectPath (line 11) | func TestProjectPath(t *testing.T) {
  function TestWithWd (line 16) | func TestWithWd(t *testing.T) {

FILE: test/testdb/database.go
  type Database (line 14) | type Database struct
    method User (line 48) | func (d *Database) User(id uint) *AppClientBuilder {
    method NewUser (line 54) | func (d *Database) NewUser(id uint) *model.User {
    method NewUserWithName (line 59) | func (d *Database) NewUserWithName(id uint, name string) *model.User {
    method AssertAppNotExist (line 180) | func (d *Database) AssertAppNotExist(id uint) {
    method AssertUserNotExist (line 187) | func (d *Database) AssertUserNotExist(id uint) {
    method AssertUsernameNotExist (line 194) | func (d *Database) AssertUsernameNotExist(name string) {
    method AssertClientNotExist (line 201) | func (d *Database) AssertClientNotExist(id uint) {
    method AssertMessageNotExist (line 208) | func (d *Database) AssertMessageNotExist(ids ...uint) {
    method AssertAppExist (line 217) | func (d *Database) AssertAppExist(id uint) {
    method AssertUserExist (line 224) | func (d *Database) AssertUserExist(id uint) {
    method AssertClientExist (line 231) | func (d *Database) AssertClientExist(id uint) {
    method AssertMessageExist (line 238) | func (d *Database) AssertMessageExist(id uint) {
  type AppClientBuilder (line 20) | type AppClientBuilder struct
    method App (line 66) | func (ab *AppClientBuilder) App(id uint) *MessageBuilder {
    method InternalApp (line 71) | func (ab *AppClientBuilder) InternalApp(id uint) *MessageBuilder {
    method app (line 75) | func (ab *AppClientBuilder) app(id uint, internal bool) *MessageBuilder {
    method AppWithToken (line 80) | func (ab *AppClientBuilder) AppWithToken(id uint, token string) *Messa...
    method InternalAppWithToken (line 85) | func (ab *AppClientBuilder) InternalAppWithToken(id uint, token string...
    method appWithToken (line 89) | func (ab *AppClientBuilder) appWithToken(id uint, token string, intern...
    method NewAppWithToken (line 95) | func (ab *AppClientBuilder) NewAppWithToken(id uint, token string) *mo...
    method NewInternalAppWithToken (line 100) | func (ab *AppClientBuilder) NewInternalAppWithToken(id uint, token str...
    method newAppWithToken (line 104) | func (ab *AppClientBuilder) newAppWithToken(id uint, token string, int...
    method AppWithTokenAndName (line 111) | func (ab *AppClientBuilder) AppWithTokenAndName(id uint, token, name s...
    method InternalAppWithTokenAndName (line 116) | func (ab *AppClientBuilder) InternalAppWithTokenAndName(id uint, token...
    method appWithTokenAndName (line 120) | func (ab *AppClientBuilder) appWithTokenAndName(id uint, token, name s...
    method NewAppWithTokenAndName (line 126) | func (ab *AppClientBuilder) NewAppWithTokenAndName(id uint, token, nam...
    method NewInternalAppWithTokenAndName (line 131) | func (ab *AppClientBuilder) NewInternalAppWithTokenAndName(id uint, to...
    method newAppWithTokenAndName (line 135) | func (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, nam...
    method AppWithTokenAndDefaultPriority (line 142) | func (ab *AppClientBuilder) AppWithTokenAndDefaultPriority(id uint, to...
    method Client (line 149) | func (ab *AppClientBuilder) Client(id uint) *AppClientBuilder {
    method ClientWithToken (line 154) | func (ab *AppClientBuilder) ClientWithToken(id uint, token string) *Ap...
    method NewClientWithToken (line 160) | func (ab *AppClientBuilder) NewClientWithToken(id uint, token string) ...
  type MessageBuilder (line 26) | type MessageBuilder struct
    method Message (line 167) | func (mb *MessageBuilder) Message(id uint) *MessageBuilder {
    method NewMessage (line 173) | func (mb *MessageBuilder) NewMessage(id uint) model.Message {
  function NewDBWithDefaultUser (line 32) | func NewDBWithDefaultUser(t *testing.T) *Database {
  function NewDB (line 40) | func NewDB(t *testing.T) *Database {

FILE: test/testdb/database_test.go
  function Test_WithDefault (line 13) | func Test_WithDefault(t *testing.T) {
  function TestDatabaseSuite (line 21) | func TestDatabaseSuite(t *testing.T) {
  type DatabaseSuite (line 25) | type DatabaseSuite struct
    method BeforeTest (line 30) | func (s *DatabaseSuite) BeforeTest(suiteName, testName string) {
    method AfterTest (line 35) | func (s *DatabaseSuite) AfterTest(suiteName, testName string) {
    method Test_Users (line 39) | func (s *DatabaseSuite) Test_Users() {
    method Test_Clients (line 63) | func (s *DatabaseSuite) Test_Clients() {
    method Test_Apps (line 95) | func (s *DatabaseSuite) Test_Apps() {
    method Test_Messages (line 155) | func (s *DatabaseSuite) Test_Messages() {

FILE: test/tmpdir.go
  type TmpDir (line 9) | type TmpDir struct
    method Path (line 14) | func (c TmpDir) Path(elem ...string) string {
    method Clean (line 19) | func (c TmpDir) Clean() error {
  function NewTmpDir (line 24) | func NewTmpDir(prefix string) TmpDir {

FILE: test/tmpdir_test.go
  function TestTmpDir (line 10) | func TestTmpDir(t *testing.T) {

FILE: test/token.go
  function Tokens (line 6) | func Tokens(tokens ...string) func() string {

FILE: test/token_test.go
  function TestTokenGeneration (line 9) | func TestTokenGeneration(t *testing.T) {

FILE: ui/serve.go
  type uiConfig (line 18) | type uiConfig struct
  function Register (line 24) | func Register(r *gin.Engine, version model.VersionInfo, register bool) {
  function noop (line 46) | func noop(s string) string {
  function serveFile (line 50) | func serveFile(name, contentType string, convert func(string) string) gi...

FILE: ui/src/CurrentUser.ts
  class CurrentUser (line 10) | class CurrentUser {
    method constructor (line 20) | public constructor(private readonly snack: SnackReporter) {}

FILE: ui/src/application/AddApplicationDialog.tsx
  type IProps (line 12) | interface IProps {

FILE: ui/src/application/AppStore.ts
  class AppStore (line 10) | class AppStore extends BaseStore<IApplication> {
    method constructor (line 13) | public constructor(private readonly snack: SnackReporter) {
    method deleteImage (line 39) | public async deleteImage(id: number): Promise<void> {

FILE: ui/src/application/Applications.tsx
  type IRowProps (line 196) | interface IRowProps {

FILE: ui/src/application/UpdateApplicationDialog.tsx
  type IProps (line 12) | interface IProps {

FILE: ui/src/client/AddClientDialog.tsx
  type IProps (line 10) | interface IProps {

FILE: ui/src/client/ClientStore.ts
  class ClientStore (line 8) | class ClientStore extends BaseStore<IClient> {
    method constructor (line 9) | public constructor(private readonly snack: SnackReporter) {
    method requestDelete (line 16) | protected requestDelete(id: number): Promise<void> {

FILE: ui/src/client/Clients.tsx
  type IRowProps (line 96) | interface IRowProps {

FILE: ui/src/client/UpdateClientDialog.tsx
  type IProps (line 11) | interface IProps {

FILE: ui/src/common/BaseStore.ts
  type HasID (line 3) | interface HasID {
  type IClearable (line 7) | interface IClearable {

FILE: ui/src/common/ConfirmDialog.tsx
  type IProps (line 9) | interface IProps {
  function ConfirmDialog (line 16) | function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) {

FILE: ui/src/common/ConnectionErrorBanner.tsx
  type ConnectionErrorBannerProps (line 5) | interface ConnectionErrorBannerProps {

FILE: ui/src/common/Container.tsx
  type IProps (line 11) | interface IProps {

FILE: ui/src/common/CopyableSecret.tsx
  type IProps (line 9) | interface IProps {

FILE: ui/src/common/DefaultPage.tsx
  type IProps (line 5) | interface IProps {

FILE: ui/src/common/LoadingSpinner.tsx
  function LoadingSpinner (line 6) | function LoadingSpinner() {

FILE: ui/src/common/NumberField.tsx
  type NumberFieldProps (line 4) | interface NumberFieldProps {

FILE: ui/src/common/SettingsDialog.tsx
  type IProps (line 12) | interface IProps {

FILE: ui/src/config.ts
  type IConfig (line 3) | interface IConfig {
  type Window (line 10) | interface Window {
  function set (line 22) | function set<Key extends keyof IConfig>(key: Key, value: IConfig[Key]): ...
  function get (line 26) | function get<K extends keyof IConfig>(key: K): IConfig[K] {

FILE: ui/src/layout/Header.tsx
  type IProps (line 74) | interface IProps {

FILE: ui/src/layout/Navigation.tsx
  type IProps (line 40) | interface IProps {

FILE: ui/src/layout/theme.ts
  type ThemeKey (line 1) | type ThemeKey = 'dark' | 'light' | 'system';

FILE: ui/src/message/Message.tsx
  constant PREVIEW_LENGTH (line 16) | const PREVIEW_LENGTH = 500;
  type IProps (line 94) | interface IProps {

FILE: ui/src/message/MessagesStore.ts
  type MessagesState (line 12) | interface MessagesState {
  type PendingDelete (line 19) | interface PendingDelete {
  class MessagesStore (line 24) | class MessagesStore {
    method constructor (line 30) | public constructor(
    method removeFromList (line 171) | private removeFromList(messages: IMessage[], messageToDelete: IMessage...

FILE: ui/src/message/PushMessageDialog.tsx
  type IProps (line 12) | interface IProps {

FILE: ui/src/message/WebSocketStore.ts
  class WebSocketStore (line 7) | class WebSocketStore {
    method constructor (line 11) | public constructor(

FILE: ui/src/message/extras.ts
  type RenderMode (line 3) | enum RenderMode {

FILE: ui/src/plugin/PluginDetailView.tsx
  type IPanelWrapperProps (line 90) | interface IPanelWrapperProps {
  type IConfigurerPanelProps (line 144) | interface IConfigurerPanelProps {
  type IDisplayerPanelProps (line 183) | interface IDisplayerPanelProps {
  type IPluginInfo (line 193) | interface IPluginInfo {

FILE: ui/src/plugin/PluginStore.ts
  class PluginStore (line 8) | class PluginStore extends BaseStore<IPlugin> {
    method constructor (line 11) | public constructor(private readonly snack: SnackReporter) {

FILE: ui/src/plugin/Plugins.tsx
  type IRowProps (line 57) | interface IRowProps {

FILE: ui/src/reactions.ts
  constant AUDIO_REPEAT_DELAY (line 5) | const AUDIO_REPEAT_DELAY = 1000;

FILE: ui/src/registerServiceWorker.ts
  function unregister (line 1) | function unregister() {

FILE: ui/src/snack/SnackManager.ts
  type SnackReporter (line 3) | interface SnackReporter {
  class SnackManager (line 7) | class SnackManager {

FILE: ui/src/snack/browserNotification.ts
  function mayAllowPermission (line 5) | function mayAllowPermission(): boolean {
  function requestPermission (line 9) | function requestPermission() {
  function notifyNewMessage (line 18) | function notifyNewMessage(msg: IMessage) {
  function closeAndFocus (line 29) | function closeAndFocus(event: Event) {
  function closeAfterTimeout (line 39) | function closeAfterTimeout(event: Event) {

FILE: ui/src/stores.tsx
  type StoreMapping (line 11) | interface StoreMapping {

FILE: ui/src/tests/application.test.ts
  type Col (line 17) | enum Col {

FILE: ui/src/tests/client.test.ts
  type Col (line 18) | enum Col {

FILE: ui/src/tests/message.test.ts
  type Msg (line 36) | interface Msg {

FILE: ui/src/tests/plugin.test.ts
  type Col (line 25) | enum Col {

FILE: ui/src/tests/setup.ts
  type GotifyTest (line 11) | interface GotifyTest {

FILE: ui/src/tests/user.test.ts
  type Col (line 17) | enum Col {

FILE: ui/src/typedef/react-timeago.d.ts
  type FormatterOptions (line 4) | type FormatterOptions = {
  type Formatter (line 8) | type Formatter = (options: FormatterOptions) => React.ReactNode;
  type ITimeAgoProps (line 10) | interface ITimeAgoProps {
  class TimeAgo (line 15) | class TimeAgo extends React.Component<ITimeAgoProps, unknown> {}

FILE: ui/src/types.ts
  type IApplication (line 1) | interface IApplication {
  type IClient (line 13) | interface IClient {
  type IPlugin (line 20) | interface IPlugin {
  type IMessage (line 32) | interface IMessage {
  type IMessageExtras (line 43) | interface IMessageExtras {
  type IPagedMessages (line 47) | interface IPagedMessages {
  type IPaging (line 52) | interface IPaging {
  type IUser (line 59) | interface IUser {
  type IVersion (line 65) | interface IVersion {

FILE: ui/src/user/AddEditUserDialog.tsx
  type IProps (line 12) | interface IProps {

FILE: ui/src/user/Register.tsx
  type IProps (line 10) | interface IProps {

FILE: ui/src/user/UserStore.ts
  class UserStore (line 8) | class UserStore extends BaseStore<IUser> {
    method constructor (line 9) | constructor(private readonly snack: SnackReporter) {
    method requestDelete (line 16) | protected requestDelete(id: number): Promise<void> {

FILE: ui/src/user/Users.tsx
  type IRowProps (line 20) | interface IRowProps {

FILE: ui/vite.config.ts
  constant GOTIFY_SERVER_PORT (line 4) | const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80';
Condensed preview — 216 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (747K chars).
[
  {
    "path": ".dockerignore",
    "chars": 81,
    "preview": "vendor/\n.idea/\nbuild/\nlicenses/\ncoverage.txt\ndata/\nimages/\n.git/\n*/node_modules/\n"
  },
  {
    "path": ".editorconfig",
    "chars": 366,
    "preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_size = 4\ntrim_trailing_whitespace ="
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 599,
    "preview": "# These are supported funding model platforms\n\ngithub: jmattheis\npatreon: # Replace with a single Patreon username\nopen_"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 1203,
    "preview": "---\nname: Bug report\nabout: Found a bug? Tell us and help us improve\ntitle: ''\nlabels: a:bug\nassignees: ''\n\n---\n\n**Can t"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 602,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: a:feature\nassignees: ''\n\n---\n\n**Is y"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/questions.md",
    "chars": 1046,
    "preview": "---\nname: Questions\nabout: Having difficulties with gotify? Feel free to ask here\ntitle: ''\nlabels: question\nassignees: "
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 2266,
    "preview": "name: build\non: [push, pull_request]\n\njobs:\n  gotify:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/setup-"
  },
  {
    "path": ".gitignore",
    "chars": 113,
    "preview": "vendor/\n.idea/\nbuild/\ncerts/\nbuild/\nlicenses/\ncoverage.txt\n*/node_modules/\n**/*-packr.go\nconfig.yml\ndata/\nimages/"
  },
  {
    "path": ".golangci.yml",
    "chars": 996,
    "preview": "version: \"2\"\nlinters:\n  enable:\n    - asciicheck\n    - copyloopvar\n    - godot\n    - gomodguard\n    - goprintffuncname\n "
  },
  {
    "path": "CODEOWNERS",
    "chars": 20,
    "preview": "* @gotify/committers"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 3218,
    "preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 3484,
    "preview": "# Contributing\n\nThanks for your interest in Gotify!\n\nFirst of all, please note that we have a [code of conduct](CODE_OF_"
  },
  {
    "path": "GO_VERSION",
    "chars": 7,
    "preview": "1.26.0\n"
  },
  {
    "path": "LICENSE",
    "chars": 1216,
    "preview": "MIT License\n\nCopyright (c) 2018 jmattheis\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "Makefile",
    "chars": 6048,
    "preview": "LICENSE_DIR=./licenses/\nBUILD_DIR=./build\nDOCKER_DIR=./docker/\nSHELL := /bin/bash\nGO_VERSION=$(shell go mod edit -json |"
  },
  {
    "path": "README.md",
    "chars": 3174,
    "preview": "<p align=\"center\">\n    <a href=\"https://github.com/gotify/logo\">\n        <img height=\"275px\" src=\"https://raw.githubuser"
  },
  {
    "path": "SECURITY.md",
    "chars": 338,
    "preview": "# Security Policy\n\n## Supported Versions\n\nOnly the latest version.\n\n## Reporting a Vulnerability\n\nPlease report (suspect"
  },
  {
    "path": "api/application.go",
    "chars": 13254,
    "preview": "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\"gith"
  },
  {
    "path": "api/application_test.go",
    "chars": 20765,
    "preview": "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\"st"
  },
  {
    "path": "api/client.go",
    "chars": 6267,
    "preview": "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"
  },
  {
    "path": "api/client_test.go",
    "chars": 6304,
    "preview": "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/go"
  },
  {
    "path": "api/errorHandling.go",
    "chars": 197,
    "preview": "package api\n\nimport \"github.com/gin-gonic/gin\"\n\nfunc successOrAbort(ctx *gin.Context, code int, err error) (success bool"
  },
  {
    "path": "api/errorHandling_test.go",
    "chars": 289,
    "preview": "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"
  },
  {
    "path": "api/health.go",
    "chars": 956,
    "preview": "package api\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// The HealthDatabase interfac"
  },
  {
    "path": "api/health_test.go",
    "chars": 1202,
    "preview": "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"
  },
  {
    "path": "api/internalutil.go",
    "chars": 314,
    "preview": "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, na"
  },
  {
    "path": "api/message.go",
    "chars": 12101,
    "preview": "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"
  },
  {
    "path": "api/message_test.go",
    "chars": 18466,
    "preview": "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\"githu"
  },
  {
    "path": "api/plugin.go",
    "chars": 11087,
    "preview": "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/got"
  },
  {
    "path": "api/plugin_test.go",
    "chars": 21086,
    "preview": "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"
  },
  {
    "path": "api/stream/client.go",
    "chars": 2701,
    "preview": "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 ("
  },
  {
    "path": "api/stream/once.go",
    "chars": 722,
    "preview": "// Copyright 2009 The Go Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license "
  },
  {
    "path": "api/stream/once_test.go",
    "chars": 757,
    "preview": "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"
  },
  {
    "path": "api/stream/stream.go",
    "chars": 5653,
    "preview": "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\"gith"
  },
  {
    "path": "api/stream/stream_test.go",
    "chars": 16906,
    "preview": "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"
  },
  {
    "path": "api/tokens.go",
    "chars": 222,
    "preview": "package api\n\nimport (\n\t\"github.com/gotify/server/v2/auth\"\n)\n\nvar generateApplicationToken = auth.GenerateApplicationToke"
  },
  {
    "path": "api/tokens_test.go",
    "chars": 346,
    "preview": "package api\n\nimport (\n\t\"regexp\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTokenGeneration(t *testing"
  },
  {
    "path": "api/user.go",
    "chars": 12055,
    "preview": "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\"gi"
  },
  {
    "path": "api/user_test.go",
    "chars": 12398,
    "preview": "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/got"
  },
  {
    "path": "app.go",
    "chars": 1299,
    "preview": "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\"git"
  },
  {
    "path": "auth/authentication.go",
    "chars": 5811,
    "preview": "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/pass"
  },
  {
    "path": "auth/authentication_test.go",
    "chars": 10597,
    "preview": "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"
  },
  {
    "path": "auth/cors.go",
    "chars": 1864,
    "preview": "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"
  },
  {
    "path": "auth/cors_test.go",
    "chars": 2316,
    "preview": "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"
  },
  {
    "path": "auth/password/password.go",
    "chars": 528,
    "preview": "package password\n\nimport \"golang.org/x/crypto/bcrypt\"\n\n// CreatePassword returns a hashed version of the given password."
  },
  {
    "path": "auth/password/password_test.go",
    "chars": 491,
    "preview": "package password\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestPasswordSuccess(t *testing.T) {"
  },
  {
    "path": "auth/token.go",
    "chars": 1658,
    "preview": "package auth\n\nimport (\n\t\"crypto/rand\"\n\t\"math/big\"\n)\n\nvar (\n\ttokenCharacters   = []byte(\"abcdefghijklmnopqrstuvwxyzABCDEF"
  },
  {
    "path": "auth/token_test.go",
    "chars": 880,
    "preview": "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/str"
  },
  {
    "path": "auth/util.go",
    "chars": 975,
    "preview": "package auth\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gotify/server/v2/model\"\n)\n\n// RegisterAuthentication reg"
  },
  {
    "path": "auth/util_test.go",
    "chars": 1417,
    "preview": "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"
  },
  {
    "path": "config/config.go",
    "chars": 2231,
    "preview": "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"
  },
  {
    "path": "config/config_test.go",
    "chars": 4539,
    "preview": "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/te"
  },
  {
    "path": "config.example.yml",
    "chars": 3194,
    "preview": "# Example configuration file for the server.\n# Save it to `config.yml` when edited\n\nserver:\n  keepaliveperiodseconds: 0 "
  },
  {
    "path": "database/application.go",
    "chars": 2507,
    "preview": "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"
  },
  {
    "path": "database/application_test.go",
    "chars": 3194,
    "preview": "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 ("
  },
  {
    "path": "database/client.go",
    "chars": 1781,
    "preview": "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"
  },
  {
    "path": "database/client_test.go",
    "chars": 1992,
    "preview": "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 ("
  },
  {
    "path": "database/database.go",
    "chars": 4228,
    "preview": "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/"
  },
  {
    "path": "database/database_test.go",
    "chars": 3350,
    "preview": "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/"
  },
  {
    "path": "database/message.go",
    "chars": 3089,
    "preview": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetMessageByID returns the message"
  },
  {
    "path": "database/message_test.go",
    "chars": 8652,
    "preview": "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"
  },
  {
    "path": "database/migration_test.go",
    "chars": 2071,
    "preview": "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"
  },
  {
    "path": "database/ping.go",
    "chars": 190,
    "preview": "package database\n\n// Ping pings the database to verify the connection.\nfunc (d *GormDatabase) Ping() error {\n\tsqldb, err"
  },
  {
    "path": "database/ping_test.go",
    "chars": 276,
    "preview": "package database\n\nimport (\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc (s *DatabaseSuite) TestPing_onValidDB() {\n\terr "
  },
  {
    "path": "database/plugin.go",
    "chars": 2456,
    "preview": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"gorm.io/gorm\"\n)\n\n// GetPluginConfByUser gets plugin co"
  },
  {
    "path": "database/plugin_test.go",
    "chars": 2103,
    "preview": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stret"
  },
  {
    "path": "database/user.go",
    "chars": 2024,
    "preview": "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 "
  },
  {
    "path": "database/user_test.go",
    "chars": 6071,
    "preview": "package database\n\nimport (\n\t\"github.com/gotify/server/v2/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stret"
  },
  {
    "path": "docker/Dockerfile",
    "chars": 2154,
    "preview": "ARG BUILDKIT_SBOM_SCAN_CONTEXT=true\n# Suppress warning about invalid variable expansion\nARG GO_VERSION=PLEASE_PROVIDE_GO"
  },
  {
    "path": "docs/package.go",
    "chars": 2171,
    "preview": "// Package docs Gotify REST-API.\n//\n// This is the documentation of the Gotify REST-API.\n//\n//\t# Authentication\n//\tIn Go"
  },
  {
    "path": "docs/spec.json",
    "chars": 68209,
    "preview": "{\n  \"consumes\": [\n    \"application/json\"\n  ],\n  \"produces\": [\n    \"application/json\"\n  ],\n  \"schemes\": [\n    \"http\",\n   "
  },
  {
    "path": "docs/swagger.go",
    "chars": 488,
    "preview": "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 sp"
  },
  {
    "path": "docs/swagger_test.go",
    "chars": 690,
    "preview": "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/serve"
  },
  {
    "path": "docs/ui.go",
    "chars": 1910,
    "preview": "package docs\n\nimport \"github.com/gin-gonic/gin\"\n\nvar ui = `\n<!-- HTML for static distribution bundle build -->\n<!DOCTYPE"
  },
  {
    "path": "docs/ui_test.go",
    "chars": 466,
    "preview": "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"
  },
  {
    "path": "error/handler.go",
    "chars": 1580,
    "preview": "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"
  },
  {
    "path": "error/handler_test.go",
    "chars": 2503,
    "preview": "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\""
  },
  {
    "path": "error/notfound.go",
    "chars": 430,
    "preview": "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 cre"
  },
  {
    "path": "error/notfound_test.go",
    "chars": 384,
    "preview": "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\""
  },
  {
    "path": "fracdex/fracdex.go",
    "chars": 8806,
    "preview": "// Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex\npackage fracdex\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t"
  },
  {
    "path": "fracdex/fracdex_test.go",
    "chars": 2866,
    "preview": "// Licensed under CC0-1.0 Universial by https://github.com/rocicorp/fracdex\npackage fracdex\n\nimport (\n\t\"math\"\n\t\"strings\""
  },
  {
    "path": "go.mod",
    "chars": 2692,
    "preview": "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\t"
  },
  {
    "path": "go.sum",
    "chars": 14179,
    "preview": "filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:"
  },
  {
    "path": "mode/mode.go",
    "chars": 694,
    "preview": "package mode\n\nimport \"github.com/gin-gonic/gin\"\n\nconst (\n\t// Dev for development mode.\n\tDev = \"dev\"\n\t// Prod for product"
  },
  {
    "path": "mode/mode_test.go",
    "chars": 640,
    "preview": "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"
  },
  {
    "path": "model/application.go",
    "chars": 2143,
    "preview": "package model\n\nimport \"time\"\n\n// Application Model\n//\n// The Application holds information about an app which can send n"
  },
  {
    "path": "model/client.go",
    "chars": 952,
    "preview": "package model\n\nimport \"time\"\n\n// Client Model\n//\n// The Client holds information about a device which can receive notifi"
  },
  {
    "path": "model/error.go",
    "chars": 530,
    "preview": "package model\n\n// Error Model\n//\n// The Error contains error relevant information.\n//\n// swagger:model Error\ntype Error "
  },
  {
    "path": "model/health.go",
    "chars": 566,
    "preview": "package model\n\n// Health Model\n//\n// Health represents how healthy the application is.\n//\n// swagger:model Health\ntype H"
  },
  {
    "path": "model/message.go",
    "chars": 2088,
    "preview": "package model\n\nimport (\n\t\"time\"\n)\n\n// Message holds information about a message.\ntype Message struct {\n\tID            ui"
  },
  {
    "path": "model/paging.go",
    "chars": 1262,
    "preview": "package model\n\n// Paging Model\n//\n// The Paging holds information about the limit and making requests to the next page.\n"
  },
  {
    "path": "model/pluginconf.go",
    "chars": 1807,
    "preview": "package model\n\n// PluginConf holds information about the plugin.\ntype PluginConf struct {\n\tID            uint `gorm:\"pri"
  },
  {
    "path": "model/user.go",
    "chars": 2324,
    "preview": "package model\n\n// The User holds information about the credentials of a user and its application and client tokens.\ntype"
  },
  {
    "path": "model/version.go",
    "chars": 512,
    "preview": "package model\n\n// VersionInfo Model\n//\n// swagger:model VersionInfo\ntype VersionInfo struct {\n\t// The current version.\n\t"
  },
  {
    "path": "plugin/compat/instance.go",
    "chars": 2191,
    "preview": "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.\nt"
  },
  {
    "path": "plugin/compat/plugin.go",
    "chars": 598,
    "preview": "package compat\n\n// Plugin is an abstraction of plugin handler.\ntype Plugin interface {\n\tPluginInfo() Info\n\tNewPluginInst"
  },
  {
    "path": "plugin/compat/plugin_test.go",
    "chars": 373,
    "preview": "package compat\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst examplePluginPath = \"github.com/goti"
  },
  {
    "path": "plugin/compat/v1.go",
    "chars": 4532,
    "preview": "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 i"
  },
  {
    "path": "plugin/compat/v1_test.go",
    "chars": 3136,
    "preview": "package compat\n\nimport (\n\t\"testing\"\n\n\tpapiv1 \"github.com/gotify/plugin-api\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"gith"
  },
  {
    "path": "plugin/compat/wrap.go",
    "chars": 1049,
    "preview": "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 ra"
  },
  {
    "path": "plugin/compat/wrap_test.go",
    "chars": 3702,
    "preview": "//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\""
  },
  {
    "path": "plugin/compat/wrap_test_norace.go",
    "chars": 85,
    "preview": "//go:build !race\n// +build !race\n\npackage compat\n\nvar extraGoBuildFlags = []string{}\n"
  },
  {
    "path": "plugin/compat/wrap_test_race.go",
    "chars": 90,
    "preview": "//go:build race\n// +build race\n\npackage compat\n\nvar extraGoBuildFlags = []string{\"-race\"}\n"
  },
  {
    "path": "plugin/example/clock/main.go",
    "chars": 1321,
    "preview": "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 retu"
  },
  {
    "path": "plugin/example/echo/echo.go",
    "chars": 2924,
    "preview": "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"
  },
  {
    "path": "plugin/example/minimal/main.go",
    "chars": 713,
    "preview": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGoti"
  },
  {
    "path": "plugin/manager.go",
    "chars": 11791,
    "preview": "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\"s"
  },
  {
    "path": "plugin/manager_test.go",
    "chars": 13785,
    "preview": "//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"
  },
  {
    "path": "plugin/manager_test_norace.go",
    "chars": 85,
    "preview": "//go:build !race\n// +build !race\n\npackage plugin\n\nvar extraGoBuildFlags = []string{}\n"
  },
  {
    "path": "plugin/manager_test_race.go",
    "chars": 90,
    "preview": "//go:build race\n// +build race\n\npackage plugin\n\nvar extraGoBuildFlags = []string{\"-race\"}\n"
  },
  {
    "path": "plugin/messagehandler.go",
    "chars": 809,
    "preview": "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\nt"
  },
  {
    "path": "plugin/pluginenabled.go",
    "chars": 370,
    "preview": "package plugin\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc requirePluginEnabled(id uint, db Database) gin.H"
  },
  {
    "path": "plugin/pluginenabled_test.go",
    "chars": 778,
    "preview": "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/mode"
  },
  {
    "path": "plugin/storagehandler.go",
    "chars": 450,
    "preview": "package plugin\n\ntype dbStorageHandler struct {\n\tpluginID uint\n\tdb       Database\n}\n\nfunc (c dbStorageHandler) Save(b []b"
  },
  {
    "path": "plugin/testing/broken/cantinstantiate/main.go",
    "chars": 749,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nf"
  },
  {
    "path": "plugin/testing/broken/malformedconstructor/main.go",
    "chars": 717,
    "preview": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGoti"
  },
  {
    "path": "plugin/testing/broken/noinstance/main.go",
    "chars": 325,
    "preview": "package main\n\nimport (\n\t\"github.com/gotify/plugin-api\"\n)\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGoti"
  },
  {
    "path": "plugin/testing/broken/nothing/main.go",
    "chars": 85,
    "preview": "package main\n\nfunc main() {\n\tpanic(\"this is a broken plugin for testing purposes\")\n}\n"
  },
  {
    "path": "plugin/testing/broken/unknowninfo/main.go",
    "chars": 246,
    "preview": "package main\n\n// GetGotifyPluginInfo returns gotify plugin info\nfunc GetGotifyPluginInfo() string {\n\treturn \"github.com/"
  },
  {
    "path": "plugin/testing/mock/mock.go",
    "chars": 4431,
    "preview": "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)"
  },
  {
    "path": "renovate.json",
    "chars": 2103,
    "preview": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"config:recommended\",\n    \":seman"
  },
  {
    "path": "router/router.go",
    "chars": 7562,
    "preview": "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"
  },
  {
    "path": "router/router_test.go",
    "chars": 12693,
    "preview": "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\"gith"
  },
  {
    "path": "runner/runner.go",
    "chars": 4354,
    "preview": "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"
  },
  {
    "path": "runner/umask.go",
    "chars": 77,
    "preview": "//go:build unix\n\npackage runner\n\nimport \"syscall\"\n\nvar umask = syscall.Umask\n"
  },
  {
    "path": "runner/umask_fallback.go",
    "chars": 70,
    "preview": "//go:build !unix\n\npackage runner\n\nfunc umask(_ int) int {\n\treturn 0\n}\n"
  },
  {
    "path": "test/asserts.go",
    "chars": 1004,
    "preview": "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/"
  },
  {
    "path": "test/asserts_test.go",
    "chars": 973,
    "preview": "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/stre"
  },
  {
    "path": "test/assets/text.txt",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "test/auth.go",
    "chars": 257,
    "preview": "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 authentic"
  },
  {
    "path": "test/auth_test.go",
    "chars": 386,
    "preview": "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/got"
  },
  {
    "path": "test/filepath.go",
    "chars": 549,
    "preview": "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 o"
  },
  {
    "path": "test/filepath_test.go",
    "chars": 1075,
    "preview": "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 *testin"
  },
  {
    "path": "test/testdb/database.go",
    "chars": 8832,
    "preview": "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/v"
  },
  {
    "path": "test/testdb/database_test.go",
    "chars": 5521,
    "preview": "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\"git"
  },
  {
    "path": "test/tmpdir.go",
    "chars": 552,
    "preview": "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"
  },
  {
    "path": "test/tmpdir_test.go",
    "chars": 429,
    "preview": "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\t"
  },
  {
    "path": "test/token.go",
    "chars": 325,
    "preview": "package test\n\nimport \"sync\"\n\n// Tokens returns a token generation function with takes a series of tokens and output them"
  },
  {
    "path": "test/token_test.go",
    "chars": 275,
    "preview": "package test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTokenGeneration(t *testing.T) {\n\tmo"
  },
  {
    "path": "ui/.gitignore",
    "chars": 285,
    "preview": "# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n\n# testing\n/cov"
  },
  {
    "path": "ui/.prettierrc",
    "chars": 233,
    "preview": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 4,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"e"
  },
  {
    "path": "ui/.yarnrc",
    "chars": 20,
    "preview": "enableTelemetry \"0\"\n"
  },
  {
    "path": "ui/eslint.config.mjs",
    "chars": 180,
    "preview": "// @ts-check\n\nimport eslint from '@eslint/js';\nimport tseslint from 'typescript-eslint';\n\nexport default tseslint.config"
  },
  {
    "path": "ui/index.html",
    "chars": 2353,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width"
  },
  {
    "path": "ui/package.json",
    "chars": 2166,
    "preview": "{\n  \"name\": \"gotify-ui\",\n  \"version\": \"0.2.0\",\n  \"private\": true,\n  \"homepage\": \".\",\n  \"proxy\": \"http://localhost:80\",\n "
  },
  {
    "path": "ui/public/manifest.json",
    "chars": 175,
    "preview": "{\n  \"short_name\": \"Gotify\",\n  \"name\": \"Gotify WebApp\",\n  \"start_url\": \"./index.html\",\n  \"display\": \"standalone\",\n  \"them"
  },
  {
    "path": "ui/serve.go",
    "chars": 1474,
    "preview": "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."
  },
  {
    "path": "ui/src/CurrentUser.ts",
    "chars": 6513,
    "preview": "import axios, {AxiosError, AxiosResponse} from 'axios';\nimport * as config from './config';\nimport {detect} from 'detect"
  },
  {
    "path": "ui/src/apiAuth.ts",
    "chars": 1031,
    "preview": "import axios from 'axios';\nimport {CurrentUser} from './CurrentUser';\nimport {SnackReporter} from './snack/SnackManager'"
  },
  {
    "path": "ui/src/application/AddApplicationDialog.tsx",
    "chars": 3037,
    "preview": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/ma"
  },
  {
    "path": "ui/src/application/AppStore.ts",
    "chars": 3493,
    "preview": "import axios from 'axios';\nimport {generateKeyBetween} from 'fractional-indexing';\nimport {action, runInAction} from 'mo"
  },
  {
    "path": "ui/src/application/Applications.tsx",
    "chars": 10764,
    "preview": "import React, {ChangeEvent, useEffect, useRef, useState} from 'react';\nimport Grid from '@mui/material/Grid';\nimport Ico"
  },
  {
    "path": "ui/src/application/UpdateApplicationDialog.tsx",
    "chars": 3251,
    "preview": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/ma"
  },
  {
    "path": "ui/src/client/AddClientDialog.tsx",
    "chars": 2022,
    "preview": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dial"
  },
  {
    "path": "ui/src/client/ClientStore.ts",
    "chars": 1344,
    "preview": "import {BaseStore} from '../common/BaseStore';\nimport axios from 'axios';\nimport * as config from '../config';\nimport {a"
  },
  {
    "path": "ui/src/client/Clients.tsx",
    "chars": 4905,
    "preview": "import React, {useEffect, useState} from 'react';\nimport Grid from '@mui/material/Grid';\nimport IconButton from '@mui/ma"
  },
  {
    "path": "ui/src/client/UpdateClientDialog.tsx",
    "chars": 2315,
    "preview": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dial"
  },
  {
    "path": "ui/src/common/BaseStore.ts",
    "chars": 1440,
    "preview": "import {action, observable} from 'mobx';\n\ninterface HasID {\n    id: number;\n}\n\nexport interface IClearable {\n    clear()"
  },
  {
    "path": "ui/src/common/ConfirmDialog.tsx",
    "chars": 1442,
    "preview": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/ma"
  },
  {
    "path": "ui/src/common/ConnectionErrorBanner.tsx",
    "chars": 758,
    "preview": "import React from 'react';\nimport Button from '@mui/material/Button';\nimport Typography from '@mui/material/Typography';"
  },
  {
    "path": "ui/src/common/Container.tsx",
    "chars": 539,
    "preview": "import Paper from '@mui/material/Paper';\nimport {makeStyles} from 'tss-react/mui';\nimport * as React from 'react';\n\ncons"
  },
  {
    "path": "ui/src/common/CopyableSecret.tsx",
    "chars": 1545,
    "preview": "import IconButton from '@mui/material/IconButton';\nimport Typography from '@mui/material/Typography';\nimport Visibility "
  },
  {
    "path": "ui/src/common/DefaultPage.tsx",
    "chars": 785,
    "preview": "import Grid from '@mui/material/Grid';\nimport Typography from '@mui/material/Typography';\nimport React, {FC} from 'react"
  },
  {
    "path": "ui/src/common/LastUsedCell.tsx",
    "chars": 530,
    "preview": "import {Typography} from '@mui/material';\nimport React from 'react';\nimport TimeAgo from 'react-timeago';\nimport {TimeAg"
  },
  {
    "path": "ui/src/common/LoadingSpinner.tsx",
    "chars": 437,
    "preview": "import CircularProgress from '@mui/material/CircularProgress';\nimport Grid from '@mui/material/Grid';\nimport React from "
  },
  {
    "path": "ui/src/common/Markdown.tsx",
    "chars": 964,
    "preview": "import React from 'react';\nimport ReactMarkdown, {defaultUrlTransform} from 'react-markdown';\nimport type {UrlTransform}"
  },
  {
    "path": "ui/src/common/NumberField.tsx",
    "chars": 1014,
    "preview": "import {TextField, TextFieldProps} from '@mui/material';\nimport React from 'react';\n\nexport interface NumberFieldProps {"
  },
  {
    "path": "ui/src/common/ScrollUpButton.tsx",
    "chars": 1283,
    "preview": "import Fab from '@mui/material/Fab';\nimport KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';\nimport React fro"
  },
  {
    "path": "ui/src/common/SettingsDialog.tsx",
    "chars": 2103,
    "preview": "import React, {useState} from 'react';\nimport Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dial"
  },
  {
    "path": "ui/src/common/TimeAgoFormatter.ts",
    "chars": 307,
    "preview": "import {Formatter} from 'react-timeago';\nimport {makeIntlFormatter} from 'react-timeago/defaultFormatter';\n\nexport const"
  },
  {
    "path": "ui/src/config.ts",
    "chars": 586,
    "preview": "import {IVersion} from './types';\n\nexport interface IConfig {\n    url: string;\n    register: boolean;\n    version: IVers"
  },
  {
    "path": "ui/src/index.tsx",
    "chars": 2424,
    "preview": "import * as React from 'react';\nimport {createRoot} from 'react-dom/client';\nimport 'typeface-roboto';\nimport {initAxios"
  },
  {
    "path": "ui/src/layout/Header.tsx",
    "chars": 7262,
    "preview": "import AppBar from '@mui/material/AppBar';\nimport Button, {ButtonProps} from '@mui/material/Button';\nimport IconButton f"
  },
  {
    "path": "ui/src/layout/Layout.tsx",
    "chars": 7628,
    "preview": "import {\n    createTheme,\n    ThemeProvider,\n    StyledEngineProvider,\n    Theme,\n    useMediaQuery,\n} from '@mui/materi"
  },
  {
    "path": "ui/src/layout/Navigation.tsx",
    "chars": 4366,
    "preview": "import Divider from '@mui/material/Divider';\nimport Drawer from '@mui/material/Drawer';\nimport {Theme} from '@mui/materi"
  },
  {
    "path": "ui/src/layout/theme.ts",
    "chars": 189,
    "preview": "export type ThemeKey = 'dark' | 'light' | 'system';\n\nexport const isThemeKey = (value: string | null): value is ThemeKey"
  },
  {
    "path": "ui/src/message/Message.tsx",
    "chars": 9353,
    "preview": "import {Button, Theme, useMediaQuery, useTheme} from '@mui/material';\nimport IconButton from '@mui/material/IconButton';"
  },
  {
    "path": "ui/src/message/Messages.tsx",
    "chars": 6177,
    "preview": "import Grid from '@mui/material/Grid';\nimport Typography from '@mui/material/Typography';\nimport React from 'react';\nimp"
  },
  {
    "path": "ui/src/message/MessagesStore.ts",
    "chars": 7183,
    "preview": "import {BaseStore} from '../common/BaseStore';\nimport {action, IObservableArray, observable, reaction, runInAction} from"
  },
  {
    "path": "ui/src/message/PushMessageDialog.tsx",
    "chars": 3267,
    "preview": "import Button from '@mui/material/Button';\nimport Dialog from '@mui/material/Dialog';\nimport DialogActions from '@mui/ma"
  },
  {
    "path": "ui/src/message/WebSocketStore.ts",
    "chars": 1690,
    "preview": "import {SnackReporter} from '../snack/SnackManager';\nimport {CurrentUser} from '../CurrentUser';\nimport * as config from"
  },
  {
    "path": "ui/src/message/extras.ts",
    "chars": 717,
    "preview": "import {IMessageExtras} from '../types';\n\nexport enum RenderMode {\n    Markdown = 'text/markdown',\n    Plain = 'text/pla"
  },
  {
    "path": "ui/src/plugin/PluginDetailView.tsx",
    "chars": 8554,
    "preview": "import React from 'react';\nimport {useParams} from 'react-router';\nimport {Markdown} from '../common/Markdown';\nimport {"
  },
  {
    "path": "ui/src/plugin/PluginStore.ts",
    "chars": 1868,
    "preview": "import axios from 'axios';\nimport {action} from 'mobx';\nimport {BaseStore} from '../common/BaseStore';\nimport * as confi"
  },
  {
    "path": "ui/src/plugin/Plugins.tsx",
    "chars": 3298,
    "preview": "import React from 'react';\nimport {Link} from 'react-router-dom';\nimport Grid from '@mui/material/Grid';\nimport Paper fr"
  },
  {
    "path": "ui/src/react-app-env.d.ts",
    "chars": 40,
    "preview": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "ui/src/reactions.ts",
    "chars": 1713,
    "preview": "import {reaction} from 'mobx';\nimport * as Notifications from './snack/browserNotification';\nimport {StoreMapping} from "
  },
  {
    "path": "ui/src/registerServiceWorker.ts",
    "chars": 193,
    "preview": "export function unregister() {\n    if ('serviceWorker' in navigator) {\n        navigator.serviceWorker.ready.then((regis"
  },
  {
    "path": "ui/src/snack/SnackManager.ts",
    "chars": 262,
    "preview": "import {enqueueSnackbar} from 'notistack';\n\nexport interface SnackReporter {\n    (message: string): void;\n}\n\nexport clas"
  },
  {
    "path": "ui/src/snack/browserNotification.ts",
    "chars": 1216,
    "preview": "import Notify from 'notifyjs';\nimport removeMarkdown from 'remove-markdown';\nimport {IMessage} from '../types';\n\nexport "
  },
  {
    "path": "ui/src/stores.tsx",
    "chars": 963,
    "preview": "import * as React from 'react';\nimport {UserStore} from './user/UserStore';\nimport {SnackManager} from './snack/SnackMan"
  },
  {
    "path": "ui/src/tests/application.test.ts",
    "chars": 4768,
    "preview": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {count, innerText, waitForExists, wa"
  },
  {
    "path": "ui/src/tests/authentication.ts",
    "chars": 875,
    "preview": "import {Page} from 'puppeteer';\nimport {waitForExists} from './utils';\nimport {expect} from 'vitest';\nimport * as select"
  },
  {
    "path": "ui/src/tests/client.test.ts",
    "chars": 4142,
    "preview": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {count, innerText, waitForExists, wa"
  },
  {
    "path": "ui/src/tests/message.test.ts",
    "chars": 11642,
    "preview": "// todo before all tests jest start puppeteer\nimport {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup"
  },
  {
    "path": "ui/src/tests/plugin.test.ts",
    "chars": 7402,
    "preview": "import * as os from 'os';\nimport {Page} from 'puppeteer';\nimport axios from 'axios';\nimport {afterAll, beforeAll, descri"
  },
  {
    "path": "ui/src/tests/selector.ts",
    "chars": 768,
    "preview": "export const heading = () => `main h4`;\n\nexport const table = (tableSelector: string) => ({\n    selector: () => tableSel"
  },
  {
    "path": "ui/src/tests/setup.ts",
    "chars": 4469,
    "preview": "import getPort from 'get-port';\nimport {spawn, exec, ChildProcess} from 'child_process';\nimport {rimrafSync} from 'rimra"
  },
  {
    "path": "ui/src/tests/user.test.ts",
    "chars": 5715,
    "preview": "import {Page} from 'puppeteer';\nimport {newTest, GotifyTest} from './setup';\nimport {clearField, count, innerText, waitF"
  },
  {
    "path": "ui/src/tests/utils.ts",
    "chars": 2437,
    "preview": "import {ElementHandle, JSHandle, Page} from 'puppeteer';\n\nexport const innerText = async (page: ElementHandle | Page, se"
  }
]

// ... and 16 more files (download for full content)

About this extraction

This page contains the full source code of the gotify/server GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 216 files (670.4 KB), approximately 180.5k tokens, and a symbol index with 914 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!