[
  {
    "path": ".dockerignore",
    "content": "dist\n*/node_modules\nDockerfile*\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view\n\n# Run prettier (https://github.com/binwiederhier/ntfy/pull/746)\n6f6a2d1f693070bf72e89d86748080e4825c9164\nc87549e71a10bc789eac8036078228f06e515a8e\nca5d736a7169eb6b4b0d849e061d5bf9565dcc53\n2e27f58963feb9e4d1c573d4745d07770777fa7d\n\n# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)\nf558b4dbe9bb5b9e0e87fada1215de2558353173\n8319f1cf26113167fb29fe12edaff5db74caf35f\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [binwiederhier]\nliberapay: ntfy\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1_bug_report.md",
    "content": "---\nname: 🐛 Bug Report\nabout: Report any errors and problems\ntitle: ''\nlabels: '🪲 bug'\nassignees: ''\n\n---\n\n:lady_beetle: **Describe the bug**\n<!-- A clear and concise description of the problem. -->\n\n:computer: **Components impacted**\n<!-- ntfy server, Android app, iOS app, web app  -->\n\n:bulb: **Screenshots and/or logs**\n<!-- \nIf applicable, add screenshots or share logs help explain your problem.\nTo get logs from the ...\n- ntfy server: Enable \"log-level: trace\" in your server.yml file\n- Android app: Go to \"Settings\" -> \"Record logs\", then eventually \"Copy/upload logs\"\n- web app: Press \"F12\" and find the \"Console\" window \n-->\n\n:crystal_ball: **Additional context**\n<!-- Add any other context about the problem here. -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2_enhancement_request.md",
    "content": "---\nname: 💡 Feature/Enhancement Request\nabout: Got a great idea? Let us know!\ntitle: ''\nlabels: 'enhancement'\nassignees: ''\n\n---\n\n<!--\n\nBefore you submit, consider asking on Discord/Matrix instead. You'll usually get an answer\nsooner, and there are more people there to help!\n\n- Discord: https://discord.gg/cT7ECsZj9w\n- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org\n\n-->\n\n:bulb: **Idea**\n<!-- Share your thoughts; try to be detailed if you can -->\n\n:computer: **Target components**\n<!-- Where should this feature/enhancement be added? -->\n<!-- e.g. ntfy server, Android app, iOS app, web app -->\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/3_tech_support.md",
    "content": "---\nname: 🆘 I need help with ...\nabout: Installing ntfy, configuring the app, etc.\ntitle: ''\nlabels: 'tech-support'\nassignees: ''\n\n---\n\n\n<!--\n\nSTOP! \n\nThis is not the right place to ask for help. Consider asking on Discord/Matrix instead. \nYou'll usually get an answer sooner, and there are more people there to help!\n\n- Discord: https://discord.gg/cT7ECsZj9w\n- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org\n\n-->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/4_question.md",
    "content": "---\nname: ❓ Question\nabout: Ask a question about ntfy\ntitle: ''\nlabels: 'question'\nassignees: ''\n\n---\n\n<!--\n\nBefore you submit, consider asking on Discord/Matrix instead. You'll usually get an answer\nsooner, and there are more people there to help!\n\n- Discord: https://discord.gg/cT7ECsZj9w\n- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org\n\n-->\n\n:question: **Question**\n<!-- Go ahead and ask your question here :) -->\n"
  },
  {
    "path": ".github/workflows/build.yaml",
    "content": "name: build\non: [ push, pull_request ]\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Install Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: '1.24.x'\n      - name: Install node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n          cache: 'npm'\n          cache-dependency-path: './web/package-lock.json'\n      - name: Install dependencies\n        run: make build-deps-ubuntu\n      - name: Build all the things\n        run: make build\n      - name: Print build results and checksums\n        run: make cli-build-results\n"
  },
  {
    "path": ".github/workflows/docs.yaml",
    "content": "name: docs\non:\n  push:\n    branches:\n      - main\njobs:\n  publish-docs:\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout ntfy code\n        uses: actions/checkout@v3\n      -\n        name: Checkout docs pages code\n        uses: actions/checkout@v3\n        with:\n          repository: binwiederhier/ntfy-docs.github.io\n          path: build/ntfy-docs.github.io\n          token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}}\n          # Expires after 1 year, re-generate via\n          # User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token\n      -\n        name: Build docs\n        run: make docs\n      -\n        name: Copy generated docs\n        run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/\n      -\n        name: Publish docs\n        run: |\n          cd build/ntfy-docs.github.io\n          git config user.name \"GitHub Actions Bot\"\n          git config user.email \"<actions@github.com>\"          \n          git add docs/\n          git commit -m \"Updated docs\"\n          git push origin main\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: release\non:\n  push:\n    tags:\n      - 'v[0-9]+.[0-9]+.[0-9]+'\njobs:\n  release:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:17\n        env:\n          POSTGRES_USER: ntfy\n          POSTGRES_PASSWORD: ntfy\n          POSTGRES_DB: ntfy_test\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd \"pg_isready -U ntfy\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    env:\n      NTFY_TEST_DATABASE_URL: \"postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable\"\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Install Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: '1.24.x'\n      - name: Install node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n          cache: 'npm'\n          cache-dependency-path: './web/package-lock.json'\n      - name: Docker login\n        uses: docker/login-action@v2\n        with:\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.DOCKER_HUB_TOKEN }}\n      - name: Install dependencies\n        run: make build-deps-ubuntu\n      - name: Build and publish\n        run: make release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - name: Print build results and checksums\n        run: make cli-build-results\n"
  },
  {
    "path": ".github/workflows/test.yaml",
    "content": "name: test\non: [ push, pull_request ]\njobs:\n  test:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:17\n        env:\n          POSTGRES_USER: ntfy\n          POSTGRES_PASSWORD: ntfy\n          POSTGRES_DB: ntfy_test\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd \"pg_isready -U ntfy\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    env:\n      NTFY_TEST_DATABASE_URL: \"postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable\"\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Install Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: '1.24.x'\n      - name: Install node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n          cache: 'npm'\n          cache-dependency-path: './web/package-lock.json'\n      - name: Install dependencies\n        run: make build-deps-ubuntu\n      - name: Build docs (required for tests)\n        run: make docs\n      - name: Build web app (required for tests)\n        run: make web\n      - name: Run tests, formatting, vetting and linting\n        run: make checkv\n      - name: Run coverage\n        run: make coverage\n"
  },
  {
    "path": ".gitignore",
    "content": "dist/\ndev-dist/\nbuild/\n.idea/\n.vscode/\n*.swp\nserver/docs/\nserver/site/\ntools/fbsend/fbsend\ntools/pgimport/pgimport\ntools/loadtest/loadtest\nplayground/\nsecrets/\n*.iml\nnode_modules/\n.DS_Store\n__pycache__\nweb/dev-dist/\nvenv/\ncmd/key-file.yaml\n"
  },
  {
    "path": ".gitpod.yml",
    "content": "tasks:\n  - name: docs\n    before: make docs-deps\n    command: mkdocs serve\n  - name: binary\n    before: |\n      npm install --global nodemon\n      make cli-deps-static-sites\n    command: |\n      nodemon --watch './**/*.go' --ext go --signal SIGTERM --exec \"CGO_ENABLED=1 go run main.go serve --listen-http :2586 --debug --base-url $(gp url 2586)\"\n    openMode: split-right\n  - name: web\n    before: make web-deps\n    command: cd web && npm start\n    openMode: split-right\n\nvscode:\n  extensions:\n    - golang.go\n    - ms-azuretools.vscode-docker\n\nports:\n  - name: docs\n    port: 8000\n  - name: binary\n    port: 2586\n  - name: web\n    port: 3000"
  },
  {
    "path": ".goreleaser.yml",
    "content": "version: 2\nbefore:\n  hooks:\n    - go mod download\n    - go mod tidy\nbuilds:\n  - id: ntfy_linux_amd64\n    binary: ntfy\n    env:\n      - CGO_ENABLED=1 # required for go-sqlite3\n    tags: [ sqlite_omit_load_extension,osusergo,netgo ]\n    ldflags:\n      - \"-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ linux ]\n    goarch: [ amd64 ]\n  - id: ntfy_linux_armv6\n    binary: ntfy\n    env:\n      - CGO_ENABLED=1 # required for go-sqlite3\n      - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi\n    tags: [ sqlite_omit_load_extension,osusergo,netgo ]\n    ldflags:\n      - \"-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ linux ]\n    goarch: [ arm ]\n    goarm: [ 6 ]\n  - id: ntfy_linux_armv7\n    binary: ntfy\n    env:\n      - CGO_ENABLED=1 # required for go-sqlite3\n      - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi\n    tags: [ sqlite_omit_load_extension,osusergo,netgo ]\n    ldflags:\n      - \"-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ linux ]\n    goarch: [ arm ]\n    goarm: [ 7 ]\n  - id: ntfy_linux_arm64\n    binary: ntfy\n    env:\n      - CGO_ENABLED=1 # required for go-sqlite3\n      - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu\n    tags: [ sqlite_omit_load_extension,osusergo,netgo ]\n    ldflags:\n      - \"-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ linux ]\n    goarch: [ arm64 ]\n  - id: ntfy_windows_amd64\n    binary: ntfy\n    env:\n      - CGO_ENABLED=1 # required for go-sqlite3\n      - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64\n    tags: [ sqlite_omit_load_extension,osusergo,netgo ]\n    ldflags:\n      - \"-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ windows ]\n    goarch: [amd64 ]\n  -\n    id: ntfy_darwin_all\n    binary: ntfy\n    env:\n      - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3\n    tags: [ noserver ] # don't include server files\n    ldflags:\n      - \"-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}\"\n    goos: [ darwin ]\n    goarch: [ amd64, arm64 ] # will be combined to \"universal binary\" (see below)\nnfpms:\n  - package_name: ntfy\n    homepage: https://heckel.io/ntfy\n    maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>\n    description: Simple pub-sub notification service\n    license: Apache 2.0\n    formats:\n      - deb\n      - rpm\n    bindir: /usr/bin\n    contents:\n      - src: server/server.yml\n        dst: /etc/ntfy/server.yml\n        type: \"config|noreplace\"\n      - src: server/ntfy.service\n        dst: /lib/systemd/system/ntfy.service\n      - src: client/client.yml\n        dst: /etc/ntfy/client.yml\n        type: \"config|noreplace\"\n      - src: client/ntfy-client.service\n        dst: /lib/systemd/system/ntfy-client.service\n      - src: client/user/ntfy-client.service\n        dst: /lib/systemd/user/ntfy-client.service\n      - dst: /var/cache/ntfy\n        type: dir\n      - dst: /var/cache/ntfy/attachments\n        type: dir\n      - dst: /var/lib/ntfy\n        type: dir\n      - dst: /usr/share/ntfy/logo.png\n        src: web/public/static/images/ntfy.png\n    scripts:\n      preinstall: \"scripts/preinst.sh\"\n      postinstall: \"scripts/postinst.sh\"\n      preremove: \"scripts/prerm.sh\"\n      postremove: \"scripts/postrm.sh\"\narchives:\n  - id: ntfy_linux\n    ids:\n      - ntfy_linux_amd64\n      - ntfy_linux_armv6\n      - ntfy_linux_armv7\n      - ntfy_linux_arm64\n    wrap_in_directory: true\n    files:\n      - LICENSE\n      - README.md\n      - server/server.yml\n      - server/ntfy.service\n      - client/client.yml\n      - client/ntfy-client.service\n      - client/user/ntfy-client.service\n  - id: ntfy_windows\n    ids:\n      - ntfy_windows_amd64\n    formats: [ zip ]\n    wrap_in_directory: true\n    files:\n      - LICENSE\n      - README.md\n      - client/client.yml\n  - id: ntfy_darwin\n    ids:\n      - ntfy_darwin_all\n    wrap_in_directory: true\n    files:\n      - LICENSE\n      - README.md\n      - client/client.yml\nuniversal_binaries:\n  - id: ntfy_darwin_all\n    replace: true\n    name_template: ntfy\nchecksum:\n  name_template: 'checksums.txt'\nsnapshot:\n  version_template: \"{{ .Tag }}-next\"\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - '^docs:'\n      - '^test:'\ndockers:\n  - image_templates:\n      - &amd64_image \"binwiederhier/ntfy:{{ .Tag }}-amd64\"\n    use: buildx\n    dockerfile: Dockerfile\n    goarch: amd64\n    build_flag_templates:\n      - \"--platform=linux/amd64\"\n  - image_templates:\n      - &arm64v8_image \"binwiederhier/ntfy:{{ .Tag }}-arm64v8\"\n    use: buildx\n    dockerfile: Dockerfile-arm\n    goarch: arm64\n    build_flag_templates:\n      - \"--platform=linux/arm64/v8\"\n  - image_templates:\n      - &armv7_image \"binwiederhier/ntfy:{{ .Tag }}-armv7\"\n    use: buildx\n    dockerfile: Dockerfile-arm\n    goarch: arm\n    goarm: 7\n    build_flag_templates:\n      - \"--platform=linux/arm/v7\"\n  - image_templates:\n      - &armv6_image \"binwiederhier/ntfy:{{ .Tag }}-armv6\"\n    use: buildx\n    dockerfile: Dockerfile-arm\n    goarch: arm\n    goarm: 6\n    build_flag_templates:\n      - \"--platform=linux/arm/v6\"\ndocker_manifests:\n  - name_template: \"binwiederhier/ntfy:latest\"\n    image_templates:\n      - *amd64_image\n      - *arm64v8_image\n      - *armv7_image\n      - *armv6_image\n  - name_template: \"binwiederhier/ntfy:{{ .Tag }}\"\n    image_templates:\n      - *amd64_image\n      - *arm64v8_image\n      - *armv7_image\n      - *armv6_image\n  - name_template: \"binwiederhier/ntfy:v{{ .Major }}\"\n    image_templates:\n      - *amd64_image\n      - *arm64v8_image\n      - *armv7_image\n      - *armv6_image\n  - name_template: \"binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}\"\n    image_templates:\n      - *amd64_image\n      - *arm64v8_image\n      - *armv7_image\n      - *armv6_image\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the overall\n  community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or advances of\n  any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email address,\n  without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),\nor email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly \nand fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series of\nactions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or permanent\nban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within the\ncommunity.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.1, available at\n[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at\n[https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM alpine\n\nLABEL org.opencontainers.image.authors=\"philipp.heckel@gmail.com\"\nLABEL org.opencontainers.image.url=\"https://ntfy.sh/\"\nLABEL org.opencontainers.image.documentation=\"https://docs.ntfy.sh/\"\nLABEL org.opencontainers.image.source=\"https://github.com/binwiederhier/ntfy\"\nLABEL org.opencontainers.image.vendor=\"Philipp C. Heckel\"\nLABEL org.opencontainers.image.licenses=\"Apache-2.0, GPL-2.0\"\nLABEL org.opencontainers.image.title=\"ntfy\"\nLABEL org.opencontainers.image.description=\"Send push notifications to your phone or desktop using PUT/POST\"\n\nRUN apk add --no-cache tzdata\nCOPY ntfy /usr/bin\n\nEXPOSE 80/tcp\nENTRYPOINT [\"ntfy\"]\n"
  },
  {
    "path": "Dockerfile-arm",
    "content": "FROM alpine\n\nLABEL org.opencontainers.image.authors=\"philipp.heckel@gmail.com\"\nLABEL org.opencontainers.image.url=\"https://ntfy.sh/\"\nLABEL org.opencontainers.image.documentation=\"https://docs.ntfy.sh/\"\nLABEL org.opencontainers.image.source=\"https://github.com/binwiederhier/ntfy\"\nLABEL org.opencontainers.image.vendor=\"Philipp C. Heckel\"\nLABEL org.opencontainers.image.licenses=\"Apache-2.0, GPL-2.0\"\nLABEL org.opencontainers.image.title=\"ntfy\"\nLABEL org.opencontainers.image.description=\"Send push notifications to your phone or desktop using PUT/POST\"\n\n# Alpine does not support adding \"tzdata\" on ARM anymore, see\n# https://github.com/binwiederhier/ntfy/issues/894\n\nCOPY ntfy /usr/bin\n\nEXPOSE 80/tcp\nENTRYPOINT [\"ntfy\"]\n"
  },
  {
    "path": "Dockerfile-build",
    "content": "FROM golang:1.24-bullseye as builder\n\nARG VERSION=dev\nARG COMMIT=unknown\nARG NODE_MAJOR=18\n\nRUN apt-get update && apt-get install -y \\\n       build-essential ca-certificates curl gnupg \\\n    && mkdir -p /etc/apt/keyrings \\\n    && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \\\n    && echo \"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main\" >> /etc/apt/sources.list.d/nodesource.list \\\n    && apt-get update \\\n    && apt-get install -y \\\n      python3-pip \\\n      python3-venv \\\n      nodejs \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\nADD Makefile .\n\n# docs\nADD ./requirements.txt .\nRUN make docs-deps\nADD ./mkdocs.yml .\nADD ./docs ./docs\nRUN make docs-build\n\n# web\nADD ./web/package.json ./web/package-lock.json ./web/\nRUN make web-deps\nADD ./web ./web\nRUN make web-build\n\n# cli & server\nADD go.mod go.sum main.go ./\nADD ./client ./client\nADD ./cmd ./cmd\nADD ./log ./log\nADD ./server ./server\nADD ./user ./user\nADD ./util ./util\nADD ./payments ./payments\nRUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server\n\nFROM alpine\n\nARG VERSION=dev\n\nLABEL org.opencontainers.image.authors=\"philipp.heckel@gmail.com\"\nLABEL org.opencontainers.image.url=\"https://ntfy.sh/\"\nLABEL org.opencontainers.image.documentation=\"https://docs.ntfy.sh/\"\nLABEL org.opencontainers.image.source=\"https://github.com/binwiederhier/ntfy\"\nLABEL org.opencontainers.image.vendor=\"Philipp C. Heckel\"\nLABEL org.opencontainers.image.licenses=\"Apache-2.0, GPL-2.0\"\nLABEL org.opencontainers.image.title=\"ntfy\"\nLABEL org.opencontainers.image.description=\"Send push notifications to your phone or desktop using PUT/POST\"\nLABEL org.opencontainers.image.version=\"$VERSION\"\n\nCOPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy\n\nEXPOSE 80/tcp\nENTRYPOINT [\"ntfy\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2021 Philipp C. Heckel\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "LICENSE.GPLv2",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    ntfy\n    Copyright (C) 2021 Philipp C. Heckel\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n\n"
  },
  {
    "path": "Makefile",
    "content": "MAKEFLAGS := --jobs=1\nNPM := npm\nPYTHON := python3\nPIP := pip3\nVERSION := $(shell git describe --tag)\nCOMMIT := $(shell git rev-parse --short HEAD)\n\n.PHONY:\n\nhelp:\n\t@echo \"Typical commands (more see below):\"\n\t@echo \"  make build                      - Build web app, documentation and server/client (sloowwww)\"\n\t@echo \"  make cli-linux-amd64            - Build server/client binary (amd64, no web app or docs)\"\n\t@echo \"  make install-linux-amd64        - Install ntfy binary to /usr/bin/ntfy (amd64)\"\n\t@echo \"  make web                        - Build the web app\"\n\t@echo \"  make docs                       - Build the documentation\"\n\t@echo \"  make check                      - Run all tests, vetting/formatting checks and linters\"\n\t@echo\n\t@echo \"Build everything:\"\n\t@echo \"  make build                      - Build web app, documentation and server/client\"\n\t@echo \"  make clean                      - Clean build/dist folders\"\n\t@echo\n\t@echo \"Build server & client (using GoReleaser, not release version):\"\n\t@echo \"  make cli                        - Build server & client (all architectures)\"\n\t@echo \"  make cli-linux-amd64            - Build server & client (Linux, amd64 only)\"\n\t@echo \"  make cli-linux-armv6            - Build server & client (Linux, armv6 only)\"\n\t@echo \"  make cli-linux-armv7            - Build server & client (Linux, armv7 only)\"\n\t@echo \"  make cli-linux-arm64            - Build server & client (Linux, arm64 only)\"\n\t@echo \"  make cli-windows-amd64          - Build client (Windows, amd64 only)\"\n\t@echo \"  make cli-darwin-all             - Build client (macOS, arm64+amd64 universal binary)\"\n\t@echo\n\t@echo \"Build server & client (without GoReleaser):\"\n\t@echo \"  make cli-linux-server           - Build client & server (no GoReleaser, current arch, Linux)\"\n\t@echo \"  make cli-darwin-server          - Build client & server (no GoReleaser, current arch, macOS)\"\n\t@echo \"  make cli-windows-server         - Build client & server (no GoReleaser, amd64 only, Windows)\"\n\t@echo \"  make cli-client                 - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)\"\n\t@echo\n\t@echo \"Build dev Docker:\"\n\t@echo \"  make docker-dev                 - Build client & server for current architecture using Docker only\"\n\t@echo\n\t@echo \"Build web app:\"\n\t@echo \"  make web                        - Build the web app\"\n\t@echo \"  make web-deps                   - Install web app dependencies (npm install the universe)\"\n\t@echo \"  make web-build                  - Actually build the web app\"\n\t@echo \"  make web-lint                   - Run eslint on the web app\"\n\t@echo \"  make web-fmt                    - Run prettier on the web app\"\n\t@echo \"  make web-fmt-check              - Run prettier on the web app, but don't change anything\"\n\t@echo\n\t@echo \"Build documentation:\"\n\t@echo \"  make docs                       - Build the documentation\"\n\t@echo \"  make docs-deps                  - Install Python dependencies (pip3 install)\"\n\t@echo \"  make docs-build                 - Actually build the documentation\"\n\t@echo\n\t@echo \"Test/check:\"\n\t@echo \"  make test                       - Run tests\"\n\t@echo \"  make race                       - Run tests with -race flag\"\n\t@echo \"  make coverage                   - Run tests and show coverage\"\n\t@echo \"  make coverage-html              - Run tests and show coverage (as HTML)\"\n\t@echo \"  make coverage-upload            - Upload coverage results to codecov.io\"\n\t@echo\n\t@echo \"Lint/format:\"\n\t@echo \"  make fmt                        - Run 'go fmt'\"\n\t@echo \"  make fmt-check                  - Run 'go fmt', but don't change anything\"\n\t@echo \"  make vet                        - Run 'go vet'\"\n\t@echo \"  make lint                       - Run 'golint'\"\n\t@echo \"  make staticcheck                - Run 'staticcheck'\"\n\t@echo\n\t@echo \"Releasing:\"\n\t@echo \"  make release                    - Create a release\"\n\t@echo \"  make release-snapshot           - Create a test release\"\n\t@echo\n\t@echo \"Install locally (requires sudo):\"\n\t@echo \"  make install-linux-amd64        - Copy amd64 binary from dist/ to /usr/bin/ntfy\"\n\t@echo \"  make install-linux-armv6        - Copy armv6 binary from dist/ to /usr/bin/ntfy\"\n\t@echo \"  make install-linux-armv7        - Copy armv7 binary from dist/ to /usr/bin/ntfy\"\n\t@echo \"  make install-linux-arm64        - Copy arm64 binary from dist/ to /usr/bin/ntfy\"\n\t@echo \"  make install-linux-deb-amd64    - Install .deb from dist/ (amd64 only)\"\n\t@echo \"  make install-linux-deb-armv6    - Install .deb from dist/ (armv6 only)\"\n\t@echo \"  make install-linux-deb-armv7    - Install .deb from dist/ (armv7 only)\"\n\t@echo \"  make install-linux-deb-arm64    - Install .deb from dist/ (arm64 only)\"\n\n\n# Building everything\n\nclean: .PHONY\n\trm -rf dist build server/docs server/site\n\nbuild: web docs cli\n\nupdate: web-deps-update cli-deps-update docs-deps-update\n\tdocker pull alpine\n\ndocker-dev:\n\tdocker build \\\n\t\t--file ./Dockerfile-build \\\n\t\t--tag binwiederhier/ntfy:$(VERSION) \\\n\t\t--tag binwiederhier/ntfy:dev \\\n\t\t--build-arg VERSION=$(VERSION) \\\n\t\t--build-arg COMMIT=$(COMMIT) \\\n\t\t./\n\n\n# Ubuntu-specific\n\nbuild-deps-ubuntu:\n\tsudo apt-get update\n\tsudo apt-get install -y \\\n\t\tcurl \\\n\t\tgcc-aarch64-linux-gnu \\\n\t\tgcc-arm-linux-gnueabi \\\n\t\tgcc-mingw-w64-x86-64 \\\n\t\tpython3 \\\n\t\tpython3-venv \\\n\t\tjq\n\twhich pip3 || sudo apt-get install -y python3-pip\n\n\n# Documentation\n\ndocs: docs-deps docs-build\n\ndocs-venv: .PHONY\n\t$(PYTHON) -m venv ./venv\n\ndocs-build: docs-venv\n\t(. venv/bin/activate && $(PYTHON) -m mkdocs build)\n\ndocs-deps: docs-venv\n\t(. venv/bin/activate && $(PIP) install -r requirements.txt)\n\ndocs-deps-update: .PHONY\n\t(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)\n\n\n# Web app\n\nweb: web-deps web-build\n\nweb-build:\n\tcd web \\\n\t\t&& $(NPM) run build \\\n\t\t&& mv build/index.html build/app.html \\\n\t\t&& rm -rf ../server/site \\\n\t\t&& mv build ../server/site \\\n\t\t&& rm \\\n\t\t\t../server/site/config.js\n\nweb-deps:\n\tcd web && $(NPM) install\n\t# If this fails for .svg files, optimize them with svgo\n\nweb-deps-update:\n\tcd web && $(NPM) update\n\nweb-fmt:\n\tcd web && $(NPM) run format\n\nweb-fmt-check:\n\tcd web && $(NPM) run format:check\n\nweb-lint:\n\tcd web && $(NPM) run lint\n\n# Main server/client build\n\ncli: cli-deps\n\tgoreleaser build --snapshot --clean\n\ncli-linux-amd64: cli-deps-static-sites\n\tgoreleaser build --snapshot --clean --id ntfy_linux_amd64\n\ncli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7\n\tgoreleaser build --snapshot --clean --id ntfy_linux_armv6\n\ncli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7\n\tgoreleaser build --snapshot --clean --id ntfy_linux_armv7\n\ncli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64\n\tgoreleaser build --snapshot --clean --id ntfy_linux_arm64\n\ncli-windows-amd64: cli-deps-static-sites\n\tgoreleaser build --snapshot --clean --id ntfy_windows_amd64\n\ncli-darwin-all: cli-deps-static-sites\n\tgoreleaser build --snapshot --clean --id ntfy_darwin_all\n\ncli-linux-server: cli-deps-static-sites\n\t# This is a target to build the CLI (including the server) manually.\n\t# Use this for development, if you really don't want to install GoReleaser ...\n\tmkdir -p dist/ntfy_linux_server server/docs\n\tCGO_ENABLED=1 go build \\\n\t\t-o dist/ntfy_linux_server/ntfy \\\n\t\t-tags sqlite_omit_load_extension,osusergo,netgo \\\n\t\t-ldflags \\\n\t\t\"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)\"\n\ncli-darwin-server: cli-deps-static-sites\n\t# This is a target to build the CLI (including the server) manually.\n\t# Use this for macOS/iOS development, so you have a local server to test with.\n\tmkdir -p dist/ntfy_darwin_server server/docs\n\tCGO_ENABLED=1 go build \\\n\t\t-o dist/ntfy_darwin_server/ntfy \\\n\t\t-tags sqlite_omit_load_extension,osusergo,netgo \\\n\t\t-ldflags \\\n\t\t\"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)\"\n\ncli-windows-server: cli-deps-static-sites\n\t# This is a target to build the CLI (including the server) for Windows.\n\t# Use this for Windows development, if you really don't want to install GoReleaser ...\n\tmkdir -p dist/ntfy_windows_server server/docs\n\tCC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \\\n\t\t-o dist/ntfy_windows_server/ntfy.exe \\\n\t\t-tags sqlite_omit_load_extension,osusergo,netgo \\\n\t\t-ldflags \\\n\t\t\"-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)\"\n\ncli-client: cli-deps-static-sites\n\t# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.\n\t# Use this for development, if you really don't want to install GoReleaser ...\n\tmkdir -p dist/ntfy_client server/docs\n\tCGO_ENABLED=0 go build \\\n\t\t-o dist/ntfy_client/ntfy \\\n\t\t-tags noserver \\\n\t\t-ldflags \\\n\t\t\"-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)\"\n\ncli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc\n\ncli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows\n\ncli-deps-static-sites:\n\tmkdir -p server/docs server/site\n\ttouch server/docs/index.html server/site/app.html\n\ncli-deps-all:\n\tgo install github.com/goreleaser/goreleaser/v2@latest\n\ncli-deps-gcc-armv6-armv7:\n\twhich arm-linux-gnueabi-gcc || { echo \"ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi\"; exit 1; }\n\ncli-deps-gcc-arm64:\n\twhich aarch64-linux-gnu-gcc || { echo \"ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu\"; exit 1; }\n\ncli-deps-gcc-windows:\n\twhich x86_64-w64-mingw32-gcc || { echo \"ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64\"; exit 1; }\n\ncli-deps-update:\n\tgo get -u\n\tgo mod tidy\n\tgo install honnef.co/go/tools/cmd/staticcheck@latest\n\tgo install golang.org/x/lint/golint@latest\n\tgo install github.com/goreleaser/goreleaser/v2@latest\n\ncli-build-results:\n\tcat dist/config.yaml\n\t[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true\n\t[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true\n\t[ -f dist/checksums.txt ] && cat dist/checksums.txt || true\n\tfind dist -maxdepth 2 -type f \\\n\t\t\\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \\) \\\n\t\t-and -not -path 'dist/goreleaserdocker*' \\\n\t\t-exec sha256sum {} \\;\n\n# Test/check targets\n\ncheck: test web-fmt-check fmt-check vet web-lint lint staticcheck\n\ncheckv: testv web-fmt-check fmt-check vet web-lint lint staticcheck\n\ntest: .PHONY\n\tgo test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')\n\ntestv: .PHONY\n\tgo test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')\n\nrace: .PHONY\n\tgo test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')\n\ncoverage:\n\tmkdir -p build/coverage\n\tgo test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)')\n\tgo tool cover -func build/coverage/coverage.txt\n\ncoverage-html:\n\tmkdir -p build/coverage\n\tgo test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')\n\tgo tool cover -html build/coverage/coverage.txt\n\ncoverage-upload:\n\tcd build/coverage && (curl -s https://codecov.io/bash | bash)\n\n\n# Lint/formatting targets\n\nfmt: web-fmt\n\tgofmt -s -w .\n\nfmt-check:\n\ttest -z $(shell gofmt -l .)\n\nvet:\n\tgo vet ./...\n\nlint:\n\twhich golint || go install golang.org/x/lint/golint@latest\n\tgo list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status\n\nstaticcheck: .PHONY\n\trm -rf build/staticcheck\n\twhich staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest\n\tmkdir -p build/staticcheck\n\tln -s \"go\" build/staticcheck/go\n\tPATH=\"$(PWD)/build/staticcheck:$(PATH)\" staticcheck ./...\n\trm -rf build/staticcheck\n\n\n# Releasing targets\n\nrelease: clean cli-deps release-checks docs web check\n\tgoreleaser release --clean\n\nrelease-snapshot: clean cli-deps docs web check\n\tgoreleaser release --snapshot --clean\n\nrelease-checks:\n\t$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))\n\tif ! grep -q $(LATEST_TAG) docs/install.md; then\\\n\t \techo \"ERROR: Must update docs/install.md with latest tag first.\";\\\n\t \texit 1;\\\n\tfi\n\tif ! grep -q $(LATEST_TAG) docs/releases.md; then\\\n\t\techo \"ERROR: Must update docs/releases.md with latest tag first.\";\\\n\t\texit 1;\\\n\tfi\n\tif [ -n \"$(shell git status -s)\" ]; then\\\n\t  echo \"ERROR: Git repository is in an unclean state.\";\\\n\t  exit 1;\\\n\tfi\n\n\n# Installing targets\n\ninstall-linux-amd64: remove-binary\n\tsudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy\n\ninstall-linux-armv6: remove-binary\n\tsudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy\n\ninstall-linux-armv7: remove-binary\n\tsudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy\n\ninstall-linux-arm64: remove-binary\n\tsudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy\n\nremove-binary:\n\tsudo rm -f /usr/bin/ntfy\n\ninstall-linux-amd64-deb: purge-package\n\tsudo dpkg -i dist/ntfy_*_linux_amd64.deb\n\ninstall-linux-armv6-deb: purge-package\n\tsudo dpkg -i dist/ntfy_*_linux_armv6.deb\n\ninstall-linux-armv7-deb: purge-package\n\tsudo dpkg -i dist/ntfy_*_linux_armv7.deb\n\ninstall-linux-arm64-deb: purge-package\n\tsudo dpkg -i dist/ntfy_*_linux_arm64.deb\n\npurge-package:\n\tsudo systemctl stop ntfy || true\n\tsudo apt-get purge ntfy || true\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\" markdown=\"1\">\n<sup>Special thanks to:</sup>\n<br>\n<br>\n<a href=\"https://go.warp.dev/ntfy\">\n  <img alt=\"Warp sponsorship\" width=\"400\" src=\"https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png\">\n</a>\n\n### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)\n[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>\n</div>\n<hr>\n\n![ntfy](web/public/static/images/ntfy.png)\n\n# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST\n[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)\n[![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy/v2)\n[![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions)\n[![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy)\n[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy)\n[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)\n[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)\n[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)\n[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)\n[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)\n\n**ntfy** (pronounced \"*notify*\") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) \nnotification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, \n**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do \nso since ntfy is open source.\n\nYou can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open-source Android app](https://github.com/binwiederhier/ntfy-android)\navailable on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),\nas well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).\n\n<p>\n  <a href=\"https://play.google.com/store/apps/details?id=io.heckel.ntfy\"><img height=\"50\" src=\"docs/static/img/badge-googleplay.png\"></a>\n  <a href=\"https://f-droid.org/en/packages/io.heckel.ntfy/\"><img width=\"170\" src=\"docs/static/img/badge-fdroid.svg\"></a>\n  <a href=\"https://apps.apple.com/us/app/ntfy/id1625396347\"><img height=\"50\" src=\"docs/static/img/badge-appstore.png\"></a>\n</p>\n\n<p>\n  <img src=\".github/images/screenshot-curl.png\" height=\"180\">\n  <img src=\".github/images/screenshot-web-detail.png\" height=\"180\">\n  <img src=\".github/images/screenshot-phone-main.jpg\" height=\"180\">\n  <img src=\".github/images/screenshot-phone-detail.jpg\" height=\"180\">\n  <img src=\".github/images/screenshot-phone-notification.jpg\" height=\"180\">\n</p>\n\n## [ntfy Pro](https://ntfy.sh/app) 💸 🎉\nI now offer paid plans for [ntfy.sh](https://ntfy.sh/) if you don't want to self-host, or you want to support the development of \nntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $5/month**.\nYou can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy).\nI would be very humbled by your sponsorship. ❤️ \n\n## **[Documentation](https://ntfy.sh/docs/)**\n\n[Getting started](https://ntfy.sh/docs/) |\n[Android/iOS](https://ntfy.sh/docs/subscribe/phone/) |\n[API](https://ntfy.sh/docs/publish/) |\n[Install / Self-hosting](https://ntfy.sh/docs/install/) |\n[Building](https://ntfy.sh/docs/develop/)\n\n## Chat/forum\nThere are a few ways to get in touch with me and/or the rest of the community. Feel free to use any of these methods. Whatever\nworks best for you:\n\n* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community\n* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord\n* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs\n\n## Announcements/beta testers\nFor announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements) \ntopic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,\njoin Discord/Matrix (I'll eventually make a testing channel in Google Play).\n\n## Sponsors\nIf you'd like to support the ntfy maintainers, please consider donating to [GitHub Sponsors](https://github.com/sponsors/binwiederhier) or\nand [Liberapay](https://liberapay.com/ntfy). We would be humbled if you helped carry the server and developer \naccount costs. Even small donations are very much appreciated. \n\nThank you to our commercial sponsors, who help keep the service running and the development going:\n\n<a href=\"https://m.do.co/c/442b929528db\"><img src=\"https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg\" width=\"201px\"></a>\n\n<a href=\"https://www.magicbell.com/?utm_source=ntfy\"><img src=\"assets/sponsors/magicbell.png\" width=\"180px\"></a>\n\n<a href=\"https://go.warp.dev/ntfy\"><img src=\"https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png\" width=\"160px\"></a>\n\nAnd a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:\n\n<a href=\"https://github.com/neutralinsomniac\"><img src=\"https://github.com/neutralinsomniac.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/aspyct\"><img src=\"https://github.com/aspyct.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/nickexyz\"><img src=\"https://github.com/nickexyz.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/qcasey\"><img src=\"https://github.com/qcasey.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/mckay115\"><img src=\"https://github.com/mckay115.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Salamafet\"><img src=\"https://github.com/Salamafet.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/codinghipster\"><img src=\"https://github.com/codinghipster.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/HinFort\"><img src=\"https://github.com/HinFort.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Lexevolution\"><img src=\"https://github.com/Lexevolution.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/johnnyip\"><img src=\"https://github.com/johnnyip.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/JonDerThan\"><img src=\"https://github.com/JonDerThan.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/12nick12\"><img src=\"https://github.com/12nick12.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/eanplatter\"><img src=\"https://github.com/eanplatter.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/fnoelscher\"><img src=\"https://github.com/fnoelscher.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/bnorick\"><img src=\"https://github.com/bnorick.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/snh\"><img src=\"https://github.com/snh.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/hen-x\"><img src=\"https://github.com/hen-x.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/JamieGoodson\"><img src=\"https://github.com/JamieGoodson.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/cremesk\"><img src=\"https://github.com/cremesk.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/dangowans\"><img src=\"https://github.com/dangowans.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/mnault\"><img src=\"https://github.com/mnault.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/nwithan8\"><img src=\"https://github.com/nwithan8.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/peterleiser\"><img src=\"https://github.com/peterleiser.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/portothree\"><img src=\"https://github.com/portothree.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/finngreig\"><img src=\"https://github.com/finngreig.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/skrollme\"><img src=\"https://github.com/skrollme.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/gergepalfi\"><img src=\"https://github.com/gergepalfi.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/tonyakwei\"><img src=\"https://github.com/tonyakwei.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/crosbyh\"><img src=\"https://github.com/crosbyh.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/mdlnr\"><img src=\"https://github.com/mdlnr.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/p-samuel\"><img src=\"https://github.com/p-samuel.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/zugaldia\"><img src=\"https://github.com/zugaldia.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/NathanSweet\"><img src=\"https://github.com/NathanSweet.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/msdeibel\"><img src=\"https://github.com/msdeibel.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/ksurl\"><img src=\"https://github.com/ksurl.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/CodingTimeDEV\"><img src=\"https://github.com/CodingTimeDEV.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Terrormixer3000\"><img src=\"https://github.com/Terrormixer3000.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/voroskoi\"><img src=\"https://github.com/voroskoi.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Nickwasused\"><img src=\"https://github.com/Nickwasused.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/bahur142\"><img src=\"https://github.com/bahur142.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/vinhdizzo\"><img src=\"https://github.com/vinhdizzo.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Ge0rg3\"><img src=\"https://github.com/Ge0rg3.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/biopsin\"><img src=\"https://github.com/biopsin.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/thebino\"><img src=\"https://github.com/thebino.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/sky4055\"><img src=\"https://github.com/sky4055.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/julianlam\"><img src=\"https://github.com/julianlam.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/andreapx\"><img src=\"https://github.com/andreapx.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/billycao\"><img src=\"https://github.com/billycao.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/zoic21\"><img src=\"https://github.com/zoic21.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/IanKulin\"><img src=\"https://github.com/IanKulin.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Joachim256\"><img src=\"https://github.com/Joachim256.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/overtone1000\"><img src=\"https://github.com/overtone1000.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/oakd\"><img src=\"https://github.com/oakd.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/KucharczykL\"><img src=\"https://github.com/KucharczykL.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/hansbickhofe\"><img src=\"https://github.com/hansbickhofe.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/caseodilla\"><img src=\"https://github.com/caseodilla.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/0xAF\"><img src=\"https://github.com/0xAF.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/soonoo\"><img src=\"https://github.com/soonoo.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/nichu42\"><img src=\"https://github.com/nichu42.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/samliebow\"><img src=\"https://github.com/samliebow.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/johman10\"><img src=\"https://github.com/johman10.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/R-Gld\"><img src=\"https://github.com/R-Gld.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/FingerlessGlov3s\"><img src=\"https://github.com/FingerlessGlov3s.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Twisterado\"><img src=\"https://github.com/Twisterado.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/ScrumpyJack\"><img src=\"https://github.com/ScrumpyJack.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/andrejarrell\"><img src=\"https://github.com/andrejarrell.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/oaustegard\"><img src=\"https://github.com/oaustegard.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/CreativeWarlock\"><img src=\"https://github.com/CreativeWarlock.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/darkdragon-001\"><img src=\"https://github.com/darkdragon-001.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/jonathan-kosgei\"><img src=\"https://github.com/jonathan-kosgei.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/KevinWang15\"><img src=\"https://github.com/KevinWang15.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/darkmattercoder\"><img src=\"https://github.com/darkmattercoder.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/bmcgonag\"><img src=\"https://github.com/bmcgonag.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/skorokithakis\"><img src=\"https://github.com/skorokithakis.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/eenturk\"><img src=\"https://github.com/eenturk.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/spirossi\"><img src=\"https://github.com/spirossi.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/teomarcdhio\"><img src=\"https://github.com/teomarcdhio.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/MarcMichalsky\"><img src=\"https://github.com/MarcMichalsky.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/LuckVintage\"><img src=\"https://github.com/LuckVintage.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/spartan\"><img src=\"https://github.com/spartan.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/alexandzors\"><img src=\"https://github.com/alexandzors.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/dkramer95\"><img src=\"https://github.com/dkramer95.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/YezGotIt\"><img src=\"https://github.com/YezGotIt.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/thomasskou\"><img src=\"https://github.com/thomasskou.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/surfernv\"><img src=\"https://github.com/surfernv.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/richardleach\"><img src=\"https://github.com/richardleach.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/bear\"><img src=\"https://github.com/bear.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/cminter\"><img src=\"https://github.com/cminter.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/bahur142\"><img src=\"https://github.com/bahur142.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/pgwiebes\"><img src=\"https://github.com/pgwiebes.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/ralhei\"><img src=\"https://github.com/ralhei.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/TechMDW\"><img src=\"https://github.com/TechMDW.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/ubipo\"><img src=\"https://github.com/ubipo.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/tka85\"><img src=\"https://github.com/tka85.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/beekeeb\"><img src=\"https://github.com/beekeeb.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Emiliaaah\"><img src=\"https://github.com/Emiliaaah.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/zark0s\"><img src=\"https://github.com/zark0s.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/tomershvueli\"><img src=\"https://github.com/tomershvueli.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/CataIana\"><img src=\"https://github.com/CataIana.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/ajay-actuary\"><img src=\"https://github.com/ajay-actuary.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/mursec\"><img src=\"https://github.com/mursec.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/FrameXX\"><img src=\"https://github.com/FrameXX.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/vovayartsev\"><img src=\"https://github.com/vovayartsev.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/dwain-lab\"><img src=\"https://github.com/dwain-lab.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/brookmg\"><img src=\"https://github.com/brookmg.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/siebej\"><img src=\"https://github.com/siebej.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/rxsantos\"><img src=\"https://github.com/rxsantos.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/hermannx5\"><img src=\"https://github.com/hermannx5.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/rwxd\"><img src=\"https://github.com/rwxd.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Integral-Tech\"><img src=\"https://github.com/Integral-Tech.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/TheTomik1\"><img src=\"https://github.com/TheTomik1.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/dav23r\"><img src=\"https://github.com/dav23r.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/stannynuytkens\"><img src=\"https://github.com/stannynuytkens.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/danbartram\"><img src=\"https://github.com/danbartram.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/arthurgleckler\"><img src=\"https://github.com/arthurgleckler.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/tomroth04\"><img src=\"https://github.com/tomroth04.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Circenn5130\"><img src=\"https://github.com/Circenn5130.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/jceloria\"><img src=\"https://github.com/jceloria.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/afunworm\"><img src=\"https://github.com/afunworm.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/PTR-inc\"><img src=\"https://github.com/PTR-inc.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/spudooli\"><img src=\"https://github.com/spudooli.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/IMarkoMC\"><img src=\"https://github.com/IMarkoMC.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/rubund\"><img src=\"https://github.com/rubund.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Riolku\"><img src=\"https://github.com/Riolku.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/arnbrhm\"><img src=\"https://github.com/arnbrhm.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/herzkerl\"><img src=\"https://github.com/herzkerl.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/0x45796164\"><img src=\"https://github.com/0x45796164.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/madchr1st\"><img src=\"https://github.com/madchr1st.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/avalentic\"><img src=\"https://github.com/avalentic.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/TheCraiggers\"><img src=\"https://github.com/TheCraiggers.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/sheetd\"><img src=\"https://github.com/sheetd.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/dlt-green\"><img src=\"https://github.com/dlt-green.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/suhlig\"><img src=\"https://github.com/suhlig.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/Proximus888\"><img src=\"https://github.com/Proximus888.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/wielandp\"><img src=\"https://github.com/wielandp.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/chxseh\"><img src=\"https://github.com/chxseh.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/user8446\"><img src=\"https://github.com/user8446.png\" width=\"40px\" /></a>\n<a href=\"https://github.com/cdf-eagles\"><img src=\"https://github.com/cdf-eagles.png\" width=\"40px\" /></a>\n\n## Contributing\nI welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out\non Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)\nfor the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in\n[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).\n\n<a href=\"https://hosted.weblate.org/engage/ntfy/\">\n<img src=\"https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\n## Code of Conduct\nWe as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for\neveryone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity\nand expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste,\ncolor, religion, or sexual identity and orientation.\n\n**We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.**\n\n_Please be sure to read the complete [Code of Conduct](CODE_OF_CONDUCT.md)._    \n\n## License\nMade with ❤️ by [Philipp C. Heckel](https://heckel.io).   \nThe project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).\n\nThird-party libraries and resources:\n* [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI\n* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds\n* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds\n* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web\n* [React](https://reactjs.org/) (MIT) is used for the web app\n* [Material UI components](https://mui.com/) (MIT) are used in the web app\n* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app\n* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB\n* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases\n* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails\n* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests\n* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache\n* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages\n* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)\n* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page \n* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files\n* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)\n* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)\n* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)\n* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications\n* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nAs of today, I only support the latest version of ntfy. Please make sure you stay up-to-date.\n\n## Reporting a Vulnerability\n\nPlease report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).\n\nYou can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) \n(my username is `binwiederhier`).\n"
  },
  {
    "path": "client/client.go",
    "content": "// Package client provides a ntfy client to publish and subscribe to topics\npackage client\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\t// MessageEvent identifies a message event\n\tMessageEvent = \"message\"\n)\n\nconst (\n\tmaxResponseBytes = 4096\n)\n\nvar (\n\ttopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go\n)\n\n// Client is the ntfy client that can be used to publish and subscribe to ntfy topics\ntype Client struct {\n\tMessages      chan *Message\n\tconfig        *Config\n\tsubscriptions map[string]*subscription\n\tmu            sync.Mutex\n}\n\n// Message is a struct that represents a ntfy message\ntype Message struct { // TODO combine with server.message\n\tID         string\n\tEvent      string\n\tTime       int64\n\tTopic      string\n\tMessage    string\n\tTitle      string\n\tPriority   int\n\tTags       []string\n\tClick      string\n\tIcon       string\n\tAttachment *Attachment\n\n\t// Additional fields\n\tTopicURL       string\n\tSubscriptionID string\n\tRaw            string\n}\n\n// Attachment represents a message attachment\ntype Attachment struct {\n\tName    string `json:\"name\"`\n\tType    string `json:\"type,omitempty\"`\n\tSize    int64  `json:\"size,omitempty\"`\n\tExpires int64  `json:\"expires,omitempty\"`\n\tURL     string `json:\"url\"`\n\tOwner   string `json:\"-\"` // IP address of uploader, used for rate limiting\n}\n\ntype subscription struct {\n\tID       string\n\ttopicURL string\n\tcancel   context.CancelFunc\n}\n\n// New creates a new Client using a given Config\nfunc New(config *Config) *Client {\n\treturn &Client{\n\t\tMessages:      make(chan *Message, 50), // Allow reading a few messages\n\t\tconfig:        config,\n\t\tsubscriptions: make(map[string]*subscription),\n\t}\n}\n\n// Publish sends a message to a specific topic, optionally using options.\n// See PublishReader for details.\nfunc (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {\n\treturn c.PublishReader(topic, strings.NewReader(message), options...)\n}\n\n// PublishReader sends a message to a specific topic, optionally using options.\n//\n// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://\n// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the\n// config (e.g. mytopic -> https://ntfy.sh/mytopic).\n//\n// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,\n// WithNoFirebase, and the generic WithHeader.\nfunc (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {\n\ttopicURL, err := c.expandTopicURL(topic)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequest(\"POST\", topicURL, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, option := range options {\n\t\tif err := option(req); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tlog.Debug(\"%s Publishing message with headers %s\", util.ShortTopicURL(topicURL), req.Header)\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tb, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, errors.New(strings.TrimSpace(string(b)))\n\t}\n\tm, err := toMessage(string(b), topicURL, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\n// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for\n// messages and does not subscribe to messages that arrive after this call.\n//\n// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://\n// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the\n// config (e.g. mytopic -> https://ntfy.sh/mytopic).\n//\n// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.\n// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.\nfunc (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {\n\ttopicURL, err := c.expandTopicURL(topic)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tctx := context.Background()\n\tmessages := make([]*Message, 0)\n\tmsgChan := make(chan *Message)\n\terrChan := make(chan error)\n\tlog.Debug(\"%s Polling from topic\", util.ShortTopicURL(topicURL))\n\toptions = append(options, WithPoll())\n\tgo func() {\n\t\terr := performSubscribeRequest(ctx, msgChan, topicURL, \"\", options...)\n\t\tclose(msgChan)\n\t\terrChan <- err\n\t}()\n\tfor m := range msgChan {\n\t\tmessages = append(messages, m)\n\t}\n\treturn messages, <-errChan\n}\n\n// Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the\n// background and returns new messages via the Messages channel.\n//\n// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://\n// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the\n// config (e.g. mytopic -> https://ntfy.sh/mytopic).\n//\n// By default, only new messages will be returned, but you can change this behavior using a SubscribeOption.\n// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.\n//\n// The method returns a unique subscriptionID that can be used in Unsubscribe.\n//\n// Example:\n//\n//\tc := client.New(client.NewConfig())\n//\tsubscriptionID, _ := c.Subscribe(\"mytopic\")\n//\tfor m := range c.Messages {\n//\t  fmt.Printf(\"New message: %s\", m.Message)\n//\t}\nfunc (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {\n\ttopicURL, err := c.expandTopicURL(topic)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tsubscriptionID := util.RandomString(10)\n\tlog.Debug(\"%s Subscribing to topic\", util.ShortTopicURL(topicURL))\n\tctx, cancel := context.WithCancel(context.Background())\n\tc.subscriptions[subscriptionID] = &subscription{\n\t\tID:       subscriptionID,\n\t\ttopicURL: topicURL,\n\t\tcancel:   cancel,\n\t}\n\tgo handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)\n\treturn subscriptionID, nil\n}\n\n// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique\n// subscriptionID returned in Subscribe.\nfunc (c *Client) Unsubscribe(subscriptionID string) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tsub, ok := c.subscriptions[subscriptionID]\n\tif !ok {\n\t\treturn\n\t}\n\tdelete(c.subscriptions, subscriptionID)\n\tsub.cancel()\n}\n\nfunc (c *Client) expandTopicURL(topic string) (string, error) {\n\tif strings.HasPrefix(topic, \"http://\") || strings.HasPrefix(topic, \"https://\") {\n\t\treturn topic, nil\n\t} else if strings.Contains(topic, \"/\") {\n\t\treturn fmt.Sprintf(\"https://%s\", topic), nil\n\t}\n\tif !topicRegex.MatchString(topic) {\n\t\treturn \"\", fmt.Errorf(\"invalid topic name: %s\", topic)\n\t}\n\treturn fmt.Sprintf(\"%s/%s\", c.config.DefaultHost, topic), nil\n}\n\nfunc handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {\n\tfor {\n\t\t// TODO The retry logic is crude and may lose messages. It should record the last message like the\n\t\t//      Android client, use since=, and do incremental backoff too\n\t\tif err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {\n\t\t\tlog.Warn(\"%s Connection failed: %s\", util.ShortTopicURL(topicURL), err.Error())\n\t\t}\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlog.Info(\"%s Connection exited\", util.ShortTopicURL(topicURL))\n\t\t\treturn\n\t\tcase <-time.After(10 * time.Second): // TODO Add incremental backoff\n\t\t}\n\t}\n}\n\nfunc performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {\n\tstreamURL := fmt.Sprintf(\"%s/json\", topicURL)\n\tlog.Debug(\"%s Listening to %s\", util.ShortTopicURL(topicURL), streamURL)\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, option := range options {\n\t\tif err := option(req); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode != http.StatusOK {\n\t\tb, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn errors.New(strings.TrimSpace(string(b)))\n\t}\n\tscanner := bufio.NewScanner(resp.Body)\n\tfor scanner.Scan() {\n\t\tmessageJSON := scanner.Text()\n\t\tm, err := toMessage(messageJSON, topicURL, subscriptionID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlog.Trace(\"%s Message received: %s\", util.ShortTopicURL(topicURL), messageJSON)\n\t\tif m.Event == MessageEvent {\n\t\t\tmsgChan <- m\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc toMessage(s, topicURL, subscriptionID string) (*Message, error) {\n\tvar m *Message\n\tif err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil {\n\t\treturn nil, err\n\t}\n\tm.TopicURL = topicURL\n\tm.SubscriptionID = subscriptionID\n\tm.Raw = s\n\treturn m, nil\n}\n"
  },
  {
    "path": "client/client.yml",
    "content": "# ntfy client config file\n\n# Base URL used to expand short topic names in the \"ntfy publish\" and \"ntfy subscribe\" commands.\n# If you self-host a ntfy server, you'll likely want to change this.\n#\n# default-host: https://ntfy.sh\n\n# Default credentials will be used with \"ntfy publish\" and \"ntfy subscribe\" if no other credentials are provided.\n# You can set a default token to use or a default user:password combination, but not both. For an empty password,\n# use empty double-quotes (\"\").\n#\n# To override the default user:password combination or default token for a particular subscription (e.g., to send\n# no Authorization header), set the user:pass/token for the subscription to empty double-quotes (\"\").\n\n# default-token:\n\n# default-user:\n# default-password:\n\n# Default command will execute after \"ntfy subscribe\" receives a message if no command is provided in subscription below\n# default-command:\n\n# Subscriptions to topics and their actions. This option is primarily used by the systemd service,\n# or if you can \"ntfy subscribe --from-config\" directly.\n#\n# Example:\n#     subscribe:\n#       - topic: mytopic\n#         command: /usr/local/bin/mytopic-triggered.sh\n#       - topic: myserver.com/anothertopic\n#         command: 'echo \"$message\"'\n#         if:\n#             priority: high,urgent\n#       - topic: secret\n#         command: 'notify-send \"$m\"'\n#         user: phill\n#         password: mypass\n#       - topic: token_topic\n#         token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n#\n# Variables:\n#     Variable        Aliases               Description\n#     --------------- --------------------- -----------------------------------\n#     $NTFY_ID        $id                   Unique message ID\n#     $NTFY_TIME      $time                 Unix timestamp of the message delivery\n#     $NTFY_TOPIC     $topic                Topic name\n#     $NTFY_MESSAGE   $message, $m          Message body\n#     $NTFY_TITLE     $title, $t            Message title\n#     $NTFY_PRIORITY  $priority, $prio, $p  Message priority (1=min, 5=max)\n#     $NTFY_TAGS      $tags, $tag, $ta      Message tags (comma separated list)\n#     $NTFY_RAW       $raw                  Raw JSON message\n#\n# Filters ('if:'):\n#     You can filter 'message', 'title', 'priority' (comma-separated list, logical OR)\n#     and 'tags' (comma-separated list, logical AND). See https://ntfy.sh/docs/subscribe/api/#filter-messages.\n#\n# subscribe:\n"
  },
  {
    "path": "client/client_test.go",
    "content": "package client_test\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestMain(m *testing.M) {\n\tlog.SetLevel(log.ErrorLevel)\n\tos.Exit(m.Run())\n}\n\nfunc TestClient_Publish_Subscribe(t *testing.T) {\n\ts, port := test.StartServer(t)\n\tdefer test.StopServer(t, s, port)\n\tc := client.New(newTestConfig(port))\n\n\tsubscriptionID, _ := c.Subscribe(\"mytopic\")\n\ttime.Sleep(time.Second)\n\n\tmsg, err := c.Publish(\"mytopic\", \"some message\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some message\", msg.Message)\n\n\tmsg, err = c.Publish(\"mytopic\", \"some other message\",\n\t\tclient.WithTitle(\"some title\"),\n\t\tclient.WithPriority(\"high\"),\n\t\tclient.WithTags([]string{\"tag1\", \"tag 2\"}))\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some other message\", msg.Message)\n\trequire.Equal(t, \"some title\", msg.Title)\n\trequire.Equal(t, []string{\"tag1\", \"tag 2\"}, msg.Tags)\n\trequire.Equal(t, 4, msg.Priority)\n\n\tmsg, err = c.Publish(\"mytopic\", \"some delayed message\",\n\t\tclient.WithDelay(\"25 hours\"))\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some delayed message\", msg.Message)\n\trequire.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)\n\n\ttime.Sleep(200 * time.Millisecond)\n\n\tmsg = nextMessage(c)\n\trequire.NotNil(t, msg)\n\trequire.Equal(t, \"some message\", msg.Message)\n\n\tmsg = nextMessage(c)\n\trequire.NotNil(t, msg)\n\trequire.Equal(t, \"some other message\", msg.Message)\n\trequire.Equal(t, \"some title\", msg.Title)\n\trequire.Equal(t, []string{\"tag1\", \"tag 2\"}, msg.Tags)\n\trequire.Equal(t, 4, msg.Priority)\n\n\tmsg = nextMessage(c)\n\trequire.Nil(t, msg)\n\n\tc.Unsubscribe(subscriptionID)\n\ttime.Sleep(200 * time.Millisecond)\n\n\tmsg, err = c.Publish(\"mytopic\", \"a message that won't be received\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"a message that won't be received\", msg.Message)\n\n\tmsg = nextMessage(c)\n\trequire.Nil(t, msg)\n}\n\nfunc TestClient_Publish_Poll(t *testing.T) {\n\ts, port := test.StartServer(t)\n\tdefer test.StopServer(t, s, port)\n\tc := client.New(newTestConfig(port))\n\n\tmsg, err := c.Publish(\"mytopic\", \"some message\", client.WithNoFirebase(), client.WithTagsList(\"tag1,tag2\"))\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some message\", msg.Message)\n\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, msg.Tags)\n\n\tmsg, err = c.Publish(\"mytopic\", \"this won't be cached\", client.WithNoCache())\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"this won't be cached\", msg.Message)\n\n\tmsg, err = c.Publish(\"mytopic\", \"some delayed message\", client.WithDelay(\"20 min\"))\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some delayed message\", msg.Message)\n\n\tmessages, err := c.Poll(\"mytopic\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(messages))\n\trequire.Equal(t, \"some message\", messages[0].Message)\n\n\tmessages, err = c.Poll(\"mytopic\", client.WithScheduled())\n\trequire.Nil(t, err)\n\trequire.Equal(t, 2, len(messages))\n\trequire.Equal(t, \"some message\", messages[0].Message)\n\trequire.Equal(t, \"some delayed message\", messages[1].Message)\n}\n\nfunc newTestConfig(port int) *client.Config {\n\tc := client.NewConfig()\n\tc.DefaultHost = fmt.Sprintf(\"http://127.0.0.1:%d\", port)\n\treturn c\n}\n\nfunc nextMessage(c *client.Client) *client.Message {\n\tselect {\n\tcase m := <-c.Messages:\n\t\treturn m\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "client/config.go",
    "content": "package client\n\nimport (\n\t\"gopkg.in/yaml.v2\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"os\"\n)\n\nconst (\n\t// DefaultBaseURL is the base URL used to expand short topic names\n\tDefaultBaseURL = \"https://ntfy.sh\"\n)\n\n// DefaultConfigFile is the default path to the client config file (set in config_*.go)\nvar DefaultConfigFile string\n\n// Config is the config struct for a Client\ntype Config struct {\n\tDefaultHost     string      `yaml:\"default-host\"`\n\tDefaultUser     string      `yaml:\"default-user\"`\n\tDefaultPassword *string     `yaml:\"default-password\"`\n\tDefaultToken    string      `yaml:\"default-token\"`\n\tDefaultCommand  string      `yaml:\"default-command\"`\n\tSubscribe       []Subscribe `yaml:\"subscribe\"`\n}\n\n// Subscribe is the struct for a Subscription within Config\ntype Subscribe struct {\n\tTopic    string            `yaml:\"topic\"`\n\tUser     *string           `yaml:\"user\"`\n\tPassword *string           `yaml:\"password\"`\n\tToken    *string           `yaml:\"token\"`\n\tCommand  string            `yaml:\"command\"`\n\tIf       map[string]string `yaml:\"if\"`\n}\n\n// NewConfig creates a new Config struct for a Client\nfunc NewConfig() *Config {\n\treturn &Config{\n\t\tDefaultHost:     DefaultBaseURL,\n\t\tDefaultUser:     \"\",\n\t\tDefaultPassword: nil,\n\t\tDefaultToken:    \"\",\n\t\tDefaultCommand:  \"\",\n\t\tSubscribe:       nil,\n\t}\n}\n\n// LoadConfig loads the Client config from a yaml file\nfunc LoadConfig(filename string) (*Config, error) {\n\tlog.Debug(\"Loading client config from %s\", filename)\n\tb, err := os.ReadFile(filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc := NewConfig()\n\tif err := yaml.Unmarshal(b, c); err != nil {\n\t\treturn nil, err\n\t}\n\treturn c, nil\n}\n"
  },
  {
    "path": "client/config_darwin.go",
    "content": "//go:build darwin\n\npackage client\n\nimport (\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n)\n\nfunc init() {\n\tu, err := user.Current()\n\tif err == nil && u.Uid == \"0\" {\n\t\tDefaultConfigFile = \"/etc/ntfy/client.yml\"\n\t} else if configDir, err := os.UserConfigDir(); err == nil {\n\t\tDefaultConfigFile = filepath.Join(configDir, \"ntfy\", \"client.yml\")\n\t}\n}\n"
  },
  {
    "path": "client/config_test.go",
    "content": "package client_test\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestConfig_Load(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(`\ndefault-host: http://localhost\ndefault-user: philipp\ndefault-password: mypass\ndefault-command: 'echo \"Got the message: $message\"'\nsubscribe:\n  - topic: no-command-with-auth\n    user: phil\n    password: mypass\n  - topic: echo-this\n    command: 'echo \"Message received: $message\"'\n  - topic: alerts\n    command: notify-send -i /usr/share/ntfy/logo.png \"Important\" \"$m\"\n    if:\n            priority: high,urgent\n  - topic: defaults\n`), 0600))\n\n\tconf, err := client.LoadConfig(filename)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"http://localhost\", conf.DefaultHost)\n\trequire.Equal(t, \"philipp\", conf.DefaultUser)\n\trequire.Equal(t, \"mypass\", *conf.DefaultPassword)\n\trequire.Equal(t, `echo \"Got the message: $message\"`, conf.DefaultCommand)\n\trequire.Equal(t, 4, len(conf.Subscribe))\n\trequire.Equal(t, \"no-command-with-auth\", conf.Subscribe[0].Topic)\n\trequire.Equal(t, \"\", conf.Subscribe[0].Command)\n\trequire.Equal(t, \"phil\", *conf.Subscribe[0].User)\n\trequire.Equal(t, \"mypass\", *conf.Subscribe[0].Password)\n\trequire.Equal(t, \"echo-this\", conf.Subscribe[1].Topic)\n\trequire.Equal(t, `echo \"Message received: $message\"`, conf.Subscribe[1].Command)\n\trequire.Equal(t, \"alerts\", conf.Subscribe[2].Topic)\n\trequire.Equal(t, `notify-send -i /usr/share/ntfy/logo.png \"Important\" \"$m\"`, conf.Subscribe[2].Command)\n\trequire.Equal(t, \"high,urgent\", conf.Subscribe[2].If[\"priority\"])\n\trequire.Equal(t, \"defaults\", conf.Subscribe[3].Topic)\n}\n\nfunc TestConfig_EmptyPassword(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(`\ndefault-host: http://localhost\ndefault-user: philipp\ndefault-password: \"\"\nsubscribe:\n  - topic: no-command-with-auth\n    user: phil\n    password: \"\"\n`), 0600))\n\n\tconf, err := client.LoadConfig(filename)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"http://localhost\", conf.DefaultHost)\n\trequire.Equal(t, \"philipp\", conf.DefaultUser)\n\trequire.Equal(t, \"\", *conf.DefaultPassword)\n\trequire.Equal(t, 1, len(conf.Subscribe))\n\trequire.Equal(t, \"no-command-with-auth\", conf.Subscribe[0].Topic)\n\trequire.Equal(t, \"\", conf.Subscribe[0].Command)\n\trequire.Equal(t, \"phil\", *conf.Subscribe[0].User)\n\trequire.Equal(t, \"\", *conf.Subscribe[0].Password)\n}\n\nfunc TestConfig_NullPassword(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(`\ndefault-host: http://localhost\ndefault-user: philipp\ndefault-password: ~\nsubscribe:\n  - topic: no-command-with-auth\n    user: phil\n    password: ~\n`), 0600))\n\n\tconf, err := client.LoadConfig(filename)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"http://localhost\", conf.DefaultHost)\n\trequire.Equal(t, \"philipp\", conf.DefaultUser)\n\trequire.Nil(t, conf.DefaultPassword)\n\trequire.Equal(t, 1, len(conf.Subscribe))\n\trequire.Equal(t, \"no-command-with-auth\", conf.Subscribe[0].Topic)\n\trequire.Equal(t, \"\", conf.Subscribe[0].Command)\n\trequire.Equal(t, \"phil\", *conf.Subscribe[0].User)\n\trequire.Nil(t, conf.Subscribe[0].Password)\n}\n\nfunc TestConfig_NoPassword(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(`\ndefault-host: http://localhost\ndefault-user: philipp\nsubscribe:\n  - topic: no-command-with-auth\n    user: phil\n`), 0600))\n\n\tconf, err := client.LoadConfig(filename)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"http://localhost\", conf.DefaultHost)\n\trequire.Equal(t, \"philipp\", conf.DefaultUser)\n\trequire.Nil(t, conf.DefaultPassword)\n\trequire.Equal(t, 1, len(conf.Subscribe))\n\trequire.Equal(t, \"no-command-with-auth\", conf.Subscribe[0].Topic)\n\trequire.Equal(t, \"\", conf.Subscribe[0].Command)\n\trequire.Equal(t, \"phil\", *conf.Subscribe[0].User)\n\trequire.Nil(t, conf.Subscribe[0].Password)\n}\n\nfunc TestConfig_DefaultToken(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(`\ndefault-host: http://localhost\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\nsubscribe:\n  - topic: mytopic\n`), 0600))\n\n\tconf, err := client.LoadConfig(filename)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"http://localhost\", conf.DefaultHost)\n\trequire.Equal(t, \"\", conf.DefaultUser)\n\trequire.Nil(t, conf.DefaultPassword)\n\trequire.Equal(t, \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", conf.DefaultToken)\n\trequire.Equal(t, 1, len(conf.Subscribe))\n\trequire.Equal(t, \"mytopic\", conf.Subscribe[0].Topic)\n\trequire.Nil(t, conf.Subscribe[0].User)\n\trequire.Nil(t, conf.Subscribe[0].Password)\n\trequire.Nil(t, conf.Subscribe[0].Token)\n}\n"
  },
  {
    "path": "client/config_unix.go",
    "content": "//go:build linux || dragonfly || freebsd || netbsd || openbsd\n\npackage client\n\nimport (\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n)\n\nfunc init() {\n\tu, err := user.Current()\n\tif err == nil && u.Uid == \"0\" {\n\t\tDefaultConfigFile = \"/etc/ntfy/client.yml\"\n\t} else if configDir, err := os.UserConfigDir(); err == nil {\n\t\tDefaultConfigFile = filepath.Join(configDir, \"ntfy\", \"client.yml\")\n\t}\n}\n"
  },
  {
    "path": "client/config_windows.go",
    "content": "//go:build windows\n\npackage client\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc init() {\n\tif configDir, err := os.UserConfigDir(); err == nil {\n\t\tDefaultConfigFile = filepath.Join(configDir, \"ntfy\", \"client.yml\")\n\t}\n}\n"
  },
  {
    "path": "client/ntfy-client.service",
    "content": "[Unit]\nDescription=ntfy client\nAfter=network.target\n\n[Service]\nUser=ntfy\nGroup=ntfy\nExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "client/options.go",
    "content": "package client\n\nimport (\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\n// RequestOption is a generic request option that can be added to Client calls\ntype RequestOption = func(r *http.Request) error\n\n// PublishOption is an option that can be passed to the Client.Publish call\ntype PublishOption = RequestOption\n\n// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call\ntype SubscribeOption = RequestOption\n\n// WithMessage sets the notification message. This is an alternative way to passing the message body.\nfunc WithMessage(message string) PublishOption {\n\treturn WithHeader(\"X-Message\", message)\n}\n\n// WithTitle adds a title to a message\nfunc WithTitle(title string) PublishOption {\n\treturn WithHeader(\"X-Title\", title)\n}\n\n// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max),\n// or the corresponding names (see util.ParsePriority).\nfunc WithPriority(priority string) PublishOption {\n\treturn WithHeader(\"X-Priority\", priority)\n}\n\n// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list\n// of tags. To use a slice, use WithTags instead\nfunc WithTagsList(tags string) PublishOption {\n\treturn WithHeader(\"X-Tags\", tags)\n}\n\n// WithTags adds a list of a tags to a message\nfunc WithTags(tags []string) PublishOption {\n\treturn WithTagsList(strings.Join(tags, \",\"))\n}\n\n// WithDelay instructs the server to send the message at a later date. The delay parameter can be a\n// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery\n// for details.\nfunc WithDelay(delay string) PublishOption {\n\treturn WithHeader(\"X-Delay\", delay)\n}\n\n// WithClick makes the notification action open the given URL as opposed to entering the detail view\nfunc WithClick(url string) PublishOption {\n\treturn WithHeader(\"X-Click\", url)\n}\n\n// WithIcon makes the notification use the given URL as its icon\nfunc WithIcon(icon string) PublishOption {\n\treturn WithHeader(\"X-Icon\", icon)\n}\n\n// WithActions adds custom user actions to the notification. The value can be either a JSON array or the\n// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.\nfunc WithActions(value string) PublishOption {\n\treturn WithHeader(\"X-Actions\", value)\n}\n\n// WithAttach sets a URL that will be used by the client to download an attachment\nfunc WithAttach(attach string) PublishOption {\n\treturn WithHeader(\"X-Attach\", attach)\n}\n\n// WithMarkdown instructs the server to interpret the message body as Markdown\nfunc WithMarkdown() PublishOption {\n\treturn WithHeader(\"X-Markdown\", \"yes\")\n}\n\n// WithTemplate instructs the server to use a specific template for the message. If templateName is is \"yes\" or \"1\",\n// the server will interpret the message and title as a template.\nfunc WithTemplate(templateName string) PublishOption {\n\treturn WithHeader(\"X-Template\", templateName)\n}\n\n// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment\nfunc WithFilename(filename string) PublishOption {\n\treturn WithHeader(\"X-Filename\", filename)\n}\n\n// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications\nfunc WithSequenceID(sequenceID string) PublishOption {\n\treturn WithHeader(\"X-Sequence-ID\", sequenceID)\n}\n\n// WithEmail instructs the server to also send the message to the given e-mail address\nfunc WithEmail(email string) PublishOption {\n\treturn WithHeader(\"X-Email\", email)\n}\n\n// WithBasicAuth adds the Authorization header for basic auth to the request\nfunc WithBasicAuth(user, pass string) PublishOption {\n\treturn WithHeader(\"Authorization\", util.BasicAuth(user, pass))\n}\n\n// WithBearerAuth adds the Authorization header for Bearer auth to the request\nfunc WithBearerAuth(token string) PublishOption {\n\treturn WithHeader(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token))\n}\n\n// WithEmptyAuth clears the Authorization header\nfunc WithEmptyAuth() PublishOption {\n\treturn RemoveHeader(\"Authorization\")\n}\n\n// WithNoCache instructs the server not to cache the message server-side\nfunc WithNoCache() PublishOption {\n\treturn WithHeader(\"X-Cache\", \"no\")\n}\n\n// WithNoFirebase instructs the server not to forward the message to Firebase\nfunc WithNoFirebase() PublishOption {\n\treturn WithHeader(\"X-Firebase\", \"no\")\n}\n\n// WithSince limits the number of messages returned from the server. The parameter since can be a Unix\n// timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word \"all\" (see WithSinceAll).\nfunc WithSince(since string) SubscribeOption {\n\treturn WithQueryParam(\"since\", since)\n}\n\n// WithSinceAll instructs the server to return all messages for the given topic from the server\nfunc WithSinceAll() SubscribeOption {\n\treturn WithSince(\"all\")\n}\n\n// WithSinceDuration instructs the server to return all messages since the given duration ago\nfunc WithSinceDuration(since time.Duration) SubscribeOption {\n\treturn WithSinceUnixTime(time.Now().Add(-1 * since).Unix())\n}\n\n// WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp\nfunc WithSinceUnixTime(since int64) SubscribeOption {\n\treturn WithSince(fmt.Sprintf(\"%d\", since))\n}\n\n// WithPoll instructs the server to close the connection after messages have been returned. Don't use this option\n// directly. Use Client.Poll instead.\nfunc WithPoll() SubscribeOption {\n\treturn WithQueryParam(\"poll\", \"1\")\n}\n\n// WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled\n// messages (see WithDelay). The messages will have a future date.\nfunc WithScheduled() SubscribeOption {\n\treturn WithQueryParam(\"scheduled\", \"1\")\n}\n\n// WithFilter is a generic subscribe option meant to be used to filter for certain messages only\nfunc WithFilter(param, value string) SubscribeOption {\n\treturn WithQueryParam(param, value)\n}\n\n// WithMessageFilter instructs the server to only return messages that match the exact message\nfunc WithMessageFilter(message string) SubscribeOption {\n\treturn WithQueryParam(\"message\", message)\n}\n\n// WithTitleFilter instructs the server to only return messages with a title that match the exact string\nfunc WithTitleFilter(title string) SubscribeOption {\n\treturn WithQueryParam(\"title\", title)\n}\n\n// WithPriorityFilter instructs the server to only return messages with the matching priority. Not that messages\n// without priority also implicitly match priority 3.\nfunc WithPriorityFilter(priority int) SubscribeOption {\n\treturn WithQueryParam(\"priority\", fmt.Sprintf(\"%d\", priority))\n}\n\n// WithTagsFilter instructs the server to only return messages that contain all of the given tags\nfunc WithTagsFilter(tags []string) SubscribeOption {\n\treturn WithQueryParam(\"tags\", strings.Join(tags, \",\"))\n}\n\n// WithHeader is a generic option to add headers to a request\nfunc WithHeader(header, value string) RequestOption {\n\treturn func(r *http.Request) error {\n\t\tif value != \"\" {\n\t\t\tr.Header.Set(header, value)\n\t\t}\n\t\treturn nil\n\t}\n}\n\n// WithQueryParam is a generic option to add query parameters to a request\nfunc WithQueryParam(param, value string) RequestOption {\n\treturn func(r *http.Request) error {\n\t\tif value != \"\" {\n\t\t\tq := r.URL.Query()\n\t\t\tq.Add(param, value)\n\t\t\tr.URL.RawQuery = q.Encode()\n\t\t}\n\t\treturn nil\n\t}\n}\n\n// RemoveHeader is a generic option to remove a header from a request\nfunc RemoveHeader(header string) RequestOption {\n\treturn func(r *http.Request) error {\n\t\tif header != \"\" {\n\t\t\tdelete(r.Header, header)\n\t\t}\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "client/user/ntfy-client.service",
    "content": "[Unit]\nDescription=ntfy client\nAfter=network.target\n\n[Service]\nExecStart=/usr/bin/ntfy subscribe --config \"%h/.config/ntfy/client.yml\" --from-config\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n"
  },
  {
    "path": "cmd/access.go",
    "content": "//go:build !noserver\n\npackage cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdAccess)\n}\n\nconst (\n\tuserEveryone = \"everyone\"\n)\n\nvar flagsAccess = append(\n\tappend([]cli.Flag{}, flagsUser...),\n\t&cli.BoolFlag{Name: \"reset\", Aliases: []string{\"r\"}, Usage: \"reset access for user (and topic)\"},\n)\n\nvar cmdAccess = &cli.Command{\n\tName:      \"access\",\n\tUsage:     \"Grant/revoke access to a topic, or show access\",\n\tUsageText: \"ntfy access [USERNAME [TOPIC [PERMISSION]]]\",\n\tFlags:     flagsAccess,\n\tBefore:    initConfigFileInputSourceFunc(\"config\", flagsAccess, initLogFunc),\n\tAction:    execUserAccess,\n\tCategory:  categoryServer,\n\tDescription: `Manage the access control list for the ntfy server.\n\nThis is a server-only command. It directly manages the user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined. Please also refer\nto the related command 'ntfy user'.\n\nThe command allows you to show the access control list, as well as change it, depending on how\nit is called.\n\nUsage:\n  ntfy access                            # Shows access control list (alias: 'ntfy user list')\n  ntfy access USERNAME                   # Shows access control entries for USERNAME\n  ntfy access USERNAME TOPIC PERMISSION  # Allow/deny access for USERNAME to TOPIC\n\nArguments:\n  USERNAME     an existing user, as created with 'ntfy user add', or \"everyone\"/\"*\"\n               to define access rules for anonymous/unauthenticated clients\n  TOPIC        name of a topic with optional wildcards, e.g. \"mytopic*\"\n  PERMISSION   one of the following:\n               - read-write (alias: rw) \n               - read-only (aliases: read, ro)\n               - write-only (aliases: write, wo)\n               - deny (alias: none)\n\nExamples:\n  ntfy access                        # Shows access control list (alias: 'ntfy user list')\n  ntfy access phil                   # Shows access for user phil\n  ntfy access phil mytopic rw        # Allow read-write access to mytopic for user phil\n  ntfy access everyone mytopic rw    # Allow anonymous read-write access to mytopic\n  ntfy access everyone \"up*\" write   # Allow anonymous write-only access to topics \"up...\" \n  ntfy access --reset                # Reset entire access control list\n  ntfy access --reset phil           # Reset all access for user phil\n  ntfy access --reset phil mytopic   # Reset access for user phil and topic mytopic\n`,\n}\n\nfunc execUserAccess(c *cli.Context) error {\n\tif c.NArg() > 3 {\n\t\treturn errors.New(\"too many arguments, please check 'ntfy access --help' for usage details\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tusername := c.Args().Get(0)\n\tif username == userEveryone {\n\t\tusername = user.Everyone\n\t}\n\ttopic := c.Args().Get(1)\n\tperms := c.Args().Get(2)\n\treset := c.Bool(\"reset\")\n\tif reset {\n\t\tif perms != \"\" {\n\t\t\treturn errors.New(\"too many arguments, please check 'ntfy access --help' for usage details\")\n\t\t}\n\t\treturn resetAccess(c, manager, username, topic)\n\t} else if perms == \"\" {\n\t\tif topic != \"\" {\n\t\t\treturn errors.New(\"invalid syntax, please check 'ntfy access --help' for usage details\")\n\t\t}\n\t\treturn showAccess(c, manager, username)\n\t}\n\treturn changeAccess(c, manager, username, topic, perms)\n}\n\nfunc changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error {\n\tif !util.Contains([]string{\"\", \"read-write\", \"rw\", \"read-only\", \"read\", \"ro\", \"write-only\", \"write\", \"wo\", \"none\", \"deny\"}, perms) {\n\t\treturn errors.New(\"permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)\")\n\t}\n\tpermission, err := user.ParsePermission(perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := manager.User(username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t} else if err != nil {\n\t\treturn err\n\t} else if u.Role == user.RoleAdmin {\n\t\treturn fmt.Errorf(\"user %s is an admin user, access control entries have no effect\", username)\n\t}\n\tif err := manager.AllowAccess(username, topic, permission); err != nil {\n\t\treturn err\n\t}\n\tif permission.IsReadWrite() {\n\t\tfmt.Fprintf(c.App.Writer, \"granted read-write access to topic %s\\n\\n\", topic)\n\t} else if permission.IsRead() {\n\t\tfmt.Fprintf(c.App.Writer, \"granted read-only access to topic %s\\n\\n\", topic)\n\t} else if permission.IsWrite() {\n\t\tfmt.Fprintf(c.App.Writer, \"granted write-only access to topic %s\\n\\n\", topic)\n\t} else {\n\t\tfmt.Fprintf(c.App.Writer, \"revoked all access to topic %s\\n\\n\", topic)\n\t}\n\treturn showUserAccess(c, manager, username)\n}\n\nfunc resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error {\n\tif username == \"\" {\n\t\treturn resetAllAccess(c, manager)\n\t} else if topic == \"\" {\n\t\treturn resetUserAccess(c, manager, username)\n\t}\n\treturn resetUserTopicAccess(c, manager, username, topic)\n}\n\nfunc resetAllAccess(c *cli.Context, manager *user.Manager) error {\n\tif err := manager.ResetAccess(\"\", \"\"); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintln(c.App.Writer, \"reset access for all users\")\n\treturn nil\n}\n\nfunc resetUserAccess(c *cli.Context, manager *user.Manager, username string) error {\n\tif err := manager.ResetAccess(username, \"\"); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"reset access for user %s\\n\\n\", username)\n\treturn showUserAccess(c, manager, username)\n}\n\nfunc resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error {\n\tif err := manager.ResetAccess(username, topic); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"reset access for user %s and topic %s\\n\\n\", username, topic)\n\treturn showUserAccess(c, manager, username)\n}\n\nfunc showAccess(c *cli.Context, manager *user.Manager, username string) error {\n\tif username == \"\" {\n\t\treturn showAllAccess(c, manager)\n\t}\n\treturn showUserAccess(c, manager, username)\n}\n\nfunc showAllAccess(c *cli.Context, manager *user.Manager) error {\n\tusers, err := manager.Users()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn showUsers(c, manager, users)\n}\n\nfunc showUserAccess(c *cli.Context, manager *user.Manager, username string) error {\n\tusers, err := manager.User(username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t} else if err != nil {\n\t\treturn err\n\t}\n\treturn showUsers(c, manager, []*user.User{users})\n}\n\nfunc showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error {\n\tfor _, u := range users {\n\t\tgrants, err := manager.Grants(u.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttier := \"none\"\n\t\tif u.Tier != nil {\n\t\t\ttier = u.Tier.Name\n\t\t}\n\t\tprovisioned := \"\"\n\t\tif u.Provisioned {\n\t\t\tprovisioned = \", server config\"\n\t\t}\n\t\tfmt.Fprintf(c.App.Writer, \"user %s (role: %s, tier: %s%s)\\n\", u.Name, u.Role, tier, provisioned)\n\t\tif u.Role == user.RoleAdmin {\n\t\t\tfmt.Fprintf(c.App.Writer, \"- read-write access to all topics (admin role)\\n\")\n\t\t} else if len(grants) > 0 {\n\t\t\tfor _, grant := range grants {\n\t\t\t\tgrantProvisioned := \"\"\n\t\t\t\tif grant.Provisioned {\n\t\t\t\t\tgrantProvisioned = \" (server config)\"\n\t\t\t\t}\n\t\t\t\tif grant.Permission.IsReadWrite() {\n\t\t\t\t\tfmt.Fprintf(c.App.Writer, \"- read-write access to topic %s%s\\n\", grant.TopicPattern, grantProvisioned)\n\t\t\t\t} else if grant.Permission.IsRead() {\n\t\t\t\t\tfmt.Fprintf(c.App.Writer, \"- read-only access to topic %s%s\\n\", grant.TopicPattern, grantProvisioned)\n\t\t\t\t} else if grant.Permission.IsWrite() {\n\t\t\t\t\tfmt.Fprintf(c.App.Writer, \"- write-only access to topic %s%s\\n\", grant.TopicPattern, grantProvisioned)\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Fprintf(c.App.Writer, \"- no access to topic %s%s\\n\", grant.TopicPattern, grantProvisioned)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tfmt.Fprintf(c.App.Writer, \"- no topic-specific permissions\\n\")\n\t\t}\n\t\tif u.Name == user.Everyone {\n\t\t\taccess := manager.DefaultAccess()\n\t\t\tif access.IsReadWrite() {\n\t\t\t\tfmt.Fprintln(c.App.Writer, \"- read-write access to all (other) topics (server config)\")\n\t\t\t} else if access.IsRead() {\n\t\t\t\tfmt.Fprintln(c.App.Writer, \"- read-only access to all (other) topics (server config)\")\n\t\t\t} else if access.IsWrite() {\n\t\t\t\tfmt.Fprintln(c.App.Writer, \"- write-only access to all (other) topics (server config)\")\n\t\t\t} else {\n\t\t\t\tfmt.Fprintln(c.App.Writer, \"- no access to any (other) topics (server config)\")\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/access_test.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"testing\"\n)\n\nfunc TestCLI_Access_Show(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, runAccessCommand(app, conf))\n\trequire.Contains(t, stdout.String(), \"user * (role: anonymous, tier: none)\\n- no topic-specific permissions\\n- no access to any (other) topics (server config)\")\n}\n\nfunc TestCLI_Access_Grant_And_Publish(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, _, _ := newTestApp()\n\tstdin.WriteString(\"philpass\\nphilpass\\nbenpass\\nbenpass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"--role=admin\", \"phil\"))\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"ben\"))\n\trequire.Nil(t, runAccessCommand(app, conf, \"ben\", \"announcements\", \"rw\"))\n\trequire.Nil(t, runAccessCommand(app, conf, \"ben\", \"sometopic\", \"read\"))\n\trequire.Nil(t, runAccessCommand(app, conf, \"everyone\", \"announcements\", \"read\"))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, runAccessCommand(app, conf))\n\texpected := `user phil (role: admin, tier: none)\n- read-write access to all topics (admin role)\nuser ben (role: user, tier: none)\n- read-write access to topic announcements\n- read-only access to topic sometopic\nuser * (role: anonymous, tier: none)\n- read-only access to topic announcements\n- no access to any (other) topics (server config)\n`\n\trequire.Equal(t, expected, stdout.String())\n\n\t// See if access permissions match\n\tapp, _, _, _ = newTestApp()\n\trequire.Error(t, app.Run([]string{\n\t\t\"ntfy\",\n\t\t\"publish\",\n\t\tfmt.Sprintf(\"http://127.0.0.1:%d/announcements\", port),\n\t}))\n\trequire.Nil(t, app.Run([]string{\n\t\t\"ntfy\",\n\t\t\"publish\",\n\t\t\"-u\", \"ben:benpass\",\n\t\tfmt.Sprintf(\"http://127.0.0.1:%d/announcements\", port),\n\t}))\n\trequire.Nil(t, app.Run([]string{\n\t\t\"ntfy\",\n\t\t\"publish\",\n\t\t\"-u\", \"phil:philpass\",\n\t\tfmt.Sprintf(\"http://127.0.0.1:%d/announcements\", port),\n\t}))\n\trequire.Nil(t, app.Run([]string{\n\t\t\"ntfy\",\n\t\t\"subscribe\",\n\t\t\"--poll\",\n\t\tfmt.Sprintf(\"http://127.0.0.1:%d/announcements\", port),\n\t}))\n\trequire.Error(t, app.Run([]string{\n\t\t\"ntfy\",\n\t\t\"subscribe\",\n\t\t\"--poll\",\n\t\tfmt.Sprintf(\"http://127.0.0.1:%d/something-else\", port),\n\t}))\n}\n\nfunc runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {\n\tuserArgs := []string{\n\t\t\"ntfy\",\n\t\t\"--log-level=ERROR\",\n\t\t\"access\",\n\t\t\"--config=\" + conf.File, // Dummy config file to avoid lookups of real file\n\t\t\"--auth-file=\" + conf.AuthFile,\n\t\t\"--auth-default-access=\" + conf.AuthDefault.String(),\n\t}\n\treturn app.Run(append(userArgs, args...))\n}\n"
  },
  {
    "path": "cmd/app.go",
    "content": "// Package cmd provides the ntfy CLI application\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"regexp\"\n\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"heckel.io/ntfy/v2/log\"\n)\n\nconst (\n\tcategoryClient = \"Client commands\"\n\tcategoryServer = \"Server commands\"\n)\n\n// Build metadata keys for app.Metadata\nconst (\n\tMetadataKeyCommit = \"commit\"\n\tMetadataKeyDate   = \"date\"\n)\n\nvar commands = make([]*cli.Command, 0)\n\nvar flagsDefault = []cli.Flag{\n\t&cli.BoolFlag{Name: \"debug\", Aliases: []string{\"d\"}, EnvVars: []string{\"NTFY_DEBUG\"}, Usage: \"enable debug logging\"},\n\t&cli.BoolFlag{Name: \"trace\", EnvVars: []string{\"NTFY_TRACE\"}, Usage: \"enable tracing (very verbose, be careful)\"},\n\t&cli.BoolFlag{Name: \"no-log-dates\", Aliases: []string{\"no_log_dates\"}, EnvVars: []string{\"NTFY_NO_LOG_DATES\"}, Usage: \"disable the date/time prefix\"},\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"log-level\", Aliases: []string{\"log_level\"}, Value: log.InfoLevel.String(), EnvVars: []string{\"NTFY_LOG_LEVEL\"}, Usage: \"set log level\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"log-level-overrides\", Aliases: []string{\"log_level_overrides\"}, EnvVars: []string{\"NTFY_LOG_LEVEL_OVERRIDES\"}, Usage: \"set log level overrides\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"log-format\", Aliases: []string{\"log_format\"}, Value: log.TextFormat.String(), EnvVars: []string{\"NTFY_LOG_FORMAT\"}, Usage: \"set log format\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"log-file\", Aliases: []string{\"log_file\"}, EnvVars: []string{\"NTFY_LOG_FILE\"}, Usage: \"set log file, default is STDOUT\"}),\n}\n\nvar (\n\tlogLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\\s]+)(?:\\s*=\\s*(\\S+))?\\s*->\\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)\n)\n\n// New creates a new CLI application\nfunc New() *cli.App {\n\treturn &cli.App{\n\t\tName:                   \"ntfy\",\n\t\tUsage:                  \"Simple pub-sub notification service\",\n\t\tUsageText:              \"ntfy [OPTION..]\",\n\t\tHideVersion:            true,\n\t\tUseShortOptionHandling: true,\n\t\tReader:                 os.Stdin,\n\t\tWriter:                 os.Stdout,\n\t\tErrWriter:              os.Stderr,\n\t\tCommands:               commands,\n\t\tFlags:                  flagsDefault,\n\t\tBefore:                 initLogFunc,\n\t}\n}\n\nfunc initLogFunc(c *cli.Context) error {\n\tlog.SetLevel(log.ToLevel(c.String(\"log-level\")))\n\tlog.SetFormat(log.ToFormat(c.String(\"log-format\")))\n\tif c.Bool(\"trace\") {\n\t\tlog.SetLevel(log.TraceLevel)\n\t} else if c.Bool(\"debug\") {\n\t\tlog.SetLevel(log.DebugLevel)\n\t}\n\tif c.Bool(\"no-log-dates\") {\n\t\tlog.DisableDates()\n\t}\n\tif err := applyLogLevelOverrides(c.StringSlice(\"log-level-overrides\")); err != nil {\n\t\treturn err\n\t}\n\tlogFile := c.String(\"log-file\")\n\tif logFile != \"\" {\n\t\tw, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlog.SetOutput(w)\n\t}\n\treturn nil\n}\n\nfunc applyLogLevelOverrides(rawOverrides []string) error {\n\tfor _, override := range rawOverrides {\n\t\tm := logLevelOverrideRegex.FindStringSubmatch(override)\n\t\tif len(m) == 4 {\n\t\t\tfield, value, level := m[1], m[2], m[3]\n\t\t\tlog.SetLevelOverride(field, value, log.ToLevel(level))\n\t\t} else if len(m) == 3 {\n\t\t\tfield, level := m[1], m[2]\n\t\t\tlog.SetLevelOverride(field, \"\", log.ToLevel(level)) // Matches any value\n\t\t} else {\n\t\t\treturn fmt.Errorf(`invalid log level override \"%s\", must be \"field=value -> loglevel\", e.g. \"user_id=u_123 -> DEBUG\"`, override)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/app_test.go",
    "content": "package cmd\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// This only contains helpers so far\n\nfunc TestMain(m *testing.M) {\n\tlog.SetLevel(log.ErrorLevel)\n\tos.Exit(m.Run())\n}\n\nfunc newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {\n\tvar stdin, stdout, stderr bytes.Buffer\n\tapp := New()\n\tapp.Reader = &stdin\n\tapp.Writer = &stdout\n\tapp.ErrWriter = &stderr\n\treturn app, &stdin, &stdout, &stderr\n}\n\nfunc toMessage(t *testing.T, s string) *client.Message {\n\tvar m *client.Message\n\tif err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn m\n}\n"
  },
  {
    "path": "cmd/config_loader.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"gopkg.in/yaml.v2\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"os\"\n)\n\n// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks\n// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.\nfunc initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc {\n\treturn func(context *cli.Context) error {\n\t\tconfigFile := context.String(configFlag)\n\t\tif context.IsSet(configFlag) && !util.FileExists(configFile) {\n\t\t\treturn fmt.Errorf(\"config file %s does not exist\", configFile)\n\t\t} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {\n\t\t\treturn nil\n\t\t}\n\t\tinputSource, err := newYamlSourceFromFile(configFile, flags)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif next != nil {\n\t\t\tif err := next(context); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n}\n\n// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath.\n//\n// This function also maps aliases, so a .yml file can contain short options, or options with underscores\n// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.\nfunc newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {\n\tvar rawConfig map[any]any\n\tb, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := yaml.Unmarshal(b, &rawConfig); err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, f := range flags {\n\t\tflagName := f.Names()[0]\n\t\tfor _, flagAlias := range f.Names()[1:] {\n\t\t\tif _, ok := rawConfig[flagAlias]; ok {\n\t\t\t\trawConfig[flagName] = rawConfig[flagAlias]\n\t\t\t}\n\t\t}\n\t}\n\treturn altsrc.NewMapInputSource(file, rawConfig), nil\n}\n"
  },
  {
    "path": "cmd/config_loader_test.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestNewYamlSourceFromFile(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"server.yml\")\n\tcontents := `\n# Normal options\nlisten-https: \":10443\"\n\n# Note the underscore!\nlisten_http: \":1080\"\n\n# OMG this is allowed now ...\nK: /some/file.pem\n`\n\trequire.Nil(t, os.WriteFile(filename, []byte(contents), 0600))\n\n\tctx, err := newYamlSourceFromFile(filename, flagsServe)\n\trequire.Nil(t, err)\n\n\tlistenHTTPS, err := ctx.String(\"listen-https\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, \":10443\", listenHTTPS)\n\n\tlistenHTTP, err := ctx.String(\"listen-http\") // No underscore!\n\trequire.Nil(t, err)\n\trequire.Equal(t, \":1080\", listenHTTP)\n\n\tkeyFile, err := ctx.String(\"key-file\") // Long option!\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"/some/file.pem\", keyFile)\n}\n"
  },
  {
    "path": "cmd/publish.go",
    "content": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdPublish)\n}\n\nvar flagsPublish = append(\n\tappend([]cli.Flag{}, flagsDefault...),\n\t&cli.StringFlag{Name: \"config\", Aliases: []string{\"c\"}, EnvVars: []string{\"NTFY_CONFIG\"}, Usage: \"client config file\"},\n\t&cli.StringFlag{Name: \"title\", Aliases: []string{\"t\"}, EnvVars: []string{\"NTFY_TITLE\"}, Usage: \"message title\"},\n\t&cli.StringFlag{Name: \"message\", Aliases: []string{\"m\"}, EnvVars: []string{\"NTFY_MESSAGE\"}, Usage: \"message body\"},\n\t&cli.StringFlag{Name: \"priority\", Aliases: []string{\"p\"}, EnvVars: []string{\"NTFY_PRIORITY\"}, Usage: \"priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)\"},\n\t&cli.StringFlag{Name: \"tags\", Aliases: []string{\"tag\", \"T\"}, EnvVars: []string{\"NTFY_TAGS\"}, Usage: \"comma separated list of tags and emojis\"},\n\t&cli.StringFlag{Name: \"delay\", Aliases: []string{\"at\", \"in\", \"D\"}, EnvVars: []string{\"NTFY_DELAY\"}, Usage: \"delay/schedule message\"},\n\t&cli.StringFlag{Name: \"click\", Aliases: []string{\"U\"}, EnvVars: []string{\"NTFY_CLICK\"}, Usage: \"URL to open when notification is clicked\"},\n\t&cli.StringFlag{Name: \"icon\", Aliases: []string{\"i\"}, EnvVars: []string{\"NTFY_ICON\"}, Usage: \"URL to use as notification icon\"},\n\t&cli.StringFlag{Name: \"actions\", Aliases: []string{\"A\"}, EnvVars: []string{\"NTFY_ACTIONS\"}, Usage: \"actions JSON array or simple definition\"},\n\t&cli.StringFlag{Name: \"attach\", Aliases: []string{\"a\"}, EnvVars: []string{\"NTFY_ATTACH\"}, Usage: \"URL to send as an external attachment\"},\n\t&cli.BoolFlag{Name: \"markdown\", Aliases: []string{\"md\"}, EnvVars: []string{\"NTFY_MARKDOWN\"}, Usage: \"Message is formatted as Markdown\"},\n\t&cli.StringFlag{Name: \"template\", Aliases: []string{\"tpl\"}, EnvVars: []string{\"NTFY_TEMPLATE\"}, Usage: \"use templates to transform JSON message body\"},\n\t&cli.StringFlag{Name: \"filename\", Aliases: []string{\"name\", \"n\"}, EnvVars: []string{\"NTFY_FILENAME\"}, Usage: \"filename for the attachment\"},\n\t&cli.StringFlag{Name: \"sequence-id\", Aliases: []string{\"sequence_id\", \"sid\", \"S\"}, EnvVars: []string{\"NTFY_SEQUENCE_ID\"}, Usage: \"sequence ID for updating notifications\"},\n\t&cli.StringFlag{Name: \"file\", Aliases: []string{\"f\"}, EnvVars: []string{\"NTFY_FILE\"}, Usage: \"file to upload as an attachment\"},\n\t&cli.StringFlag{Name: \"email\", Aliases: []string{\"mail\", \"e\"}, EnvVars: []string{\"NTFY_EMAIL\"}, Usage: \"also send to e-mail address\"},\n\t&cli.StringFlag{Name: \"user\", Aliases: []string{\"u\"}, EnvVars: []string{\"NTFY_USER\"}, Usage: \"username[:password] used to auth against the server\"},\n\t&cli.StringFlag{Name: \"token\", Aliases: []string{\"k\"}, EnvVars: []string{\"NTFY_TOKEN\"}, Usage: \"access token used to auth against the server\"},\n\t&cli.IntFlag{Name: \"wait-pid\", Aliases: []string{\"wait_pid\", \"pid\"}, EnvVars: []string{\"NTFY_WAIT_PID\"}, Usage: \"wait until PID exits before publishing\"},\n\t&cli.BoolFlag{Name: \"wait-cmd\", Aliases: []string{\"wait_cmd\", \"cmd\", \"done\"}, EnvVars: []string{\"NTFY_WAIT_CMD\"}, Usage: \"run command and wait until it finishes before publishing\"},\n\t&cli.BoolFlag{Name: \"no-cache\", Aliases: []string{\"no_cache\", \"C\"}, EnvVars: []string{\"NTFY_NO_CACHE\"}, Usage: \"do not cache message server-side\"},\n\t&cli.BoolFlag{Name: \"no-firebase\", Aliases: []string{\"no_firebase\", \"F\"}, EnvVars: []string{\"NTFY_NO_FIREBASE\"}, Usage: \"do not forward message to Firebase\"},\n\t&cli.BoolFlag{Name: \"quiet\", Aliases: []string{\"q\"}, EnvVars: []string{\"NTFY_QUIET\"}, Usage: \"do not print message\"},\n)\n\nvar cmdPublish = &cli.Command{\n\tName:    \"publish\",\n\tAliases: []string{\"pub\", \"send\", \"trigger\"},\n\tUsage:   \"Send message via a ntfy server\",\n\tUsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]\nntfy publish [OPTIONS..] --wait-cmd COMMAND...\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`,\n\tAction:   execPublish,\n\tCategory: categoryClient,\n\tFlags:    flagsPublish,\n\tBefore:   initLogFunc,\n\tDescription: `Publish a message to a ntfy server.\n\nExamples:\n  ntfy publish mytopic This is my message                 # Send simple message\n  ntfy send myserver.com/mytopic \"This is my message\"     # Send message to different default host\n  ntfy pub -p high backups \"Backups failed\"               # Send high priority message\n  ntfy pub --tags=warning,skull backups \"Backups failed\"  # Add tags/emojis to message\n  ntfy pub --delay=10s delayed_topic Laterzz              # Delay message by 10s\n  ntfy pub --at=8:30am delayed_topic Laterzz              # Send message at 8:30am\n  ntfy pub -e phil@example.com alerts 'App is down!'      # Also send email to phil@example.com\n  ntfy pub --click=\"https://reddit.com\" redd 'New msg'    # Opens Reddit when notification is clicked\n  ntfy pub --icon=\"http://some.tld/icon.png\" 'Icon!'      # Send notification with custom icon\n  ntfy pub --attach=\"http://some.tld/file.zip\" files      # Send ZIP archive from URL as attachment\n  ntfy pub --file=flower.jpg flowers 'Nice!'              # Send image.jpg as attachment\n  ntfy pub -S my-id mytopic 'Update me'                   # Send with sequence ID for updates\n  echo 'message' | ntfy publish mytopic                   # Send message from stdin\n  ntfy pub -u phil:mypass secret Psst                     # Publish with username/password\n  ntfy pub --wait-pid 1234 mytopic                        # Wait for process 1234 to exit before publishing\n  ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a         # Run command and publish after it completes\n  NTFY_USER=phil:mypass ntfy pub secret Psst              # Use env variables to set username/password\n  NTFY_TOPIC=mytopic ntfy pub \"some message\"              # Use NTFY_TOPIC variable as topic \n  cat flower.jpg | ntfy pub --file=- flowers 'Nice!'      # Same as above, send image.jpg as attachment\n  ntfy trigger mywebhook                                  # Sending without message, useful for webhooks\n \nPlease also check out the docs on publishing messages. Especially for the --tags and --delay options, \nit has incredibly useful information: https://ntfy.sh/docs/publish/.\n\n` + clientCommandDescriptionSuffix,\n}\n\nfunc execPublish(c *cli.Context) error {\n\tconf, err := loadConfig(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttitle := c.String(\"title\")\n\tpriority := c.String(\"priority\")\n\ttags := c.String(\"tags\")\n\tdelay := c.String(\"delay\")\n\tclick := c.String(\"click\")\n\ticon := c.String(\"icon\")\n\tactions := c.String(\"actions\")\n\tattach := c.String(\"attach\")\n\tmarkdown := c.Bool(\"markdown\")\n\ttemplate := c.String(\"template\")\n\tfilename := c.String(\"filename\")\n\tsequenceID := c.String(\"sequence-id\")\n\tfile := c.String(\"file\")\n\temail := c.String(\"email\")\n\tuser := c.String(\"user\")\n\ttoken := c.String(\"token\")\n\tnoCache := c.Bool(\"no-cache\")\n\tnoFirebase := c.Bool(\"no-firebase\")\n\tquiet := c.Bool(\"quiet\")\n\tpid := c.Int(\"wait-pid\")\n\n\t// Checks\n\tif user != \"\" && token != \"\" {\n\t\treturn errors.New(\"cannot set both --user and --token\")\n\t}\n\n\t// Do the things\n\ttopic, message, command, err := parseTopicMessageCommand(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar options []client.PublishOption\n\tif title != \"\" {\n\t\toptions = append(options, client.WithTitle(title))\n\t}\n\tif priority != \"\" {\n\t\toptions = append(options, client.WithPriority(priority))\n\t}\n\tif tags != \"\" {\n\t\toptions = append(options, client.WithTagsList(tags))\n\t}\n\tif delay != \"\" {\n\t\toptions = append(options, client.WithDelay(delay))\n\t}\n\tif click != \"\" {\n\t\toptions = append(options, client.WithClick(click))\n\t}\n\tif icon != \"\" {\n\t\toptions = append(options, client.WithIcon(icon))\n\t}\n\tif actions != \"\" {\n\t\toptions = append(options, client.WithActions(strings.ReplaceAll(actions, \"\\n\", \" \")))\n\t}\n\tif attach != \"\" {\n\t\toptions = append(options, client.WithAttach(attach))\n\t}\n\tif markdown {\n\t\toptions = append(options, client.WithMarkdown())\n\t}\n\tif template != \"\" {\n\t\toptions = append(options, client.WithTemplate(template))\n\t}\n\tif filename != \"\" {\n\t\toptions = append(options, client.WithFilename(filename))\n\t}\n\tif sequenceID != \"\" {\n\t\toptions = append(options, client.WithSequenceID(sequenceID))\n\t}\n\tif email != \"\" {\n\t\toptions = append(options, client.WithEmail(email))\n\t}\n\tif noCache {\n\t\toptions = append(options, client.WithNoCache())\n\t}\n\tif noFirebase {\n\t\toptions = append(options, client.WithNoFirebase())\n\t}\n\tif token != \"\" {\n\t\toptions = append(options, client.WithBearerAuth(token))\n\t} else if user != \"\" {\n\t\tvar pass string\n\t\tparts := strings.SplitN(user, \":\", 2)\n\t\tif len(parts) == 2 {\n\t\t\tuser = parts[0]\n\t\t\tpass = parts[1]\n\t\t} else {\n\t\t\tfmt.Fprint(c.App.ErrWriter, \"Enter Password: \")\n\t\t\tp, err := util.ReadPassword(c.App.Reader)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tpass = string(p)\n\t\t\tfmt.Fprintf(c.App.ErrWriter, \"\\r%s\\r\", strings.Repeat(\" \", 20))\n\t\t}\n\t\toptions = append(options, client.WithBasicAuth(user, pass))\n\t} else if conf.DefaultToken != \"\" {\n\t\toptions = append(options, client.WithBearerAuth(conf.DefaultToken))\n\t} else if conf.DefaultUser != \"\" && conf.DefaultPassword != nil {\n\t\toptions = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))\n\t}\n\tif pid > 0 {\n\t\tnewMessage, err := waitForProcess(pid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if message == \"\" {\n\t\t\tmessage = newMessage\n\t\t}\n\t} else if len(command) > 0 {\n\t\tnewMessage, err := runAndWaitForCommand(command)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if message == \"\" {\n\t\t\tmessage = newMessage\n\t\t}\n\t}\n\tvar body io.Reader\n\tif file == \"\" {\n\t\tbody = strings.NewReader(message)\n\t} else {\n\t\tif message != \"\" {\n\t\t\toptions = append(options, client.WithMessage(message))\n\t\t}\n\t\tif file == \"-\" {\n\t\t\tif filename == \"\" {\n\t\t\t\toptions = append(options, client.WithFilename(\"stdin\"))\n\t\t\t}\n\t\t\tbody = c.App.Reader\n\t\t} else {\n\t\t\tif filename == \"\" {\n\t\t\t\toptions = append(options, client.WithFilename(filepath.Base(file)))\n\t\t\t}\n\t\t\tbody, err = os.Open(file)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\tcl := client.New(conf)\n\tm, err := cl.PublishReader(topic, body, options...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !quiet {\n\t\tfmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw))\n\t}\n\treturn nil\n}\n\n// parseTopicMessageCommand reads the topic and the remaining arguments from the context.\n\n// There are a few cases to consider:\n//\n//\tntfy publish <topic> [<message>]\n//\tntfy publish --wait-cmd <topic> <command>\n//\tNTFY_TOPIC=.. ntfy publish [<message>]\n//\tNTFY_TOPIC=.. ntfy publish --wait-cmd <command>\nfunc parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) {\n\tvar args []string\n\ttopic, args, err = parseTopicAndArgs(c)\n\tif err != nil {\n\t\treturn\n\t}\n\tif c.Bool(\"wait-cmd\") {\n\t\tif len(args) == 0 {\n\t\t\terr = errors.New(\"must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help\")\n\t\t\treturn\n\t\t}\n\t\tcommand = args\n\t} else {\n\t\tmessage = strings.Join(args, \" \")\n\t}\n\tif c.String(\"message\") != \"\" {\n\t\tmessage = c.String(\"message\")\n\t}\n\tif message == \"\" && isStdinRedirected() {\n\t\tvar data []byte\n\t\tdata, err = io.ReadAll(io.LimitReader(c.App.Reader, 1024*1024))\n\t\tif err != nil {\n\t\t\tlog.Debug(\"Failed to read from stdin: %s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tmessage = strings.TrimSpace(string(data))\n\t}\n\treturn\n}\n\nfunc parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) {\n\tenvTopic := os.Getenv(\"NTFY_TOPIC\")\n\tif envTopic != \"\" {\n\t\ttopic = envTopic\n\t\treturn topic, remainingArgs(c, 0), nil\n\t}\n\tif c.NArg() < 1 {\n\t\treturn \"\", nil, errors.New(\"must specify topic, type 'ntfy publish --help' for help\")\n\t}\n\treturn c.Args().Get(0), remainingArgs(c, 1), nil\n}\n\nfunc remainingArgs(c *cli.Context, fromIndex int) []string {\n\tif c.NArg() > fromIndex {\n\t\treturn c.Args().Slice()[fromIndex:]\n\t}\n\treturn []string{}\n}\n\nfunc waitForProcess(pid int) (message string, err error) {\n\tif !processExists(pid) {\n\t\treturn \"\", fmt.Errorf(\"process with PID %d not running\", pid)\n\t}\n\tstart := time.Now()\n\tlog.Debug(\"Waiting for process with PID %d to exit\", pid)\n\tfor processExists(pid) {\n\t\ttime.Sleep(500 * time.Millisecond)\n\t}\n\truntime := time.Since(start).Round(time.Millisecond)\n\tlog.Debug(\"Process with PID %d exited after %s\", pid, runtime)\n\treturn fmt.Sprintf(\"Process with PID %d exited after %s\", pid, runtime), nil\n}\n\nfunc runAndWaitForCommand(command []string) (message string, err error) {\n\tprettyCmd := util.QuoteCommand(command)\n\tlog.Debug(\"Running command: %s\", prettyCmd)\n\tstart := time.Now()\n\tcmd := exec.Command(command[0], command[1:]...)\n\tif log.IsTrace() {\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\t}\n\terr = cmd.Run()\n\truntime := time.Since(start).Round(time.Millisecond)\n\tif err != nil {\n\t\tif exitError, ok := err.(*exec.ExitError); ok {\n\t\t\tlog.Debug(\"Command failed after %s (exit code %d): %s\", runtime, exitError.ExitCode(), prettyCmd)\n\t\t\treturn fmt.Sprintf(\"Command failed after %s (exit code %d): %s\", runtime, exitError.ExitCode(), prettyCmd), nil\n\t\t}\n\t\t// Hard fail when command does not exist or could not be properly launched\n\t\treturn \"\", fmt.Errorf(\"command failed: %s, error: %s\", prettyCmd, err.Error())\n\t}\n\tlog.Debug(\"Command succeeded after %s: %s\", runtime, prettyCmd)\n\treturn fmt.Sprintf(\"Command succeeded after %s: %s\", runtime, prettyCmd), nil\n}\n\nfunc isStdinRedirected() bool {\n\tstat, err := os.Stdin.Stat()\n\tif err != nil {\n\t\tlog.Debug(\"Failed to stat stdin: %s\", err.Error())\n\t\treturn false\n\t}\n\treturn (stat.Mode() & os.ModeCharDevice) == 0\n}\n"
  },
  {
    "path": "cmd/publish_test.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {\n\tt.Skip(\"temporarily disabled\") // FIXME\n\ttestMessage := util.RandomString(10)\n\tapp, _, _, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"ntfytest\", \"ntfy unit test \" + testMessage}))\n\n\t_, err := util.Retry(func() (*int, error) {\n\t\tapp2, _, stdout, _ := newTestApp()\n\t\tif err := app2.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"ntfytest\"}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif !strings.Contains(stdout.String(), testMessage) {\n\t\t\treturn nil, fmt.Errorf(\"test message %s not found in topic\", testMessage)\n\t\t}\n\t\treturn util.Int(1), nil\n\t}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s\n\trequire.Nil(t, err)\n}\n\nfunc TestCLI_Publish_Subscribe_Poll(t *testing.T) {\n\ts, port := test.StartServer(t)\n\tdefer test.StopServer(t, s, port)\n\ttopic := fmt.Sprintf(\"http://127.0.0.1:%d/mytopic\", port)\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", topic, \"some message\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"some message\", m.Message)\n\n\tapp2, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app2.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", topic}))\n\tm = toMessage(t, stdout.String())\n\trequire.Equal(t, \"some message\", m.Message)\n}\n\nfunc TestCLI_Publish_All_The_Things(t *testing.T) {\n\ts, port := test.StartServer(t)\n\tdefer test.StopServer(t, s, port)\n\ttopic := fmt.Sprintf(\"http://127.0.0.1:%d/mytopic\", port)\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\n\t\t\"ntfy\", \"publish\",\n\t\t\"--title\", \"this is a title\",\n\t\t\"--priority\", \"high\",\n\t\t\"--tags\", \"tag1,tag2\",\n\t\t// No --delay, --email\n\t\t\"--click\", \"https://ntfy.sh\",\n\t\t\"--icon\", \"https://ntfy.sh/static/img/ntfy.png\",\n\t\t\"--attach\", \"https://f-droid.org/F-Droid.apk\",\n\t\t\"--filename\", \"fdroid.apk\",\n\t\t\"--no-cache\",\n\t\t\"--no-firebase\",\n\t\ttopic,\n\t\t\"some message\",\n\t}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"message\", m.Event)\n\trequire.Equal(t, \"mytopic\", m.Topic)\n\trequire.Equal(t, \"some message\", m.Message)\n\trequire.Equal(t, \"this is a title\", m.Title)\n\trequire.Equal(t, 4, m.Priority)\n\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, m.Tags)\n\trequire.Equal(t, \"https://ntfy.sh\", m.Click)\n\trequire.Equal(t, \"https://f-droid.org/F-Droid.apk\", m.Attachment.URL)\n\trequire.Equal(t, \"fdroid.apk\", m.Attachment.Name)\n\trequire.Equal(t, int64(0), m.Attachment.Size)\n\trequire.Equal(t, \"\", m.Attachment.Owner)\n\trequire.Equal(t, int64(0), m.Attachment.Expires)\n\trequire.Equal(t, \"\", m.Attachment.Type)\n\trequire.Equal(t, \"https://ntfy.sh/static/img/ntfy.png\", m.Icon)\n}\n\nfunc TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {\n\ts, port := test.StartServer(t)\n\tdefer test.StopServer(t, s, port)\n\ttopic := fmt.Sprintf(\"http://127.0.0.1:%d/mytopic\", port)\n\n\t// Test: sleep 0.5\n\tsleep := exec.Command(\"sleep\", \"0.5\")\n\trequire.Nil(t, sleep.Start())\n\tgo sleep.Wait() // Must be called to release resources\n\tstart := time.Now()\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--wait-pid\", strconv.Itoa(sleep.Process.Pid), topic}))\n\tm := toMessage(t, stdout.String())\n\trequire.True(t, time.Since(start) >= 500*time.Millisecond)\n\trequire.Regexp(t, `Process with PID \\d+ exited after `, m.Message)\n\n\t// Test: PID does not exist\n\tapp, _, _, _ = newTestApp()\n\terr := app.Run([]string{\"ntfy\", \"publish\", \"--wait-pid\", \"1234567\", topic})\n\trequire.Error(t, err)\n\trequire.Equal(t, \"process with PID 1234567 not running\", err.Error())\n\n\t// Test: Successful command (exit 0)\n\tstart = time.Now()\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--wait-cmd\", topic, \"sleep\", \"0.5\"}))\n\tm = toMessage(t, stdout.String())\n\trequire.True(t, time.Since(start) >= 500*time.Millisecond)\n\trequire.Contains(t, m.Message, `Command succeeded after `)\n\trequire.Contains(t, m.Message, `: sleep 0.5`)\n\n\t// Test: Failing command (exit 1)\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--wait-cmd\", topic, \"/bin/false\", \"false doesn't care about its args\"}))\n\tm = toMessage(t, stdout.String())\n\trequire.Contains(t, m.Message, `Command failed after `)\n\trequire.Contains(t, m.Message, `(exit code 1): /bin/false \"false doesn't care about its args\"`, m.Message)\n\n\t// Test: Non-existing command (hard fail!)\n\tapp, _, _, _ = newTestApp()\n\terr = app.Run([]string{\"ntfy\", \"publish\", \"--wait-cmd\", topic, \"does-not-exist-no-really\", \"really though\"})\n\trequire.Error(t, err)\n\trequire.Equal(t, `command failed: does-not-exist-no-really \"really though\", error: exec: \"does-not-exist-no-really\": executable file not found in $PATH`, err.Error())\n\n\t// Tests with NTFY_TOPIC set ////\n\tt.Setenv(\"NTFY_TOPIC\", topic)\n\n\t// Test: Successful command with NTFY_TOPIC\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--cmd\", \"echo\", \"hi there\"}))\n\tm = toMessage(t, stdout.String())\n\trequire.Equal(t, \"mytopic\", m.Topic)\n\n\t// Test: Successful --wait-pid with NTFY_TOPIC\n\tsleep = exec.Command(\"sleep\", \"0.2\")\n\trequire.Nil(t, sleep.Start())\n\tgo sleep.Wait() // Must be called to release resources\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--wait-pid\", strconv.Itoa(sleep.Process.Pid)}))\n\tm = toMessage(t, stdout.String())\n\trequire.Regexp(t, `Process with PID \\d+ exited after .+ms`, m.Message)\n}\n\nfunc TestCLI_Publish_Default_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Default_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"--token\", \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"--user\", \"philipp:mypass\", \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_FAKETOKEN01234567890FAKETOKEN\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"--token\", \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: fakepass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"publish\", \"--config=\" + filename, \"--user\", \"philipp:mypass\", \"mytopic\", \"triggered\"}))\n\tm := toMessage(t, stdout.String())\n\trequire.Equal(t, \"triggered\", m.Message)\n}\n\nfunc TestCLI_Publish_Token_And_UserPass(t *testing.T) {\n\tapp, _, _, _ := newTestApp()\n\terr := app.Run([]string{\"ntfy\", \"publish\", \"--token\", \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", \"--user\", \"philipp:mypass\", \"mytopic\", \"triggered\"})\n\trequire.Error(t, err)\n\trequire.Equal(t, \"cannot set both --user and --token\", err.Error())\n}\n"
  },
  {
    "path": "cmd/publish_unix.go",
    "content": "//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd\n\npackage cmd\n\nimport \"syscall\"\n\nfunc processExists(pid int) bool {\n\terr := syscall.Kill(pid, syscall.Signal(0))\n\treturn err == nil\n}\n"
  },
  {
    "path": "cmd/publish_windows.go",
    "content": "package cmd\n\nimport (\n\t\"os\"\n)\n\nfunc processExists(pid int) bool {\n\t_, err := os.FindProcess(pid)\n\treturn err == nil\n}\n"
  },
  {
    "path": "cmd/serve.go",
    "content": "//go:build !noserver\n\npackage cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"math\"\n\t\"net\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"runtime\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/payments\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdServe)\n}\n\nvar flagsServe = append(\n\tappend([]cli.Flag{}, flagsDefault...),\n\t&cli.StringFlag{Name: \"config\", Aliases: []string{\"c\"}, EnvVars: []string{\"NTFY_CONFIG_FILE\"}, Value: server.DefaultConfigFile, Usage: \"config file\"},\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"base-url\", Aliases: []string{\"base_url\", \"B\"}, EnvVars: []string{\"NTFY_BASE_URL\"}, Usage: \"externally visible base URL for this host (e.g. https://ntfy.sh)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"listen-http\", Aliases: []string{\"listen_http\", \"l\"}, EnvVars: []string{\"NTFY_LISTEN_HTTP\"}, Value: server.DefaultListenHTTP, Usage: \"ip:port used as HTTP listen address\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"listen-https\", Aliases: []string{\"listen_https\", \"L\"}, EnvVars: []string{\"NTFY_LISTEN_HTTPS\"}, Usage: \"ip:port used as HTTPS listen address\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"listen-unix\", Aliases: []string{\"listen_unix\", \"U\"}, EnvVars: []string{\"NTFY_LISTEN_UNIX\"}, Usage: \"listen on unix socket path\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"listen-unix-mode\", Aliases: []string{\"listen_unix_mode\"}, EnvVars: []string{\"NTFY_LISTEN_UNIX_MODE\"}, DefaultText: \"system default\", Usage: \"file permissions of unix socket, e.g. 0700\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"key-file\", Aliases: []string{\"key_file\", \"K\"}, EnvVars: []string{\"NTFY_KEY_FILE\"}, Usage: \"private key file, if listen-https is set\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cert-file\", Aliases: []string{\"cert_file\", \"E\"}, EnvVars: []string{\"NTFY_CERT_FILE\"}, Usage: \"certificate file, if listen-https is set\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"firebase-key-file\", Aliases: []string{\"firebase_key_file\", \"F\"}, EnvVars: []string{\"NTFY_FIREBASE_KEY_FILE\"}, Usage: \"Firebase credentials file; if set additionally publish to FCM topic\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"database-url\", Aliases: []string{\"database_url\"}, EnvVars: []string{\"NTFY_DATABASE_URL\"}, Usage: \"PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"database-replica-urls\", Aliases: []string{\"database_replica_urls\"}, EnvVars: []string{\"NTFY_DATABASE_REPLICA_URLS\"}, Usage: \"PostgreSQL read replica connection strings for offloading read queries\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cache-file\", Aliases: []string{\"cache_file\", \"C\"}, EnvVars: []string{\"NTFY_CACHE_FILE\"}, Usage: \"cache file used for message caching\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cache-duration\", Aliases: []string{\"cache_duration\", \"b\"}, EnvVars: []string{\"NTFY_CACHE_DURATION\"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: \"buffer messages for this time to allow `since` requests\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"cache-batch-size\", Aliases: []string{\"cache_batch_size\"}, EnvVars: []string{\"NTFY_BATCH_SIZE\"}, Usage: \"max size of messages to batch together when writing to message cache (if zero, writes are synchronous)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cache-batch-timeout\", Aliases: []string{\"cache_batch_timeout\"}, EnvVars: []string{\"NTFY_CACHE_BATCH_TIMEOUT\"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: \"timeout for batched async writes to the message cache (if zero, writes are synchronous)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cache-startup-queries\", Aliases: []string{\"cache_startup_queries\"}, EnvVars: []string{\"NTFY_CACHE_STARTUP_QUERIES\"}, Usage: \"queries run when the cache database is initialized\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-file\", Aliases: []string{\"auth_file\", \"H\"}, EnvVars: []string{\"NTFY_AUTH_FILE\"}, Usage: \"auth database file used for access control\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-startup-queries\", Aliases: []string{\"auth_startup_queries\"}, EnvVars: []string{\"NTFY_AUTH_STARTUP_QUERIES\"}, Usage: \"queries run when the auth database is initialized\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-default-access\", Aliases: []string{\"auth_default_access\", \"p\"}, EnvVars: []string{\"NTFY_AUTH_DEFAULT_ACCESS\"}, Value: \"read-write\", Usage: \"default permissions if no matching entries in the auth database are found\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"auth-users\", Aliases: []string{\"auth_users\"}, EnvVars: []string{\"NTFY_AUTH_USERS\"}, Usage: \"pre-provisioned declarative users\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"auth-access\", Aliases: []string{\"auth_access\"}, EnvVars: []string{\"NTFY_AUTH_ACCESS\"}, Usage: \"pre-provisioned declarative access control entries\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"auth-tokens\", Aliases: []string{\"auth_tokens\"}, EnvVars: []string{\"NTFY_AUTH_TOKENS\"}, Usage: \"pre-provisioned declarative access tokens\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"attachment-cache-dir\", Aliases: []string{\"attachment_cache_dir\"}, EnvVars: []string{\"NTFY_ATTACHMENT_CACHE_DIR\"}, Usage: \"cache directory for attached files\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"attachment-total-size-limit\", Aliases: []string{\"attachment_total_size_limit\", \"A\"}, EnvVars: []string{\"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT\"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: \"limit of the on-disk attachment cache\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"attachment-file-size-limit\", Aliases: []string{\"attachment_file_size_limit\", \"Y\"}, EnvVars: []string{\"NTFY_ATTACHMENT_FILE_SIZE_LIMIT\"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: \"per-file attachment size limit (e.g. 300k, 2M, 100M)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"attachment-expiry-duration\", Aliases: []string{\"attachment_expiry_duration\", \"X\"}, EnvVars: []string{\"NTFY_ATTACHMENT_EXPIRY_DURATION\"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: \"duration after which uploaded attachments will be deleted (e.g. 3h, 20h)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"template-dir\", Aliases: []string{\"template_dir\"}, EnvVars: []string{\"NTFY_TEMPLATE_DIR\"}, Value: server.DefaultTemplateDir, Usage: \"directory to load named message templates from\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"keepalive-interval\", Aliases: []string{\"keepalive_interval\", \"k\"}, EnvVars: []string{\"NTFY_KEEPALIVE_INTERVAL\"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: \"interval of keepalive messages\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"manager-interval\", Aliases: []string{\"manager_interval\", \"m\"}, EnvVars: []string{\"NTFY_MANAGER_INTERVAL\"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: \"interval of for message pruning and stats printing\"}),\n\taltsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: \"disallowed-topics\", Aliases: []string{\"disallowed_topics\"}, EnvVars: []string{\"NTFY_DISALLOWED_TOPICS\"}, Usage: \"topics that are not allowed to be used\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-root\", Aliases: []string{\"web_root\"}, EnvVars: []string{\"NTFY_WEB_ROOT\"}, Value: \"/\", Usage: \"sets root of the web app (e.g. /, or /app), or disables it (disable)\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"enable-signup\", Aliases: []string{\"enable_signup\"}, EnvVars: []string{\"NTFY_ENABLE_SIGNUP\"}, Value: false, Usage: \"allows users to sign up via the web app, or API\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"enable-login\", Aliases: []string{\"enable_login\"}, EnvVars: []string{\"NTFY_ENABLE_LOGIN\"}, Value: false, Usage: \"allows users to log in via the web app, or API\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"enable-reservations\", Aliases: []string{\"enable_reservations\"}, EnvVars: []string{\"NTFY_ENABLE_RESERVATIONS\"}, Value: false, Usage: \"allows users to reserve topics (if their tier allows it)\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"require-login\", Aliases: []string{\"require_login\"}, EnvVars: []string{\"NTFY_REQUIRE_LOGIN\"}, Value: false, Usage: \"all actions via the web app requires a login\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"upstream-base-url\", Aliases: []string{\"upstream_base_url\"}, EnvVars: []string{\"NTFY_UPSTREAM_BASE_URL\"}, Value: \"\", Usage: \"forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"upstream-access-token\", Aliases: []string{\"upstream_access_token\"}, EnvVars: []string{\"NTFY_UPSTREAM_ACCESS_TOKEN\"}, Value: \"\", Usage: \"access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-sender-addr\", Aliases: []string{\"smtp_sender_addr\"}, EnvVars: []string{\"NTFY_SMTP_SENDER_ADDR\"}, Usage: \"SMTP server address (host:port) for outgoing emails\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-sender-user\", Aliases: []string{\"smtp_sender_user\"}, EnvVars: []string{\"NTFY_SMTP_SENDER_USER\"}, Usage: \"SMTP user (if e-mail sending is enabled)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-sender-pass\", Aliases: []string{\"smtp_sender_pass\"}, EnvVars: []string{\"NTFY_SMTP_SENDER_PASS\"}, Usage: \"SMTP password (if e-mail sending is enabled)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-sender-from\", Aliases: []string{\"smtp_sender_from\"}, EnvVars: []string{\"NTFY_SMTP_SENDER_FROM\"}, Usage: \"SMTP sender address (if e-mail sending is enabled)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-server-listen\", Aliases: []string{\"smtp_server_listen\"}, EnvVars: []string{\"NTFY_SMTP_SERVER_LISTEN\"}, Usage: \"SMTP server address (ip:port) for incoming emails, e.g. :25\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-server-domain\", Aliases: []string{\"smtp_server_domain\"}, EnvVars: []string{\"NTFY_SMTP_SERVER_DOMAIN\"}, Usage: \"SMTP domain for incoming e-mail, e.g. ntfy.sh\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"smtp-server-addr-prefix\", Aliases: []string{\"smtp_server_addr_prefix\"}, EnvVars: []string{\"NTFY_SMTP_SERVER_ADDR_PREFIX\"}, Usage: \"SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"twilio-account\", Aliases: []string{\"twilio_account\"}, EnvVars: []string{\"NTFY_TWILIO_ACCOUNT\"}, Usage: \"Twilio account SID, used for phone calls, e.g. AC123...\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"twilio-auth-token\", Aliases: []string{\"twilio_auth_token\"}, EnvVars: []string{\"NTFY_TWILIO_AUTH_TOKEN\"}, Usage: \"Twilio auth token\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"twilio-phone-number\", Aliases: []string{\"twilio_phone_number\"}, EnvVars: []string{\"NTFY_TWILIO_PHONE_NUMBER\"}, Usage: \"Twilio number to use for outgoing calls\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"twilio-verify-service\", Aliases: []string{\"twilio_verify_service\"}, EnvVars: []string{\"NTFY_TWILIO_VERIFY_SERVICE\"}, Usage: \"Twilio Verify service ID, used for phone number verification\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"twilio-call-format\", Aliases: []string{\"twilio_call_format\"}, EnvVars: []string{\"NTFY_TWILIO_CALL_FORMAT\"}, Usage: \"Twilio/TwiML format string for phone calls\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"message-size-limit\", Aliases: []string{\"message_size_limit\"}, EnvVars: []string{\"NTFY_MESSAGE_SIZE_LIMIT\"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: \"size limit for the message (see docs for limitations)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"message-delay-limit\", Aliases: []string{\"message_delay_limit\"}, EnvVars: []string{\"NTFY_MESSAGE_DELAY_LIMIT\"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: \"max duration a message can be scheduled into the future\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"global-topic-limit\", Aliases: []string{\"global_topic_limit\", \"T\"}, EnvVars: []string{\"NTFY_GLOBAL_TOPIC_LIMIT\"}, Value: server.DefaultTotalTopicLimit, Usage: \"total number of topics allowed\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-subscription-limit\", Aliases: []string{\"visitor_subscription_limit\"}, EnvVars: []string{\"NTFY_VISITOR_SUBSCRIPTION_LIMIT\"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: \"number of subscriptions per visitor\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"visitor-subscriber-rate-limiting\", Aliases: []string{\"visitor_subscriber_rate_limiting\"}, EnvVars: []string{\"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING\"}, Value: false, Usage: \"enables subscriber-based rate limiting\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"visitor-attachment-total-size-limit\", Aliases: []string{\"visitor_attachment_total_size_limit\"}, EnvVars: []string{\"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT\"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: \"total storage limit used for attachments per visitor\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"visitor-attachment-daily-bandwidth-limit\", Aliases: []string{\"visitor_attachment_daily_bandwidth_limit\"}, EnvVars: []string{\"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT\"}, Value: \"500M\", Usage: \"total daily attachment download/upload bandwidth limit per visitor\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-request-limit-burst\", Aliases: []string{\"visitor_request_limit_burst\"}, EnvVars: []string{\"NTFY_VISITOR_REQUEST_LIMIT_BURST\"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: \"initial limit of requests per visitor\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"visitor-request-limit-replenish\", Aliases: []string{\"visitor_request_limit_replenish\"}, EnvVars: []string{\"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH\"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: \"interval at which burst limit is replenished (one per x)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"visitor-request-limit-exempt-hosts\", Aliases: []string{\"visitor_request_limit_exempt_hosts\"}, EnvVars: []string{\"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS\"}, Value: \"\", Usage: \"hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-message-daily-limit\", Aliases: []string{\"visitor_message_daily_limit\"}, EnvVars: []string{\"NTFY_VISITOR_MESSAGE_DAILY_LIMIT\"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: \"max messages per visitor per day, derived from request limit if unset\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-email-limit-burst\", Aliases: []string{\"visitor_email_limit_burst\"}, EnvVars: []string{\"NTFY_VISITOR_EMAIL_LIMIT_BURST\"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: \"initial limit of e-mails per visitor\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"visitor-email-limit-replenish\", Aliases: []string{\"visitor_email_limit_replenish\"}, EnvVars: []string{\"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH\"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: \"interval at which burst limit is replenished (one per x)\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-prefix-bits-ipv4\", Aliases: []string{\"visitor_prefix_bits_ipv4\"}, EnvVars: []string{\"NTFY_VISITOR_PREFIX_BITS_IPV4\"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: \"number of bits of the IPv4 address to use for rate limiting (default: 32, full address)\"}),\n\taltsrc.NewIntFlag(&cli.IntFlag{Name: \"visitor-prefix-bits-ipv6\", Aliases: []string{\"visitor_prefix_bits_ipv6\"}, EnvVars: []string{\"NTFY_VISITOR_PREFIX_BITS_IPV6\"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: \"number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"behind-proxy\", Aliases: []string{\"behind_proxy\", \"P\"}, EnvVars: []string{\"NTFY_BEHIND_PROXY\"}, Value: false, Usage: \"if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"proxy-forwarded-header\", Aliases: []string{\"proxy_forwarded_header\"}, EnvVars: []string{\"NTFY_PROXY_FORWARDED_HEADER\"}, Value: \"X-Forwarded-For\", Usage: \"use specified header to determine visitor IP address (for rate limiting)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"proxy-trusted-hosts\", Aliases: []string{\"proxy_trusted_hosts\"}, EnvVars: []string{\"NTFY_PROXY_TRUSTED_HOSTS\"}, Value: \"\", Usage: \"comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"stripe-secret-key\", Aliases: []string{\"stripe_secret_key\"}, EnvVars: []string{\"NTFY_STRIPE_SECRET_KEY\"}, Value: \"\", Usage: \"key used for the Stripe API communication, this enables payments\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"stripe-webhook-key\", Aliases: []string{\"stripe_webhook_key\"}, EnvVars: []string{\"NTFY_STRIPE_WEBHOOK_KEY\"}, Value: \"\", Usage: \"key required to validate the authenticity of incoming webhooks from Stripe\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"billing-contact\", Aliases: []string{\"billing_contact\"}, EnvVars: []string{\"NTFY_BILLING_CONTACT\"}, Value: \"\", Usage: \"e-mail or website to display in upgrade dialog (only if payments are enabled)\"}),\n\taltsrc.NewBoolFlag(&cli.BoolFlag{Name: \"enable-metrics\", Aliases: []string{\"enable_metrics\"}, EnvVars: []string{\"NTFY_ENABLE_METRICS\"}, Value: false, Usage: \"if set, Prometheus metrics are exposed via the /metrics endpoint\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"metrics-listen-http\", Aliases: []string{\"metrics_listen_http\"}, EnvVars: []string{\"NTFY_METRICS_LISTEN_HTTP\"}, Usage: \"ip:port used to expose the metrics endpoint (implicitly enables metrics)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"profile-listen-http\", Aliases: []string{\"profile_listen_http\"}, EnvVars: []string{\"NTFY_PROFILE_LISTEN_HTTP\"}, Usage: \"ip:port used to expose the profiling endpoints (implicitly enables profiling)\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-public-key\", Aliases: []string{\"web_push_public_key\"}, EnvVars: []string{\"NTFY_WEB_PUSH_PUBLIC_KEY\"}, Usage: \"public key used for web push notifications\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-private-key\", Aliases: []string{\"web_push_private_key\"}, EnvVars: []string{\"NTFY_WEB_PUSH_PRIVATE_KEY\"}, Usage: \"private key used for web push notifications\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-file\", Aliases: []string{\"web_push_file\"}, EnvVars: []string{\"NTFY_WEB_PUSH_FILE\"}, Usage: \"file used to store web push subscriptions\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-email-address\", Aliases: []string{\"web_push_email_address\"}, EnvVars: []string{\"NTFY_WEB_PUSH_EMAIL_ADDRESS\"}, Usage: \"e-mail address of sender, required to use browser push services\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-startup-queries\", Aliases: []string{\"web_push_startup_queries\"}, EnvVars: []string{\"NTFY_WEB_PUSH_STARTUP_QUERIES\"}, Usage: \"queries run when the web push database is initialized\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-expiry-duration\", Aliases: []string{\"web_push_expiry_duration\"}, EnvVars: []string{\"NTFY_WEB_PUSH_EXPIRY_DURATION\"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: \"automatically expire unused subscriptions after this time\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-expiry-warning-duration\", Aliases: []string{\"web_push_expiry_warning_duration\"}, EnvVars: []string{\"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION\"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: \"send web push warning notification after this time before expiring unused subscriptions\"}),\n)\n\nvar cmdServe = &cli.Command{\n\tName:      \"serve\",\n\tUsage:     \"Run the ntfy server\",\n\tUsageText: \"ntfy serve [OPTIONS..]\",\n\tAction:    execServe,\n\tCategory:  categoryServer,\n\tFlags:     flagsServe,\n\tBefore:    initConfigFileInputSourceFunc(\"config\", flagsServe, initLogFunc),\n\tDescription: `Run the ntfy server and listen for incoming requests\n\nThe command will load the configuration from /etc/ntfy/server.yml. Config options can \nbe overridden using the command line options.\n\nExamples:\n  ntfy serve                      # Starts server in the foreground (on port 80)\n  ntfy serve --listen-http :8080  # Starts server with alternate port`,\n}\n\nfunc execServe(c *cli.Context) error {\n\tif c.NArg() > 0 {\n\t\treturn errors.New(\"no arguments expected, see 'ntfy serve --help' for help\")\n\t}\n\n\t// Read all the options\n\tconfig := c.String(\"config\")\n\tbaseURL := strings.TrimSuffix(c.String(\"base-url\"), \"/\")\n\tlistenHTTP := c.String(\"listen-http\")\n\tlistenHTTPS := c.String(\"listen-https\")\n\tlistenUnix := c.String(\"listen-unix\")\n\tlistenUnixMode := c.Int(\"listen-unix-mode\")\n\tkeyFile := c.String(\"key-file\")\n\tcertFile := c.String(\"cert-file\")\n\tfirebaseKeyFile := c.String(\"firebase-key-file\")\n\tdatabaseURL := c.String(\"database-url\")\n\tdatabaseReplicaURLs := c.StringSlice(\"database-replica-urls\")\n\twebPushPrivateKey := c.String(\"web-push-private-key\")\n\twebPushPublicKey := c.String(\"web-push-public-key\")\n\twebPushFile := c.String(\"web-push-file\")\n\twebPushEmailAddress := c.String(\"web-push-email-address\")\n\twebPushStartupQueries := c.String(\"web-push-startup-queries\")\n\twebPushExpiryDurationStr := c.String(\"web-push-expiry-duration\")\n\twebPushExpiryWarningDurationStr := c.String(\"web-push-expiry-warning-duration\")\n\tcacheFile := c.String(\"cache-file\")\n\tcacheDurationStr := c.String(\"cache-duration\")\n\tcacheStartupQueries := c.String(\"cache-startup-queries\")\n\tcacheBatchSize := c.Int(\"cache-batch-size\")\n\tcacheBatchTimeoutStr := c.String(\"cache-batch-timeout\")\n\tauthFile := c.String(\"auth-file\")\n\tauthStartupQueries := c.String(\"auth-startup-queries\")\n\tauthDefaultAccess := c.String(\"auth-default-access\")\n\tauthUsersRaw := c.StringSlice(\"auth-users\")\n\tauthAccessRaw := c.StringSlice(\"auth-access\")\n\tauthTokensRaw := c.StringSlice(\"auth-tokens\")\n\tattachmentCacheDir := c.String(\"attachment-cache-dir\")\n\tattachmentTotalSizeLimitStr := c.String(\"attachment-total-size-limit\")\n\tattachmentFileSizeLimitStr := c.String(\"attachment-file-size-limit\")\n\tattachmentExpiryDurationStr := c.String(\"attachment-expiry-duration\")\n\ttemplateDir := c.String(\"template-dir\")\n\tkeepaliveIntervalStr := c.String(\"keepalive-interval\")\n\tmanagerIntervalStr := c.String(\"manager-interval\")\n\tdisallowedTopics := c.StringSlice(\"disallowed-topics\")\n\twebRoot := c.String(\"web-root\")\n\tenableSignup := c.Bool(\"enable-signup\")\n\tenableLogin := c.Bool(\"enable-login\")\n\trequireLogin := c.Bool(\"require-login\")\n\tenableReservations := c.Bool(\"enable-reservations\")\n\tupstreamBaseURL := c.String(\"upstream-base-url\")\n\tupstreamAccessToken := c.String(\"upstream-access-token\")\n\tsmtpSenderAddr := c.String(\"smtp-sender-addr\")\n\tsmtpSenderUser := c.String(\"smtp-sender-user\")\n\tsmtpSenderPass := c.String(\"smtp-sender-pass\")\n\tsmtpSenderFrom := c.String(\"smtp-sender-from\")\n\tsmtpServerListen := c.String(\"smtp-server-listen\")\n\tsmtpServerDomain := c.String(\"smtp-server-domain\")\n\tsmtpServerAddrPrefix := c.String(\"smtp-server-addr-prefix\")\n\ttwilioAccount := c.String(\"twilio-account\")\n\ttwilioAuthToken := c.String(\"twilio-auth-token\")\n\ttwilioPhoneNumber := c.String(\"twilio-phone-number\")\n\ttwilioVerifyService := c.String(\"twilio-verify-service\")\n\ttwilioCallFormat := c.String(\"twilio-call-format\")\n\tmessageSizeLimitStr := c.String(\"message-size-limit\")\n\tmessageDelayLimitStr := c.String(\"message-delay-limit\")\n\ttotalTopicLimit := c.Int(\"global-topic-limit\")\n\tvisitorSubscriptionLimit := c.Int(\"visitor-subscription-limit\")\n\tvisitorSubscriberRateLimiting := c.Bool(\"visitor-subscriber-rate-limiting\")\n\tvisitorAttachmentTotalSizeLimitStr := c.String(\"visitor-attachment-total-size-limit\")\n\tvisitorAttachmentDailyBandwidthLimitStr := c.String(\"visitor-attachment-daily-bandwidth-limit\")\n\tvisitorRequestLimitBurst := c.Int(\"visitor-request-limit-burst\")\n\tvisitorRequestLimitReplenishStr := c.String(\"visitor-request-limit-replenish\")\n\tvisitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String(\"visitor-request-limit-exempt-hosts\"), \",\")\n\tvisitorMessageDailyLimit := c.Int(\"visitor-message-daily-limit\")\n\tvisitorEmailLimitBurst := c.Int(\"visitor-email-limit-burst\")\n\tvisitorEmailLimitReplenishStr := c.String(\"visitor-email-limit-replenish\")\n\tvisitorPrefixBitsIPv4 := c.Int(\"visitor-prefix-bits-ipv4\")\n\tvisitorPrefixBitsIPv6 := c.Int(\"visitor-prefix-bits-ipv6\")\n\tbehindProxy := c.Bool(\"behind-proxy\")\n\tproxyForwardedHeader := c.String(\"proxy-forwarded-header\")\n\tproxyTrustedHosts := util.SplitNoEmpty(c.String(\"proxy-trusted-hosts\"), \",\")\n\tstripeSecretKey := c.String(\"stripe-secret-key\")\n\tstripeWebhookKey := c.String(\"stripe-webhook-key\")\n\tbillingContact := c.String(\"billing-contact\")\n\tmetricsListenHTTP := c.String(\"metrics-listen-http\")\n\tenableMetrics := c.Bool(\"enable-metrics\") || metricsListenHTTP != \"\"\n\tprofileListenHTTP := c.String(\"profile-listen-http\")\n\n\t// Convert durations\n\tcacheDuration, err := util.ParseDuration(cacheDurationStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid cache duration: %s\", cacheDurationStr)\n\t}\n\tcacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid cache batch timeout: %s\", cacheBatchTimeoutStr)\n\t}\n\tattachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid attachment expiry duration: %s\", attachmentExpiryDurationStr)\n\t}\n\tkeepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid keepalive interval: %s\", keepaliveIntervalStr)\n\t}\n\tmanagerInterval, err := util.ParseDuration(managerIntervalStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid manager interval: %s\", managerIntervalStr)\n\t}\n\tmessageDelayLimit, err := util.ParseDuration(messageDelayLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid message delay limit: %s\", messageDelayLimitStr)\n\t}\n\tvisitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid visitor request limit replenish: %s\", visitorRequestLimitReplenishStr)\n\t}\n\tvisitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid visitor email limit replenish: %s\", visitorEmailLimitReplenishStr)\n\t}\n\twebPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid web push expiry duration: %s\", webPushExpiryDurationStr)\n\t}\n\twebPushExpiryWarningDuration, err := util.ParseDuration(webPushExpiryWarningDurationStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid web push expiry warning duration: %s\", webPushExpiryWarningDurationStr)\n\t}\n\n\t// Convert sizes to bytes\n\tmessageSizeLimit, err := util.ParseSize(messageSizeLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid message size limit: %s\", messageSizeLimitStr)\n\t}\n\tattachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid attachment total size limit: %s\", attachmentTotalSizeLimitStr)\n\t}\n\tattachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid attachment file size limit: %s\", attachmentFileSizeLimitStr)\n\t}\n\tvisitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid visitor attachment total size limit: %s\", visitorAttachmentTotalSizeLimitStr)\n\t}\n\tvisitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid visitor attachment daily bandwidth limit: %s\", visitorAttachmentDailyBandwidthLimitStr)\n\t} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {\n\t\treturn fmt.Errorf(\"config option visitor-attachment-daily-bandwidth-limit must be lower than %d\", math.MaxInt)\n\t}\n\n\t// Check values\n\tif databaseURL != \"\" && !strings.HasPrefix(databaseURL, \"postgres://\") && !strings.HasPrefix(databaseURL, \"postgresql://\") {\n\t\treturn errors.New(\"if database-url is set, it must start with postgres:// or postgresql://\")\n\t} else if databaseURL != \"\" && (authFile != \"\" || cacheFile != \"\" || webPushFile != \"\") {\n\t\treturn errors.New(\"if database-url is set, auth-file, cache-file, and web-push-file must not be set\")\n\t} else if len(databaseReplicaURLs) > 0 && databaseURL == \"\" {\n\t\treturn errors.New(\"database-replica-urls can only be used if database-url is also set\")\n\t} else if firebaseKeyFile != \"\" && !util.FileExists(firebaseKeyFile) {\n\t\treturn errors.New(\"if set, FCM key file must exist\")\n\t} else if firebaseKeyFile != \"\" && !server.FirebaseAvailable {\n\t\treturn errors.New(\"cannot set firebase-key-file, support for Firebase is not available (nofirebase)\")\n\t} else if webPushPublicKey != \"\" && (webPushPrivateKey == \"\" || (webPushFile == \"\" && databaseURL == \"\") || webPushEmailAddress == \"\" || baseURL == \"\") {\n\t\treturn errors.New(\"if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys\")\n\t} else if keepaliveInterval < 5*time.Second {\n\t\treturn errors.New(\"keepalive interval cannot be lower than five seconds\")\n\t} else if managerInterval < 5*time.Second {\n\t\treturn errors.New(\"manager interval cannot be lower than five seconds\")\n\t} else if cacheDuration > 0 && cacheDuration < managerInterval {\n\t\treturn errors.New(\"cache duration cannot be lower than manager interval\")\n\t} else if keyFile != \"\" && !util.FileExists(keyFile) {\n\t\treturn errors.New(\"if set, key file must exist\")\n\t} else if certFile != \"\" && !util.FileExists(certFile) {\n\t\treturn errors.New(\"if set, certificate file must exist\")\n\t} else if listenHTTPS != \"\" && (keyFile == \"\" || certFile == \"\") {\n\t\treturn errors.New(\"if listen-https is set, both key-file and cert-file must be set\")\n\t} else if smtpSenderAddr != \"\" && (baseURL == \"\" || smtpSenderFrom == \"\") {\n\t\treturn errors.New(\"if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set\")\n\t} else if smtpServerListen != \"\" && smtpServerDomain == \"\" {\n\t\treturn errors.New(\"if smtp-server-listen is set, smtp-server-domain must also be set\")\n\t} else if attachmentCacheDir != \"\" && baseURL == \"\" {\n\t\treturn errors.New(\"if attachment-cache-dir is set, base-url must also be set\")\n\t} else if baseURL != \"\" {\n\t\tu, err := url.Parse(baseURL)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v\", err)\n\t\t} else if u.Scheme != \"http\" && u.Scheme != \"https\" {\n\t\t\treturn errors.New(\"if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com\")\n\t\t} else if u.Path != \"\" {\n\t\t\treturn fmt.Errorf(\"if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com\", u.Path)\n\t\t}\n\t} else if upstreamBaseURL != \"\" && !strings.HasPrefix(upstreamBaseURL, \"http://\") && !strings.HasPrefix(upstreamBaseURL, \"https://\") {\n\t\treturn errors.New(\"if set, upstream-base-url must start with http:// or https://\")\n\t} else if upstreamBaseURL != \"\" && strings.HasSuffix(upstreamBaseURL, \"/\") {\n\t\treturn errors.New(\"if set, upstream-base-url must not end with a slash (/)\")\n\t} else if upstreamBaseURL != \"\" && baseURL == \"\" {\n\t\treturn errors.New(\"if upstream-base-url is set, base-url must also be set\")\n\t} else if upstreamBaseURL != \"\" && baseURL != \"\" && baseURL == upstreamBaseURL {\n\t\treturn errors.New(\"base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications\")\n\t} else if authFile == \"\" && databaseURL == \"\" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != \"\") {\n\t\treturn errors.New(\"cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file or database-url is not set\")\n\t} else if enableSignup && !enableLogin {\n\t\treturn errors.New(\"cannot set enable-signup without also setting enable-login\")\n\t} else if requireLogin && !enableLogin {\n\t\treturn errors.New(\"cannot set require-login without also setting enable-login\")\n\t} else if !payments.Available && (stripeSecretKey != \"\" || stripeWebhookKey != \"\") {\n\t\treturn errors.New(\"cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)\")\n\t} else if stripeSecretKey != \"\" && (stripeWebhookKey == \"\" || baseURL == \"\") {\n\t\treturn errors.New(\"if stripe-secret-key is set, stripe-webhook-key and base-url must also be set\")\n\t} else if twilioAccount != \"\" && (twilioAuthToken == \"\" || twilioPhoneNumber == \"\" || twilioVerifyService == \"\" || baseURL == \"\" || (authFile == \"\" && databaseURL == \"\")) {\n\t\treturn errors.New(\"if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set\")\n\t} else if messageSizeLimit > server.DefaultMessageSizeLimit {\n\t\tlog.Warn(\"message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients\")\n\t\tif messageSizeLimit > 5*1024*1024 {\n\t\t\treturn errors.New(\"message-size-limit cannot be higher than 5M\")\n\t\t}\n\t} else if !server.WebPushAvailable && (webPushPrivateKey != \"\" || webPushPublicKey != \"\" || webPushFile != \"\") {\n\t\treturn errors.New(\"cannot enable WebPush, support is not available in this build (nowebpush)\")\n\t} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {\n\t\treturn errors.New(\"web push expiry warning duration cannot be higher than web push expiry duration\")\n\t} else if behindProxy && proxyForwardedHeader == \"\" {\n\t\treturn errors.New(\"if behind-proxy is set, proxy-forwarded-header must also be set\")\n\t} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {\n\t\treturn errors.New(\"visitor-prefix-bits-ipv4 must be between 1 and 32\")\n\t} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {\n\t\treturn errors.New(\"visitor-prefix-bits-ipv6 must be between 1 and 128\")\n\t} else if runtime.GOOS == \"windows\" && listenUnix != \"\" {\n\t\treturn errors.New(\"listen-unix is not supported on Windows\")\n\t}\n\n\t// Backwards compatibility\n\tif webRoot == \"app\" {\n\t\twebRoot = \"/\"\n\t} else if webRoot == \"home\" {\n\t\twebRoot = \"/app\"\n\t} else if webRoot == \"disable\" {\n\t\twebRoot = \"\"\n\t} else if !strings.HasPrefix(webRoot, \"/\") {\n\t\twebRoot = \"/\" + webRoot\n\t}\n\n\t// Convert default auth permission, read provisioned users\n\tauthDefault, err := user.ParsePermission(authDefaultAccess)\n\tif err != nil {\n\t\treturn errors.New(\"if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'\")\n\t}\n\tauthUsers, err := parseUsers(authUsersRaw)\n\tif err != nil {\n\t\treturn err\n\t}\n\tauthAccess, err := parseAccess(authUsers, authAccessRaw)\n\tif err != nil {\n\t\treturn err\n\t}\n\tauthTokens, err := parseTokens(authUsers, authTokensRaw)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Special case: Unset default\n\tif listenHTTP == \"-\" {\n\t\tlistenHTTP = \"\"\n\t}\n\n\t// Resolve hosts\n\tvisitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0)\n\tfor _, host := range visitorRequestLimitExemptHosts {\n\t\tprefixes, err := parseIPHostPrefix(host)\n\t\tif err != nil {\n\t\t\tlog.Warn(\"cannot resolve host %s: %s, ignoring visitor request exemption\", host, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tvisitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...)\n\t}\n\n\t// Parse trusted prefixes\n\ttrustedProxyPrefixes := make([]netip.Prefix, 0)\n\tfor _, host := range proxyTrustedHosts {\n\t\tprefixes, err := parseIPHostPrefix(host)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot resolve trusted proxy host %s: %s\", host, err.Error())\n\t\t}\n\t\ttrustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...)\n\t}\n\n\t// Stripe things\n\tif stripeSecretKey != \"\" {\n\t\tpayments.Setup(stripeSecretKey)\n\t}\n\n\t// Parse Twilio template\n\tvar twilioCallFormatTemplate *template.Template\n\tif twilioCallFormat != \"\" {\n\t\ttwilioCallFormatTemplate, err = template.New(\"\").Parse(twilioCallFormat)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse twilio-call-format template: %w\", err)\n\t\t}\n\t}\n\n\t// Add default forbidden topics\n\tdisallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)\n\n\t// Run server\n\tconf := server.NewConfig()\n\tconf.File = config\n\tconf.BaseURL = baseURL\n\tconf.ListenHTTP = listenHTTP\n\tconf.ListenHTTPS = listenHTTPS\n\tconf.ListenUnix = listenUnix\n\tconf.ListenUnixMode = fs.FileMode(listenUnixMode)\n\tconf.KeyFile = keyFile\n\tconf.CertFile = certFile\n\tconf.FirebaseKeyFile = firebaseKeyFile\n\tconf.CacheFile = cacheFile\n\tconf.CacheDuration = cacheDuration\n\tconf.CacheStartupQueries = cacheStartupQueries\n\tconf.CacheBatchSize = cacheBatchSize\n\tconf.CacheBatchTimeout = cacheBatchTimeout\n\tconf.AuthFile = authFile\n\tconf.AuthStartupQueries = authStartupQueries\n\tconf.AuthDefault = authDefault\n\tconf.AuthUsers = authUsers\n\tconf.AuthAccess = authAccess\n\tconf.AuthTokens = authTokens\n\tconf.AttachmentCacheDir = attachmentCacheDir\n\tconf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit\n\tconf.AttachmentFileSizeLimit = attachmentFileSizeLimit\n\tconf.AttachmentExpiryDuration = attachmentExpiryDuration\n\tconf.TemplateDir = templateDir\n\tconf.KeepaliveInterval = keepaliveInterval\n\tconf.ManagerInterval = managerInterval\n\tconf.DisallowedTopics = disallowedTopics\n\tconf.WebRoot = webRoot\n\tconf.UpstreamBaseURL = upstreamBaseURL\n\tconf.UpstreamAccessToken = upstreamAccessToken\n\tconf.SMTPSenderAddr = smtpSenderAddr\n\tconf.SMTPSenderUser = smtpSenderUser\n\tconf.SMTPSenderPass = smtpSenderPass\n\tconf.SMTPSenderFrom = smtpSenderFrom\n\tconf.SMTPServerListen = smtpServerListen\n\tconf.SMTPServerDomain = smtpServerDomain\n\tconf.SMTPServerAddrPrefix = smtpServerAddrPrefix\n\tconf.TwilioAccount = twilioAccount\n\tconf.TwilioAuthToken = twilioAuthToken\n\tconf.TwilioPhoneNumber = twilioPhoneNumber\n\tconf.TwilioVerifyService = twilioVerifyService\n\tconf.TwilioCallFormat = twilioCallFormatTemplate\n\tconf.MessageSizeLimit = int(messageSizeLimit)\n\tconf.MessageDelayMax = messageDelayLimit\n\tconf.TotalTopicLimit = totalTopicLimit\n\tconf.VisitorSubscriptionLimit = visitorSubscriptionLimit\n\tconf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting\n\tconf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit\n\tconf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit\n\tconf.VisitorRequestLimitBurst = visitorRequestLimitBurst\n\tconf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish\n\tconf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes\n\tconf.VisitorMessageDailyLimit = visitorMessageDailyLimit\n\tconf.VisitorEmailLimitBurst = visitorEmailLimitBurst\n\tconf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish\n\tconf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4\n\tconf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6\n\tconf.BehindProxy = behindProxy\n\tconf.ProxyForwardedHeader = proxyForwardedHeader\n\tconf.ProxyTrustedPrefixes = trustedProxyPrefixes\n\tconf.StripeSecretKey = stripeSecretKey\n\tconf.StripeWebhookKey = stripeWebhookKey\n\tconf.BillingContact = billingContact\n\tconf.EnableSignup = enableSignup\n\tconf.EnableLogin = enableLogin\n\tconf.RequireLogin = requireLogin\n\tconf.EnableReservations = enableReservations\n\tconf.EnableMetrics = enableMetrics\n\tconf.MetricsListenHTTP = metricsListenHTTP\n\tconf.ProfileListenHTTP = profileListenHTTP\n\tconf.DatabaseURL = databaseURL\n\tconf.DatabaseReplicaURLs = databaseReplicaURLs\n\tconf.WebPushPrivateKey = webPushPrivateKey\n\tconf.WebPushPublicKey = webPushPublicKey\n\tconf.WebPushFile = webPushFile\n\tconf.WebPushEmailAddress = webPushEmailAddress\n\tconf.WebPushStartupQueries = webPushStartupQueries\n\tconf.WebPushExpiryDuration = webPushExpiryDuration\n\tconf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration\n\tconf.BuildVersion = c.App.Version\n\tconf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)\n\tconf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)\n\n\t// Check if we should run as a Windows service\n\tif ranAsService, err := maybeRunAsService(conf); err != nil {\n\t\tlog.Fatal(\"%s\", err.Error())\n\t} else if ranAsService {\n\t\tlog.Info(\"Exiting.\")\n\t\treturn nil\n\t}\n\n\t// Set up hot-reloading of config\n\tgo sigHandlerConfigReload(config)\n\n\t// Run server\n\ts, err := server.New(conf)\n\tif err != nil {\n\t\tlog.Fatal(\"%s\", err.Error())\n\t} else if err := s.Run(); err != nil {\n\t\tlog.Fatal(\"%s\", err.Error())\n\t}\n\tlog.Info(\"Exiting.\")\n\treturn nil\n}\n\nfunc parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {\n\t// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32\n\tprefix, err := netip.ParsePrefix(host)\n\tif err == nil {\n\t\tprefixes = append(prefixes, prefix.Masked())\n\t\treturn prefixes, nil\n\t}\n\t// Not a prefix, parse as host or IP (LookupHost passes through an IP as is)\n\tips, err := net.LookupHost(host)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, ipStr := range ips {\n\t\tip, err := netip.ParseAddr(ipStr)\n\t\tif err == nil {\n\t\t\tprefix, err := ip.Prefix(ip.BitLen())\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"%s successfully parsed but unable to make prefix: %s\", ip.String(), err.Error())\n\t\t\t}\n\t\t\tprefixes = append(prefixes, prefix.Masked())\n\t\t}\n\t}\n\treturn\n}\n\nfunc parseUsers(usersRaw []string) ([]*user.User, error) {\n\tusers := make([]*user.User, 0)\n\tfor _, userLine := range usersRaw {\n\t\tparts := strings.Split(userLine, \":\")\n\t\tif len(parts) != 3 {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-users: %s, expected format: 'name:hash:role'\", userLine)\n\t\t}\n\t\tusername := strings.TrimSpace(parts[0])\n\t\tpasswordHash := strings.TrimSpace(parts[1])\n\t\trole := user.Role(strings.TrimSpace(parts[2]))\n\t\tif !user.AllowedUsername(username) {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-users: %s, username invalid\", userLine)\n\t\t} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-users: %s, password hash invalid, %s\", userLine, err.Error())\n\t\t} else if !user.AllowedRole(role) {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'\", userLine, role)\n\t\t}\n\t\tusers = append(users, &user.User{\n\t\t\tName:        username,\n\t\t\tHash:        passwordHash,\n\t\t\tRole:        role,\n\t\t\tProvisioned: true,\n\t\t})\n\t}\n\treturn users, nil\n}\n\nfunc parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {\n\taccess := make(map[string][]*user.Grant)\n\tfor _, accessLine := range accessRaw {\n\t\tparts := strings.Split(accessLine, \":\")\n\t\tif len(parts) != 3 {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, expected format: 'user:topic:permission'\", accessLine)\n\t\t}\n\t\tusername := strings.TrimSpace(parts[0])\n\t\tif username == userEveryone {\n\t\t\tusername = user.Everyone\n\t\t}\n\t\tu, exists := util.Find(users, func(u *user.User) bool {\n\t\t\treturn u.Name == username\n\t\t})\n\t\tif username != user.Everyone {\n\t\t\tif !exists {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, user %s is not provisioned\", accessLine, username)\n\t\t\t} else if !user.AllowedUsername(username) {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, username %s invalid\", accessLine, username)\n\t\t\t} else if u.Role != user.RoleUser {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries\", accessLine, username)\n\t\t\t}\n\t\t}\n\t\ttopic := strings.TrimSpace(parts[1])\n\t\tif !user.AllowedTopicPattern(topic) {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, topic pattern %s invalid\", accessLine, topic)\n\t\t}\n\t\tpermission, err := user.ParsePermission(strings.TrimSpace(parts[2]))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-access: %s, permission %s invalid, %s\", accessLine, parts[2], err.Error())\n\t\t}\n\t\tif _, exists := access[username]; !exists {\n\t\t\taccess[username] = make([]*user.Grant, 0)\n\t\t}\n\t\taccess[username] = append(access[username], &user.Grant{\n\t\t\tTopicPattern: topic,\n\t\t\tPermission:   permission,\n\t\t\tProvisioned:  true,\n\t\t})\n\t}\n\treturn access, nil\n}\n\nfunc parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) {\n\ttokens := make(map[string][]*user.Token)\n\tfor _, tokenLine := range tokensRaw {\n\t\tparts := strings.Split(tokenLine, \":\")\n\t\tif len(parts) < 2 || len(parts) > 3 {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-tokens: %s, expected format: 'user:token[:label]'\", tokenLine)\n\t\t}\n\t\tusername := strings.TrimSpace(parts[0])\n\t\t_, exists := util.Find(users, func(u *user.User) bool {\n\t\t\treturn u.Name == username\n\t\t})\n\t\tif !exists {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-tokens: %s, user %s is not provisioned\", tokenLine, username)\n\t\t} else if !user.AllowedUsername(username) {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-tokens: %s, username %s invalid\", tokenLine, username)\n\t\t}\n\t\ttoken := strings.TrimSpace(parts[1])\n\t\tif !user.ValidToken(token) {\n\t\t\treturn nil, fmt.Errorf(\"invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token\", tokenLine, token)\n\t\t}\n\t\tvar label string\n\t\tif len(parts) > 2 {\n\t\t\tlabel = parts[2]\n\t\t}\n\t\tif _, exists := tokens[username]; !exists {\n\t\t\ttokens[username] = make([]*user.Token, 0)\n\t\t}\n\t\ttokens[username] = append(tokens[username], &user.Token{\n\t\t\tValue:       token,\n\t\t\tLabel:       label,\n\t\t\tProvisioned: true,\n\t\t})\n\t}\n\treturn tokens, nil\n}\n\nfunc maybeFromMetadata(m map[string]any, key string) string {\n\tif m == nil {\n\t\treturn \"\"\n\t}\n\tv, exists := m[key]\n\tif !exists {\n\t\treturn \"\"\n\t}\n\ts, ok := v.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "cmd/serve_test.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc TestParseUsers_Success(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []string\n\t\texpected []*user.User\n\t}{\n\t\t{\n\t\t\tname:  \"single user\",\n\t\t\tinput: []string{\"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user\"},\n\t\t\texpected: []*user.User{\n\t\t\t\t{\n\t\t\t\t\tName:        \"alice\",\n\t\t\t\t\tHash:        \"$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S\",\n\t\t\t\t\tRole:        user.RoleUser,\n\t\t\t\t\tProvisioned: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple users with different roles\",\n\t\t\tinput: []string{\n\t\t\t\t\"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user\",\n\t\t\t\t\"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin\",\n\t\t\t},\n\t\t\texpected: []*user.User{\n\t\t\t\t{\n\t\t\t\t\tName:        \"alice\",\n\t\t\t\t\tHash:        \"$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S\",\n\t\t\t\t\tRole:        user.RoleUser,\n\t\t\t\t\tProvisioned: true,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:        \"bob\",\n\t\t\t\t\tHash:        \"$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq\",\n\t\t\t\t\tRole:        user.RoleAdmin,\n\t\t\t\t\tProvisioned: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tinput:    []string{},\n\t\t\texpected: []*user.User{},\n\t\t},\n\t\t{\n\t\t\tname:  \"user with special characters in name\",\n\t\t\tinput: []string{\"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user\"},\n\t\t\texpected: []*user.User{\n\t\t\t\t{\n\t\t\t\t\tName:        \"alice.test+123@example.com\",\n\t\t\t\t\tHash:        \"$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe\",\n\t\t\t\t\tRole:        user.RoleUser,\n\t\t\t\t\tProvisioned: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseUsers(tt.input)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, result, len(tt.expected))\n\n\t\t\tfor i, expectedUser := range tt.expected {\n\t\t\t\tassert.Equal(t, expectedUser.Name, result[i].Name)\n\t\t\t\tassert.Equal(t, expectedUser.Hash, result[i].Hash)\n\t\t\t\tassert.Equal(t, expectedUser.Role, result[i].Role)\n\t\t\t\tassert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseUsers_Errors(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput []string\n\t\terror string\n\t}{\n\t\t{\n\t\t\tname:  \"invalid format - too few parts\",\n\t\t\tinput: []string{\"alice:hash\"},\n\t\t\terror: \"invalid auth-users: alice:hash, expected format: 'name:hash:role'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid format - too many parts\",\n\t\t\tinput: []string{\"alice:hash:role:extra\"},\n\t\t\terror: \"invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid username\",\n\t\t\tinput: []string{\"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user\"},\n\t\t\terror: \"invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid password hash - wrong prefix\",\n\t\t\tinput: []string{\"alice:plaintext:user\"},\n\t\t\terror: \"invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid role\",\n\t\t\tinput: []string{\"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid\"},\n\t\t\terror: \"invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"empty username\",\n\t\t\tinput: []string{\":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user\"},\n\t\t\terror: \"invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseUsers(tt.input)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, result)\n\t\t\tassert.Contains(t, err.Error(), tt.error)\n\t\t})\n\t}\n}\n\nfunc TestParseAccess_Success(t *testing.T) {\n\tusers := []*user.User{\n\t\t{Name: \"alice\", Role: user.RoleUser},\n\t\t{Name: \"bob\", Role: user.RoleUser},\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tusers    []*user.User\n\t\tinput    []string\n\t\texpected map[string][]*user.Grant\n\t}{\n\t\t{\n\t\t\tname:  \"single access entry\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:mytopic:read-write\"},\n\t\t\texpected: map[string][]*user.Grant{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"mytopic\",\n\t\t\t\t\t\tPermission:   user.PermissionReadWrite,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"multiple access entries for same user\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\n\t\t\t\t\"alice:topic1:read-only\",\n\t\t\t\t\"alice:topic2:write-only\",\n\t\t\t},\n\t\t\texpected: map[string][]*user.Grant{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"topic1\",\n\t\t\t\t\t\tPermission:   user.PermissionRead,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"topic2\",\n\t\t\t\t\t\tPermission:   user.PermissionWrite,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"access for everyone\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"everyone:publictopic:read-only\"},\n\t\t\texpected: map[string][]*user.Grant{\n\t\t\t\tuser.Everyone: {\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"publictopic\",\n\t\t\t\t\t\tPermission:   user.PermissionRead,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"wildcard topic pattern\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:topic*:read-write\"},\n\t\t\texpected: map[string][]*user.Grant{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"topic*\",\n\t\t\t\t\t\tPermission:   user.PermissionReadWrite,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tusers:    users,\n\t\t\tinput:    []string{},\n\t\t\texpected: map[string][]*user.Grant{},\n\t\t},\n\t\t{\n\t\t\tname:  \"deny-all permission\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:secretopic:deny-all\"},\n\t\t\texpected: map[string][]*user.Grant{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tTopicPattern: \"secretopic\",\n\t\t\t\t\t\tPermission:   user.PermissionDenyAll,\n\t\t\t\t\t\tProvisioned:  true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseAccess(tt.users, tt.input)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestParseAccess_Errors(t *testing.T) {\n\tusers := []*user.User{\n\t\t{Name: \"alice\", Role: user.RoleUser},\n\t\t{Name: \"admin\", Role: user.RoleAdmin},\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tusers []*user.User\n\t\tinput []string\n\t\terror string\n\t}{\n\t\t{\n\t\t\tname:  \"invalid format - too few parts\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:topic\"},\n\t\t\terror: \"invalid auth-access: alice:topic, expected format: 'user:topic:permission'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid format - too many parts\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:topic:read:extra\"},\n\t\t\terror: \"invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"user not provisioned\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"charlie:topic:read\"},\n\t\t\terror: \"invalid auth-access: charlie:topic:read, user charlie is not provisioned\",\n\t\t},\n\t\t{\n\t\t\tname:  \"admin user cannot have ACL entries\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"admin:topic:read\"},\n\t\t\terror: \"invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid topic pattern\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:topic-with-invalid-chars!:read\"},\n\t\t\terror: \"invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid permission\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:topic:invalid-permission\"},\n\t\t\terror: \"invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseAccess(tt.users, tt.input)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, result)\n\t\t\tassert.Contains(t, err.Error(), tt.error)\n\t\t})\n\t}\n}\n\nfunc TestParseTokens_Success(t *testing.T) {\n\tusers := []*user.User{\n\t\t{Name: \"alice\"},\n\t\t{Name: \"bob\"},\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tusers    []*user.User\n\t\tinput    []string\n\t\texpected map[string][]*user.Token\n\t}{\n\t\t{\n\t\t\tname:  \"single token without label\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:tk_abcdefghijklmnopqrstuvwxyz123\"},\n\t\t\texpected: map[string][]*user.Token{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_abcdefghijklmnopqrstuvwxyz123\",\n\t\t\t\t\t\tLabel:       \"\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"single token with label\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone\"},\n\t\t\texpected: map[string][]*user.Token{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_abcdefghijklmnopqrstuvwxyz123\",\n\t\t\t\t\t\tLabel:       \"My Phone\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"multiple tokens for same user\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\n\t\t\t\t\"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone\",\n\t\t\t\t\"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop\",\n\t\t\t},\n\t\t\texpected: map[string][]*user.Token{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_abcdefghijklmnopqrstuvwxyz123\",\n\t\t\t\t\t\tLabel:       \"Phone\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_zyxwvutsrqponmlkjihgfedcba987\",\n\t\t\t\t\t\tLabel:       \"Laptop\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"tokens for multiple users\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\n\t\t\t\t\"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone\",\n\t\t\t\t\"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet\",\n\t\t\t},\n\t\t\texpected: map[string][]*user.Token{\n\t\t\t\t\"alice\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_abcdefghijklmnopqrstuvwxyz123\",\n\t\t\t\t\t\tLabel:       \"Phone\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"bob\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tValue:       \"tk_zyxwvutsrqponmlkjihgfedcba987\",\n\t\t\t\t\t\tLabel:       \"Tablet\",\n\t\t\t\t\t\tProvisioned: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tusers:    users,\n\t\t\tinput:    []string{},\n\t\t\texpected: map[string][]*user.Token{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseTokens(tt.users, tt.input)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestParseTokens_Errors(t *testing.T) {\n\tusers := []*user.User{\n\t\t{Name: \"alice\"},\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tusers []*user.User\n\t\tinput []string\n\t\terror string\n\t}{\n\t\t{\n\t\t\tname:  \"invalid format - too few parts\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice\"},\n\t\t\terror: \"invalid auth-tokens: alice, expected format: 'user:token[:label]'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid format - too many parts\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:token:label:extra:parts\"},\n\t\t\terror: \"invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'\",\n\t\t},\n\t\t{\n\t\t\tname:  \"user not provisioned\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"charlie:tk_abcdefghijklmnopqrstuvwxyz123\"},\n\t\t\terror: \"invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned\",\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid token format\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:invalid-token\"},\n\t\t\terror: \"invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token\",\n\t\t},\n\t\t{\n\t\t\tname:  \"token too short\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:tk_short\"},\n\t\t\terror: \"invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token\",\n\t\t},\n\t\t{\n\t\t\tname:  \"token without prefix\",\n\t\t\tusers: users,\n\t\t\tinput: []string{\"alice:abcdefghijklmnopqrstuvwxyz12345\"},\n\t\t\terror: \"invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseTokens(tt.users, tt.input)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, result)\n\t\t\tassert.Contains(t, err.Error(), tt.error)\n\t\t})\n\t}\n}\n\nfunc TestCLI_Serve_Unix_Curl(t *testing.T) {\n\tsockFile := filepath.Join(t.TempDir(), \"ntfy.sock\")\n\tconfigFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system\n\tgo func() {\n\t\tapp, _, _, _ := newTestApp()\n\t\terr := app.Run([]string{\"ntfy\", \"serve\", \"--config=\" + configFile, \"--listen-http=-\", \"--listen-unix=\" + sockFile})\n\t\trequire.Nil(t, err)\n\t}()\n\tfor i := 0; i < 40 && !util.FileExists(sockFile); i++ {\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\trequire.True(t, util.FileExists(sockFile))\n\n\tcmd := exec.Command(\"curl\", \"-s\", \"--unix-socket\", sockFile, \"-d\", \"this is a message\", \"localhost/mytopic\")\n\tout, err := cmd.Output()\n\trequire.Nil(t, err)\n\tm := toMessage(t, string(out))\n\trequire.Equal(t, \"this is a message\", m.Message)\n}\n\nfunc TestCLI_Serve_WebSocket(t *testing.T) {\n\tport := 10000 + rand.Intn(20000)\n\tgo func() {\n\t\tconfigFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system\n\t\tapp, _, _, _ := newTestApp()\n\t\terr := app.Run([]string{\"ntfy\", \"serve\", \"--config=\" + configFile, fmt.Sprintf(\"--listen-http=:%d\", port)})\n\t\trequire.Nil(t, err)\n\t}()\n\ttest.WaitForPortUp(t, port)\n\n\tws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf(\"ws://127.0.0.1:%d/mytopic/ws\", port), nil)\n\trequire.Nil(t, err)\n\n\tmessageType, data, err := ws.ReadMessage()\n\trequire.Nil(t, err)\n\trequire.Equal(t, websocket.TextMessage, messageType)\n\trequire.Equal(t, \"open\", toMessage(t, string(data)).Event)\n\n\tc := client.New(client.NewConfig())\n\t_, err = c.Publish(fmt.Sprintf(\"http://127.0.0.1:%d/mytopic\", port), \"my message\")\n\trequire.Nil(t, err)\n\n\tmessageType, data, err = ws.ReadMessage()\n\trequire.Nil(t, err)\n\trequire.Equal(t, websocket.TextMessage, messageType)\n\n\tm := toMessage(t, string(data))\n\trequire.Equal(t, \"my message\", m.Message)\n\trequire.Equal(t, \"mytopic\", m.Topic)\n}\n\nfunc TestIP_Host_Parsing(t *testing.T) {\n\tcases := map[string]string{\n\t\t\"1.1.1.1\":          \"1.1.1.1/32\",\n\t\t\"fd00::1234\":       \"fd00::1234/128\",\n\t\t\"192.168.0.3/24\":   \"192.168.0.0/24\",\n\t\t\"10.1.2.3/8\":       \"10.0.0.0/8\",\n\t\t\"201:be93::4a6/21\": \"201:b800::/21\",\n\t}\n\tfor q, expectedAnswer := range cases {\n\t\tips, err := parseIPHostPrefix(q)\n\t\trequire.Nil(t, err)\n\t\tassert.Equal(t, 1, len(ips))\n\t\tassert.Equal(t, expectedAnswer, ips[0].String())\n\t}\n}\n\nfunc newEmptyFile(t *testing.T) string {\n\tfilename := filepath.Join(t.TempDir(), \"empty\")\n\trequire.Nil(t, os.WriteFile(filename, []byte{}, 0600))\n\treturn filename\n}\n"
  },
  {
    "path": "cmd/serve_unix.go",
    "content": "//go:build linux || dragonfly || freebsd || netbsd || openbsd\n\npackage cmd\n\nimport (\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/server\"\n)\n\nfunc sigHandlerConfigReload(config string) {\n\tsigs := make(chan os.Signal, 1)\n\tsignal.Notify(sigs, syscall.SIGHUP)\n\tfor range sigs {\n\t\tlog.Info(\"Partially hot reloading configuration ...\")\n\t\tinputSource, err := newYamlSourceFromFile(config, flagsServe)\n\t\tif err != nil {\n\t\t\tlog.Warn(\"Hot reload failed: %s\", err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tif err := reloadLogLevel(inputSource); err != nil {\n\t\t\tlog.Warn(\"Reloading log level failed: %s\", err.Error())\n\t\t}\n\t}\n}\n\nfunc reloadLogLevel(inputSource altsrc.InputSourceContext) error {\n\tnewLevelStr, err := inputSource.String(\"log-level\")\n\tif err != nil {\n\t\treturn err\n\t}\n\toverrides, err := inputSource.StringSlice(\"log-level-overrides\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.ResetLevelOverrides()\n\tif err := applyLogLevelOverrides(overrides); err != nil {\n\t\treturn err\n\t}\n\tlog.SetLevel(log.ToLevel(newLevelStr))\n\tif len(overrides) > 0 {\n\t\tlog.Info(\"Log level is %v, %d override(s) in place\", newLevelStr, len(overrides))\n\t} else {\n\t\tlog.Info(\"Log level is %v\", newLevelStr)\n\t}\n\treturn nil\n}\n\nfunc maybeRunAsService(conf *server.Config) (bool, error) {\n\treturn false, nil\n}\n"
  },
  {
    "path": "cmd/serve_windows.go",
    "content": "//go:build windows && !noserver\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"golang.org/x/sys/windows/svc\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/server\"\n)\n\nconst serviceName = \"ntfy\"\n\n// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.\n// Windows users can restart the service to reload configuration.\nfunc sigHandlerConfigReload(config string) {\n\tlog.Debug(\"Config hot-reload via SIGHUP is not supported on Windows\")\n}\n\n// runAsWindowsService runs the ntfy server as a Windows service\nfunc runAsWindowsService(conf *server.Config) error {\n\treturn svc.Run(serviceName, &windowsService{conf: conf})\n}\n\n// windowsService implements the svc.Handler interface\ntype windowsService struct {\n\tconf   *server.Config\n\tserver *server.Server\n\tmu     sync.Mutex\n}\n\n// Execute is the main entry point for the Windows service\nfunc (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {\n\tconst cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown\n\tstatus <- svc.Status{State: svc.StartPending}\n\n\t// Create and start the server\n\tvar err error\n\ts.mu.Lock()\n\ts.server, err = server.New(s.conf)\n\ts.mu.Unlock()\n\tif err != nil {\n\t\tlog.Error(\"Failed to create server: %s\", err.Error())\n\t\treturn true, 1\n\t}\n\n\t// Start server in a goroutine\n\tserverErrChan := make(chan error, 1)\n\tgo func() {\n\t\tserverErrChan <- s.server.Run()\n\t}()\n\n\tstatus <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}\n\tlog.Info(\"Windows service started\")\n\n\tfor {\n\t\tselect {\n\t\tcase err := <-serverErrChan:\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(\"Server error: %s\", err.Error())\n\t\t\t\treturn true, 1\n\t\t\t}\n\t\t\treturn false, 0\n\t\tcase req := <-requests:\n\t\t\tswitch req.Cmd {\n\t\t\tcase svc.Interrogate:\n\t\t\t\tstatus <- req.CurrentStatus\n\t\t\tcase svc.Stop, svc.Shutdown:\n\t\t\t\tlog.Info(\"Windows service stopping...\")\n\t\t\t\tstatus <- svc.Status{State: svc.StopPending}\n\t\t\t\ts.mu.Lock()\n\t\t\t\tif s.server != nil {\n\t\t\t\t\ts.server.Stop()\n\t\t\t\t}\n\t\t\t\ts.mu.Unlock()\n\t\t\t\treturn false, 0\n\t\t\tdefault:\n\t\t\t\tlog.Warn(\"Unexpected service control request: %d\", req.Cmd)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// maybeRunAsService checks if the process is running as a Windows service,\n// and if so, runs the server as a service. Returns true if it ran as a service.\nfunc maybeRunAsService(conf *server.Config) (bool, error) {\n\tisService, err := svc.IsWindowsService()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to detect Windows service mode: %w\", err)\n\t} else if !isService {\n\t\treturn false, nil\n\t}\n\tlog.Info(\"Running as Windows service\")\n\tif err := runAsWindowsService(conf); err != nil {\n\t\treturn true, fmt.Errorf(\"failed to run as Windows service: %w\", err)\n\t}\n\treturn true, nil\n}\n"
  },
  {
    "path": "cmd/subscribe.go",
    "content": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/client\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdSubscribe)\n}\n\nvar flagsSubscribe = append(\n\tappend([]cli.Flag{}, flagsDefault...),\n\t&cli.StringFlag{Name: \"config\", Aliases: []string{\"c\"}, Usage: \"client config file\"},\n\t&cli.StringFlag{Name: \"since\", Aliases: []string{\"s\"}, Usage: \"return events since `SINCE` (Unix timestamp, or all)\"},\n\t&cli.StringFlag{Name: \"user\", Aliases: []string{\"u\"}, EnvVars: []string{\"NTFY_USER\"}, Usage: \"username[:password] used to auth against the server\"},\n\t&cli.StringFlag{Name: \"token\", Aliases: []string{\"k\"}, EnvVars: []string{\"NTFY_TOKEN\"}, Usage: \"access token used to auth against the server\"},\n\t&cli.BoolFlag{Name: \"from-config\", Aliases: []string{\"from_config\", \"C\"}, Usage: \"read subscriptions from config file (service mode)\"},\n\t&cli.BoolFlag{Name: \"poll\", Aliases: []string{\"p\"}, Usage: \"return events and exit, do not listen for new events\"},\n\t&cli.BoolFlag{Name: \"scheduled\", Aliases: []string{\"sched\", \"S\"}, Usage: \"also return scheduled/delayed events\"},\n)\n\nvar cmdSubscribe = &cli.Command{\n\tName:      \"subscribe\",\n\tAliases:   []string{\"sub\"},\n\tUsage:     \"Subscribe to one or more topics on a ntfy server\",\n\tUsageText: \"ntfy subscribe [OPTIONS..] [TOPIC]\",\n\tAction:    execSubscribe,\n\tCategory:  categoryClient,\n\tFlags:     flagsSubscribe,\n\tBefore:    initLogFunc,\n\tDescription: `Subscribe to a topic from a ntfy server, and either print or execute a command for \nevery arriving message. There are 3 modes in which the command can be run:\n\nntfy subscribe TOPIC\n  This prints the JSON representation of every incoming message. It is useful when you\n  have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,\n  this command stays open forever. \n\n  Examples:\n    ntfy subscribe mytopic            # Prints JSON for incoming messages for ntfy.sh/mytopic\n    ntfy sub home.lan/backups         # Subscribe to topic on different server\n    ntfy sub --poll home.lan/backups  # Just query for latest messages and exit\n    ntfy sub -u phil:mypass secret    # Subscribe with username/password\n  \nntfy subscribe TOPIC COMMAND\n  This executes COMMAND for every incoming messages. The message fields are passed to the\n  command as environment variables:\n\n    Variable        Aliases               Description\n    --------------- --------------------- -----------------------------------\n    $NTFY_ID        $id                   Unique message ID\n    $NTFY_TIME      $time                 Unix timestamp of the message delivery\n    $NTFY_TOPIC     $topic                Topic name\n    $NTFY_MESSAGE   $message, $m          Message body\n    $NTFY_TITLE     $title, $t            Message title\n    $NTFY_PRIORITY  $priority, $prio, $p  Message priority (1=min, 5=max)\n    $NTFY_TAGS      $tags, $tag, $ta      Message tags (comma separated list)\n    $NTFY_RAW       $raw                  Raw JSON message\n\n  Examples:\n    ntfy sub mytopic 'notify-send \"$m\"'    # Execute command for incoming messages\n    ntfy sub topic1 myscript.sh            # Execute script for incoming messages\n\nntfy subscribe --from-config\n  Service mode (used in ntfy-client.service). This reads the config file and sets up \n  subscriptions for every topic in the \"subscribe:\" block (see config file).\n\n  Examples: \n    ntfy sub --from-config                           # Read topics from config file\n    ntfy sub --config=myclient.yml --from-config     # Read topics from alternate config file\n\n` + clientCommandDescriptionSuffix,\n}\n\nfunc execSubscribe(c *cli.Context) error {\n\t// Read config and options\n\tconf, err := loadConfig(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcl := client.New(conf)\n\tsince := c.String(\"since\")\n\tuser := c.String(\"user\")\n\ttoken := c.String(\"token\")\n\tpoll := c.Bool(\"poll\")\n\tscheduled := c.Bool(\"scheduled\")\n\tfromConfig := c.Bool(\"from-config\")\n\ttopic := c.Args().Get(0)\n\tcommand := c.Args().Get(1)\n\n\t// Checks\n\tif user != \"\" && token != \"\" {\n\t\treturn errors.New(\"cannot set both --user and --token\")\n\t}\n\n\tif !fromConfig {\n\t\tconf.Subscribe = nil // wipe if --from-config not passed\n\t}\n\tvar options []client.SubscribeOption\n\tif since != \"\" {\n\t\toptions = append(options, client.WithSince(since))\n\t}\n\tif token != \"\" {\n\t\toptions = append(options, client.WithBearerAuth(token))\n\t} else if user != \"\" {\n\t\tvar pass string\n\t\tparts := strings.SplitN(user, \":\", 2)\n\t\tif len(parts) == 2 {\n\t\t\tuser = parts[0]\n\t\t\tpass = parts[1]\n\t\t} else {\n\t\t\tfmt.Fprint(c.App.ErrWriter, \"Enter Password: \")\n\t\t\tp, err := util.ReadPassword(c.App.Reader)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tpass = string(p)\n\t\t\tfmt.Fprintf(c.App.ErrWriter, \"\\r%s\\r\", strings.Repeat(\" \", 20))\n\t\t}\n\t\toptions = append(options, client.WithBasicAuth(user, pass))\n\t} else if conf.DefaultToken != \"\" {\n\t\toptions = append(options, client.WithBearerAuth(conf.DefaultToken))\n\t} else if conf.DefaultUser != \"\" && conf.DefaultPassword != nil {\n\t\toptions = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))\n\t}\n\tif scheduled {\n\t\toptions = append(options, client.WithScheduled())\n\t}\n\tif topic == \"\" && len(conf.Subscribe) == 0 {\n\t\treturn errors.New(\"must specify topic, type 'ntfy subscribe --help' for help\")\n\t}\n\n\t// Execute poll or subscribe\n\tif poll {\n\t\treturn doPoll(c, cl, conf, topic, command, options...)\n\t}\n\treturn doSubscribe(c, cl, conf, topic, command, options...)\n}\n\nfunc doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {\n\tfor _, s := range conf.Subscribe { // may be nil\n\t\tif auth := maybeAddAuthHeader(s, conf); auth != nil {\n\t\t\toptions = append(options, auth)\n\t\t}\n\t\tif err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif topic != \"\" {\n\t\tif err := doPollSingle(c, cl, topic, command, options...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {\n\tmessages, err := cl.Poll(topic, options...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, m := range messages {\n\t\tprintMessageOrRunCommand(c, m, command)\n\t}\n\treturn nil\n}\n\nfunc doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {\n\tcmds := make(map[string]string)    // Subscription ID -> command\n\tfor _, s := range conf.Subscribe { // May be nil\n\t\ttopicOptions := append(make([]client.SubscribeOption, 0), options...)\n\t\tfor filter, value := range s.If {\n\t\t\ttopicOptions = append(topicOptions, client.WithFilter(filter, value))\n\t\t}\n\n\t\tif auth := maybeAddAuthHeader(s, conf); auth != nil {\n\t\t\ttopicOptions = append(topicOptions, auth)\n\t\t}\n\n\t\tsubscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif s.Command != \"\" {\n\t\t\tcmds[subscriptionID] = s.Command\n\t\t} else if conf.DefaultCommand != \"\" {\n\t\t\tcmds[subscriptionID] = conf.DefaultCommand\n\t\t} else {\n\t\t\tcmds[subscriptionID] = \"\"\n\t\t}\n\t}\n\tif topic != \"\" {\n\t\tsubscriptionID, err := cl.Subscribe(topic, options...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcmds[subscriptionID] = command\n\t}\n\tfor m := range cl.Messages {\n\t\tcmd, ok := cmds[m.SubscriptionID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tlog.Debug(\"%s Dispatching received message: %s\", logMessagePrefix(m), m.Raw)\n\t\tprintMessageOrRunCommand(c, m, cmd)\n\t}\n\treturn nil\n}\n\nfunc maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {\n\t// if an explicit empty token or empty user:pass is given, exit without auth\n\tif (s.Token != nil && *s.Token == \"\") || (s.User != nil && *s.User == \"\" && s.Password != nil && *s.Password == \"\") {\n\t\treturn client.WithEmptyAuth()\n\t}\n\n\t// check for subscription token then subscription user:pass\n\tif s.Token != nil && *s.Token != \"\" {\n\t\treturn client.WithBearerAuth(*s.Token)\n\t}\n\tif s.User != nil && *s.User != \"\" && s.Password != nil {\n\t\treturn client.WithBasicAuth(*s.User, *s.Password)\n\t}\n\n\t// if no subscription token nor subscription user:pass, check for default token then default user:pass\n\tif conf.DefaultToken != \"\" {\n\t\treturn client.WithBearerAuth(conf.DefaultToken)\n\t}\n\tif conf.DefaultUser != \"\" && conf.DefaultPassword != nil {\n\t\treturn client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)\n\t}\n\treturn nil\n}\n\nfunc printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {\n\tif command != \"\" {\n\t\trunCommand(c, command, m)\n\t} else {\n\t\tlog.Debug(\"%s Printing raw message\", logMessagePrefix(m))\n\t\tfmt.Fprintln(c.App.Writer, m.Raw)\n\t}\n}\n\nfunc runCommand(c *cli.Context, command string, m *client.Message) {\n\tif err := runCommandInternal(c, command, m); err != nil {\n\t\tlog.Warn(\"%s Command failed: %s\", logMessagePrefix(m), err.Error())\n\t}\n}\n\nfunc runCommandInternal(c *cli.Context, script string, m *client.Message) error {\n\tscriptFile := fmt.Sprintf(\"%s/ntfy-subscribe-%s.%s\", os.TempDir(), util.RandomString(10), scriptExt)\n\tlog.Debug(\"%s Running command '%s' via temporary script %s\", logMessagePrefix(m), script, scriptFile)\n\tscript = scriptHeader + script\n\tif err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {\n\t\treturn err\n\t}\n\tdefer os.Remove(scriptFile)\n\tlog.Debug(\"%s Executing script %s\", logMessagePrefix(m), scriptFile)\n\tcmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)\n\tcmd.Stdin = c.App.Reader\n\tcmd.Stdout = c.App.Writer\n\tcmd.Stderr = c.App.ErrWriter\n\tcmd.Env = envVars(m)\n\treturn cmd.Run()\n}\n\nfunc envVars(m *client.Message) []string {\n\tenv := make([]string, 0)\n\tenv = append(env, envVar(m.ID, \"NTFY_ID\", \"id\")...)\n\tenv = append(env, envVar(m.Topic, \"NTFY_TOPIC\", \"topic\")...)\n\tenv = append(env, envVar(fmt.Sprintf(\"%d\", m.Time), \"NTFY_TIME\", \"time\")...)\n\tenv = append(env, envVar(m.Message, \"NTFY_MESSAGE\", \"message\", \"m\")...)\n\tenv = append(env, envVar(m.Title, \"NTFY_TITLE\", \"title\", \"t\")...)\n\tenv = append(env, envVar(fmt.Sprintf(\"%d\", m.Priority), \"NTFY_PRIORITY\", \"priority\", \"prio\", \"p\")...)\n\tenv = append(env, envVar(strings.Join(m.Tags, \",\"), \"NTFY_TAGS\", \"tags\", \"tag\", \"ta\")...)\n\tenv = append(env, envVar(m.Raw, \"NTFY_RAW\", \"raw\")...)\n\tsort.Strings(env)\n\tif log.IsTrace() {\n\t\tlog.Trace(\"%s With environment:\\n%s\", logMessagePrefix(m), strings.Join(env, \"\\n\"))\n\t}\n\treturn append(os.Environ(), env...)\n}\n\nfunc envVar(value string, vars ...string) []string {\n\tenv := make([]string, 0)\n\tfor _, v := range vars {\n\t\tenv = append(env, fmt.Sprintf(\"%s=%s\", v, value))\n\t}\n\treturn env\n}\n\nfunc loadConfig(c *cli.Context) (*client.Config, error) {\n\tfilename := c.String(\"config\")\n\tif filename != \"\" {\n\t\treturn client.LoadConfig(filename)\n\t}\n\tif client.DefaultConfigFile != \"\" {\n\t\tif s, _ := os.Stat(client.DefaultConfigFile); s != nil {\n\t\t\treturn client.LoadConfig(client.DefaultConfigFile)\n\t\t}\n\t\tlog.Debug(\"Config file %s not found\", client.DefaultConfigFile)\n\t}\n\tlog.Debug(\"Loading default config\")\n\treturn client.NewConfig(), nil\n}\n\nfunc logMessagePrefix(m *client.Message) string {\n\treturn fmt.Sprintf(\"%s/%s\", util.ShortTopicURL(m.TopicURL), m.ID)\n}\n"
  },
  {
    "path": "cmd/subscribe_darwin.go",
    "content": "//go:build darwin\n\npackage cmd\n\nconst (\n\tscriptExt                      = \"sh\"\n\tscriptHeader                   = \"#!/bin/sh\\n\"\n\tclientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),\nor \"~/Library/Application Support/ntfy/client.yml\" for all other users.`\n)\n\nvar (\n\tscriptLauncher = []string{\"sh\", \"-c\"}\n)\n"
  },
  {
    "path": "cmd/subscribe_test.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\nsubscribe:\n  - topic: mytopic\n    token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\nsubscribe:\n  - topic: mytopic\n    user: philipp\n    password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_FAKETOKEN01234567890FAKETOKEN\nsubscribe:\n  - topic: mytopic\n    token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: fake\ndefault-password: password\nsubscribe:\n  - topic: mytopic\n    user: philipp\n    password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\nsubscribe:\n  - topic: mytopic\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\nsubscribe:\n  - topic: mytopic\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\nsubscribe:\n  - topic: mytopic\n    token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\nsubscribe:\n  - topic: mytopic\n    user: philipp\n    password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_FAKETOKEN0123456789FAKETOKEN\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename, \"--token\", \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", \"mytopic\"}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename, \"--user\", \"philipp:mypass\", \"mytopic\"}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_FAKETOKEN01234567890FAKETOKEN\nsubscribe:\n  - topic: mytopic\n    token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename, \"--user\", \"philipp:mypass\"}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Token_And_UserPass(t *testing.T) {\n\tapp, _, _, _ := newTestApp()\n\terr := app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--token\", \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", \"--user\", \"philipp:mypass\", \"mytopic\", \"triggered\"})\n\trequire.Error(t, err)\n\trequire.Equal(t, \"cannot set both --user and --token\", err.Error())\n}\n\nfunc TestCLI_Subscribe_Default_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename, \"mytopic\"}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Default_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"Basic cGhpbGlwcDpteXBhc3M=\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename, \"mytopic\"}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Override_Default_UserPass_With_Empty_UserPass(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-user: philipp\ndefault-password: mypass\nsubscribe:\n  - topic: mytopic\n    user: \"\"\n    password: \"\"\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n\nfunc TestCLI_Subscribe_Override_Default_Token_With_Empty_Token(t *testing.T) {\n\tmessage := `{\"id\":\"RXIQBFaieLVr\",\"time\":124,\"expires\":1124,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"triggered\"}`\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic/json\", r.URL.Path)\n\t\trequire.Equal(t, \"\", r.Header.Get(\"Authorization\"))\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(message))\n\t}))\n\tdefer server.Close()\n\n\tfilename := filepath.Join(t.TempDir(), \"client.yml\")\n\trequire.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`\ndefault-host: %s\ndefault-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\nsubscribe:\n  - topic: mytopic\n    token: \"\"\n`, server.URL)), 0600))\n\n\tapp, _, stdout, _ := newTestApp()\n\n\trequire.Nil(t, app.Run([]string{\"ntfy\", \"subscribe\", \"--poll\", \"--from-config\", \"--config=\" + filename}))\n\n\trequire.Equal(t, message, strings.TrimSpace(stdout.String()))\n}\n"
  },
  {
    "path": "cmd/subscribe_unix.go",
    "content": "//go:build linux || dragonfly || freebsd || netbsd || openbsd\n\npackage cmd\n\nconst (\n\tscriptExt                      = \"sh\"\n\tscriptHeader                   = \"#!/bin/sh\\n\"\n\tclientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),\nor ~/.config/ntfy/client.yml for all other users.`\n)\n\nvar (\n\tscriptLauncher = []string{\"sh\", \"-c\"}\n)\n"
  },
  {
    "path": "cmd/subscribe_windows.go",
    "content": "//go:build windows\n\npackage cmd\n\nconst (\n\tscriptExt                      = \"bat\"\n\tscriptHeader                   = \"\"\n\tclientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\\ntfy\\client.yml.`\n)\n\nvar (\n\tscriptLauncher = []string{\"cmd.exe\", \"/Q\", \"/C\"}\n)\n"
  },
  {
    "path": "cmd/tier.go",
    "content": "//go:build !noserver\n\npackage cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdTier)\n}\n\nconst (\n\tdefaultMessageLimit             = 5000\n\tdefaultMessageExpiryDuration    = \"12h\"\n\tdefaultEmailLimit               = 20\n\tdefaultCallLimit                = 0\n\tdefaultReservationLimit         = 3\n\tdefaultAttachmentFileSizeLimit  = \"15M\"\n\tdefaultAttachmentTotalSizeLimit = \"100M\"\n\tdefaultAttachmentExpiryDuration = \"6h\"\n\tdefaultAttachmentBandwidthLimit = \"1G\"\n)\n\nvar (\n\tflagsTier = append([]cli.Flag{}, flagsUser...)\n)\n\nvar cmdTier = &cli.Command{\n\tName:      \"tier\",\n\tUsage:     \"Manage/show tiers\",\n\tUsageText: \"ntfy tier [list|add|change|remove] ...\",\n\tFlags:     flagsTier,\n\tBefore:    initConfigFileInputSourceFunc(\"config\", flagsUser, initLogFunc),\n\tCategory:  categoryServer,\n\tSubcommands: []*cli.Command{\n\t\t{\n\t\t\tName:      \"add\",\n\t\t\tAliases:   []string{\"a\"},\n\t\t\tUsage:     \"Adds a new tier\",\n\t\t\tUsageText: \"ntfy tier add [OPTIONS] CODE\",\n\t\t\tAction:    execTierAdd,\n\t\t\tFlags: []cli.Flag{\n\t\t\t\t&cli.StringFlag{Name: \"name\", Usage: \"tier name\"},\n\t\t\t\t&cli.Int64Flag{Name: \"message-limit\", Value: defaultMessageLimit, Usage: \"daily message limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"message-expiry-duration\", Value: defaultMessageExpiryDuration, Usage: \"duration after which messages are deleted\"},\n\t\t\t\t&cli.Int64Flag{Name: \"email-limit\", Value: defaultEmailLimit, Usage: \"daily email limit\"},\n\t\t\t\t&cli.Int64Flag{Name: \"call-limit\", Value: defaultCallLimit, Usage: \"daily phone call limit\"},\n\t\t\t\t&cli.Int64Flag{Name: \"reservation-limit\", Value: defaultReservationLimit, Usage: \"topic reservation limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-file-size-limit\", Value: defaultAttachmentFileSizeLimit, Usage: \"per-attachment file size limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-total-size-limit\", Value: defaultAttachmentTotalSizeLimit, Usage: \"total size limit of attachments for the user\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-expiry-duration\", Value: defaultAttachmentExpiryDuration, Usage: \"duration after which attachments are deleted\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-bandwidth-limit\", Value: defaultAttachmentBandwidthLimit, Usage: \"daily bandwidth limit for attachment uploads/downloads\"},\n\t\t\t\t&cli.StringFlag{Name: \"stripe-monthly-price-id\", Usage: \"Monthly Stripe price ID for paid tiers (e.g. price_12345)\"},\n\t\t\t\t&cli.StringFlag{Name: \"stripe-yearly-price-id\", Usage: \"Yearly Stripe price ID for paid tiers (e.g. price_12345)\"},\n\t\t\t\t&cli.BoolFlag{Name: \"ignore-exists\", Usage: \"if the tier already exists, perform no action and exit\"},\n\t\t\t},\n\t\t\tDescription: `Add a new tier to the ntfy user database.\n\nTiers can be used to grant users higher limits, such as daily message limits, attachment size, or\nmake it possible for users to reserve topics.\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExamples:\n  ntfy tier add pro                     # Add tier with code \"pro\", using the defaults\n  ntfy tier add \\                       # Add a tier with custom limits\n    --name=\"Pro\" \\\n    --message-limit=10000 \\\n    --message-expiry-duration=24h \\\n    --email-limit=50 \\\n    --reservation-limit=10 \\\n    --attachment-file-size-limit=100M \\\n    --attachment-total-size-limit=1G \\\n    --attachment-expiry-duration=12h \\\n    --attachment-bandwidth-limit=5G \\\n    pro\n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"change\",\n\t\t\tAliases:   []string{\"ch\"},\n\t\t\tUsage:     \"Change a tier\",\n\t\t\tUsageText: \"ntfy tier change [OPTIONS] CODE\",\n\t\t\tAction:    execTierChange,\n\t\t\tFlags: []cli.Flag{\n\t\t\t\t&cli.StringFlag{Name: \"name\", Usage: \"tier name\"},\n\t\t\t\t&cli.Int64Flag{Name: \"message-limit\", Usage: \"daily message limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"message-expiry-duration\", Usage: \"duration after which messages are deleted\"},\n\t\t\t\t&cli.Int64Flag{Name: \"email-limit\", Usage: \"daily email limit\"},\n\t\t\t\t&cli.Int64Flag{Name: \"call-limit\", Usage: \"daily phone call limit\"},\n\t\t\t\t&cli.Int64Flag{Name: \"reservation-limit\", Usage: \"topic reservation limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-file-size-limit\", Usage: \"per-attachment file size limit\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-total-size-limit\", Usage: \"total size limit of attachments for the user\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-expiry-duration\", Usage: \"duration after which attachments are deleted\"},\n\t\t\t\t&cli.StringFlag{Name: \"attachment-bandwidth-limit\", Usage: \"daily bandwidth limit for attachment uploads/downloads\"},\n\t\t\t\t&cli.StringFlag{Name: \"stripe-monthly-price-id\", Usage: \"Monthly Stripe price ID for paid tiers (e.g. price_12345)\"},\n\t\t\t\t&cli.StringFlag{Name: \"stripe-yearly-price-id\", Usage: \"Yearly Stripe price ID for paid tiers (e.g. price_12345)\"},\n\t\t\t},\n\t\t\tDescription: `Updates a tier to change the limits.\n\nAfter updating a tier, you may have to restart the ntfy server to apply them \nto all visitors. \n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExamples:\n  ntfy tier change --name=\"Pro\" pro        # Update the name of an existing tier\n  ntfy tier change \\                       # Update multiple limits and fields\n    --message-expiry-duration=24h \\\n    --stripe-monthly-price-id=price_1234 \\\n    --stripe-monthly-price-id=price_5678 \\\n    pro\n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"remove\",\n\t\t\tAliases:   []string{\"del\", \"rm\"},\n\t\t\tUsage:     \"Removes a tier\",\n\t\t\tUsageText: \"ntfy tier remove CODE\",\n\t\t\tAction:    execTierDel,\n\t\t\tDescription: `Remove a tier from the ntfy user database.\n\nYou cannot remove a tier if there are users associated with a tier. Use \"ntfy user change-tier\"\nto remove or switch their tier first.\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExample:\n  ntfy tier del pro\n`,\n\t\t},\n\t\t{\n\t\t\tName:    \"list\",\n\t\t\tAliases: []string{\"l\"},\n\t\t\tUsage:   \"Shows a list of tiers\",\n\t\t\tAction:  execTierList,\n\t\t\tDescription: `Shows a list of all configured tiers.\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n`,\n\t\t},\n\t},\n\tDescription: `Manage tiers of the ntfy server.\n\nThe command allows you to add/remove/change tiers in the ntfy user database. Tiers are used\nto grant users higher limits, such as daily message limits, attachment size, or make it \npossible for users to reserve topics.\n\nThis is a server-only command. It directly manages the user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExamples:\n  ntfy tier add pro                     # Add tier with code \"pro\", using the defaults\n  ntfy tier change --name=\"Pro\" pro     # Update the name of an existing tier\n  ntfy tier del pro                     # Delete an existing tier\n`,\n}\n\nfunc execTierAdd(c *cli.Context) error {\n\tcode := c.Args().Get(0)\n\tif code == \"\" {\n\t\treturn errors.New(\"tier code expected, type 'ntfy tier add --help' for help\")\n\t} else if !user.AllowedTier(code) {\n\t\treturn errors.New(\"tier code must consist only of numbers and letters\")\n\t} else if c.String(\"stripe-monthly-price-id\") != \"\" && c.String(\"stripe-yearly-price-id\") == \"\" {\n\t\treturn errors.New(\"if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set\")\n\t} else if c.String(\"stripe-monthly-price-id\") == \"\" && c.String(\"stripe-yearly-price-id\") != \"\" {\n\t\treturn errors.New(\"if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif tier, _ := manager.Tier(code); tier != nil {\n\t\tif c.Bool(\"ignore-exists\") {\n\t\t\tfmt.Fprintf(c.App.Writer, \"tier %s already exists (exited successfully)\\n\", code)\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tier %s already exists\", code)\n\t}\n\tname := c.String(\"name\")\n\tif name == \"\" {\n\t\tname = code\n\t}\n\tmessageExpiryDuration, err := util.ParseDuration(c.String(\"message-expiry-duration\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tattachmentFileSizeLimit, err := util.ParseSize(c.String(\"attachment-file-size-limit\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tattachmentTotalSizeLimit, err := util.ParseSize(c.String(\"attachment-total-size-limit\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tattachmentBandwidthLimit, err := util.ParseSize(c.String(\"attachment-bandwidth-limit\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tattachmentExpiryDuration, err := util.ParseDuration(c.String(\"attachment-expiry-duration\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\ttier := &user.Tier{\n\t\tID:                       \"\", // Generated\n\t\tCode:                     code,\n\t\tName:                     name,\n\t\tMessageLimit:             c.Int64(\"message-limit\"),\n\t\tMessageExpiryDuration:    messageExpiryDuration,\n\t\tEmailLimit:               c.Int64(\"email-limit\"),\n\t\tCallLimit:                c.Int64(\"call-limit\"),\n\t\tReservationLimit:         c.Int64(\"reservation-limit\"),\n\t\tAttachmentFileSizeLimit:  attachmentFileSizeLimit,\n\t\tAttachmentTotalSizeLimit: attachmentTotalSizeLimit,\n\t\tAttachmentExpiryDuration: attachmentExpiryDuration,\n\t\tAttachmentBandwidthLimit: attachmentBandwidthLimit,\n\t\tStripeMonthlyPriceID:     c.String(\"stripe-monthly-price-id\"),\n\t\tStripeYearlyPriceID:      c.String(\"stripe-yearly-price-id\"),\n\t}\n\tif err := manager.AddTier(tier); err != nil {\n\t\treturn err\n\t}\n\ttier, err = manager.Tier(code)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"tier added\\n\\n\")\n\tprintTier(c, tier)\n\treturn nil\n}\n\nfunc execTierChange(c *cli.Context) error {\n\tcode := c.Args().Get(0)\n\tif code == \"\" {\n\t\treturn errors.New(\"tier code expected, type 'ntfy tier change --help' for help\")\n\t} else if !user.AllowedTier(code) {\n\t\treturn errors.New(\"tier code must consist only of numbers and letters\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttier, err := manager.Tier(code)\n\tif err == user.ErrTierNotFound {\n\t\treturn fmt.Errorf(\"tier %s does not exist\", code)\n\t} else if err != nil {\n\t\treturn err\n\t}\n\tif c.IsSet(\"name\") {\n\t\ttier.Name = c.String(\"name\")\n\t}\n\tif c.IsSet(\"message-limit\") {\n\t\ttier.MessageLimit = c.Int64(\"message-limit\")\n\t}\n\tif c.IsSet(\"message-expiry-duration\") {\n\t\ttier.MessageExpiryDuration, err = util.ParseDuration(c.String(\"message-expiry-duration\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.IsSet(\"email-limit\") {\n\t\ttier.EmailLimit = c.Int64(\"email-limit\")\n\t}\n\tif c.IsSet(\"call-limit\") {\n\t\ttier.CallLimit = c.Int64(\"call-limit\")\n\t}\n\tif c.IsSet(\"reservation-limit\") {\n\t\ttier.ReservationLimit = c.Int64(\"reservation-limit\")\n\t}\n\tif c.IsSet(\"attachment-file-size-limit\") {\n\t\ttier.AttachmentFileSizeLimit, err = util.ParseSize(c.String(\"attachment-file-size-limit\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.IsSet(\"attachment-total-size-limit\") {\n\t\ttier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String(\"attachment-total-size-limit\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.IsSet(\"attachment-expiry-duration\") {\n\t\ttier.AttachmentExpiryDuration, err = util.ParseDuration(c.String(\"attachment-expiry-duration\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.IsSet(\"attachment-bandwidth-limit\") {\n\t\ttier.AttachmentBandwidthLimit, err = util.ParseSize(c.String(\"attachment-bandwidth-limit\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.IsSet(\"stripe-monthly-price-id\") {\n\t\ttier.StripeMonthlyPriceID = c.String(\"stripe-monthly-price-id\")\n\t}\n\tif c.IsSet(\"stripe-yearly-price-id\") {\n\t\ttier.StripeYearlyPriceID = c.String(\"stripe-yearly-price-id\")\n\t}\n\tif tier.StripeMonthlyPriceID != \"\" && tier.StripeYearlyPriceID == \"\" {\n\t\treturn errors.New(\"if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set\")\n\t} else if tier.StripeMonthlyPriceID == \"\" && tier.StripeYearlyPriceID != \"\" {\n\t\treturn errors.New(\"if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set\")\n\t}\n\tif err := manager.UpdateTier(tier); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"tier updated\\n\\n\")\n\tprintTier(c, tier)\n\treturn nil\n}\n\nfunc execTierDel(c *cli.Context) error {\n\tcode := c.Args().Get(0)\n\tif code == \"\" {\n\t\treturn errors.New(\"tier code expected, type 'ntfy tier del --help' for help\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := manager.Tier(code); err == user.ErrTierNotFound {\n\t\treturn fmt.Errorf(\"tier %s does not exist\", code)\n\t}\n\tif err := manager.RemoveTier(code); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"tier %s removed\\n\", code)\n\treturn nil\n}\n\nfunc execTierList(c *cli.Context) error {\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttiers, err := manager.Tiers()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, tier := range tiers {\n\t\tprintTier(c, tier)\n\t}\n\treturn nil\n}\n\nfunc printTier(c *cli.Context, tier *user.Tier) {\n\tprices := \"(none)\"\n\tif tier.StripeMonthlyPriceID != \"\" && tier.StripeYearlyPriceID != \"\" {\n\t\tprices = fmt.Sprintf(\"%s / %s\", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)\n\t}\n\tfmt.Fprintf(c.App.Writer, \"tier %s (id: %s)\\n\", tier.Code, tier.ID)\n\tfmt.Fprintf(c.App.Writer, \"- Name: %s\\n\", tier.Name)\n\tfmt.Fprintf(c.App.Writer, \"- Message limit: %d\\n\", tier.MessageLimit)\n\tfmt.Fprintf(c.App.Writer, \"- Message expiry duration: %s (%d seconds)\\n\", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))\n\tfmt.Fprintf(c.App.Writer, \"- Email limit: %d\\n\", tier.EmailLimit)\n\tfmt.Fprintf(c.App.Writer, \"- Phone call limit: %d\\n\", tier.CallLimit)\n\tfmt.Fprintf(c.App.Writer, \"- Reservation limit: %d\\n\", tier.ReservationLimit)\n\tfmt.Fprintf(c.App.Writer, \"- Attachment file size limit: %s\\n\", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))\n\tfmt.Fprintf(c.App.Writer, \"- Attachment total size limit: %s\\n\", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))\n\tfmt.Fprintf(c.App.Writer, \"- Attachment expiry duration: %s (%d seconds)\\n\", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))\n\tfmt.Fprintf(c.App.Writer, \"- Attachment daily bandwidth limit: %s\\n\", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))\n\tfmt.Fprintf(c.App.Writer, \"- Stripe prices (monthly/yearly): %s\\n\", prices)\n}\n"
  },
  {
    "path": "cmd/tier_test.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"testing\"\n)\n\nfunc TestCLI_Tier_AddListChangeDelete(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, runTierCommand(app, conf, \"add\", \"--name\", \"Pro\", \"--message-limit\", \"1234\", \"pro\"))\n\trequire.Contains(t, stdout.String(), \"tier added\\n\\ntier pro (id: ti_\")\n\n\terr := runTierCommand(app, conf, \"add\", \"pro\")\n\trequire.NotNil(t, err)\n\trequire.Equal(t, \"tier pro already exists\", err.Error())\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTierCommand(app, conf, \"list\"))\n\trequire.Contains(t, stdout.String(), \"tier pro (id: ti_\")\n\trequire.Contains(t, stdout.String(), \"- Name: Pro\")\n\trequire.Contains(t, stdout.String(), \"- Message limit: 1234\")\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTierCommand(app, conf, \"change\",\n\t\t\"--message-limit=999\",\n\t\t\"--message-expiry-duration=2d\",\n\t\t\"--email-limit=91\",\n\t\t\"--reservation-limit=98\",\n\t\t\"--attachment-file-size-limit=100m\",\n\t\t\"--attachment-expiry-duration=1d\",\n\t\t\"--attachment-total-size-limit=10G\",\n\t\t\"--attachment-bandwidth-limit=100G\",\n\t\t\"--stripe-monthly-price-id=price_991\",\n\t\t\"--stripe-yearly-price-id=price_992\",\n\t\t\"pro\",\n\t))\n\trequire.Contains(t, stdout.String(), \"- Message limit: 999\")\n\trequire.Contains(t, stdout.String(), \"- Message expiry duration: 48h\")\n\trequire.Contains(t, stdout.String(), \"- Email limit: 91\")\n\trequire.Contains(t, stdout.String(), \"- Reservation limit: 98\")\n\trequire.Contains(t, stdout.String(), \"- Attachment file size limit: 100.0 MB\")\n\trequire.Contains(t, stdout.String(), \"- Attachment expiry duration: 24h\")\n\trequire.Contains(t, stdout.String(), \"- Attachment total size limit: 10.0 GB\")\n\trequire.Contains(t, stdout.String(), \"- Stripe prices (monthly/yearly): price_991 / price_992\")\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTierCommand(app, conf, \"remove\", \"pro\"))\n\trequire.Contains(t, stdout.String(), \"tier pro removed\")\n}\n\nfunc runTierCommand(app *cli.App, conf *server.Config, args ...string) error {\n\tuserArgs := []string{\n\t\t\"ntfy\",\n\t\t\"--log-level=ERROR\",\n\t\t\"tier\",\n\t\t\"--config=\" + conf.File, // Dummy config file to avoid lookups of real file\n\t\t\"--auth-file=\" + conf.AuthFile,\n\t\t\"--auth-default-access=\" + conf.AuthDefault.String(),\n\t}\n\treturn app.Run(append(userArgs, args...))\n}\n"
  },
  {
    "path": "cmd/token.go",
    "content": "//go:build !noserver\n\npackage cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"net/netip\"\n\t\"time\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdToken)\n}\n\nvar flagsToken = append([]cli.Flag{}, flagsUser...)\n\nvar cmdToken = &cli.Command{\n\tName:      \"token\",\n\tUsage:     \"Create, list or delete user tokens\",\n\tUsageText: \"ntfy token [list|add|remove] ...\",\n\tFlags:     flagsToken,\n\tBefore:    initConfigFileInputSourceFunc(\"config\", flagsToken, initLogFunc),\n\tCategory:  categoryServer,\n\tSubcommands: []*cli.Command{\n\t\t{\n\t\t\tName:      \"add\",\n\t\t\tAliases:   []string{\"a\"},\n\t\t\tUsage:     \"Create a new token\",\n\t\t\tUsageText: \"ntfy token add [--expires=<duration>] [--label=..] USERNAME\",\n\t\t\tAction:    execTokenAdd,\n\t\t\tFlags: []cli.Flag{\n\t\t\t\t&cli.StringFlag{Name: \"expires\", Aliases: []string{\"e\"}, Value: \"\", Usage: \"token expires after\"},\n\t\t\t\t&cli.StringFlag{Name: \"label\", Aliases: []string{\"l\"}, Value: \"\", Usage: \"token label\"},\n\t\t\t},\n\t\t\tDescription: `Create a new user access token.\n\nUser access tokens can be used to publish, subscribe, or perform any other user-specific tasks.\nTokens have full access, and can perform any task a user can do. They are meant to be used to \navoid spreading the password to various places.\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExamples:\n  ntfy token add phil                   # Create token for user phil which never expires\n  ntfy token add --expires=2d phil      # Create token for user phil which expires in 2 days\n  ntfy token add -e \"tuesday, 8pm\" phil # Create token for user phil which expires next Tuesday\n  ntfy token add -l backups phil        # Create token for user phil with label \"backups\"`,\n\t\t},\n\t\t{\n\t\t\tName:      \"remove\",\n\t\t\tAliases:   []string{\"del\", \"rm\"},\n\t\t\tUsage:     \"Removes a token\",\n\t\t\tUsageText: \"ntfy token remove USERNAME TOKEN\",\n\t\t\tAction:    execTokenDel,\n\t\t\tDescription: `Remove a token from the ntfy user database.\n\nExample:\n  ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`,\n\t\t},\n\t\t{\n\t\t\tName:    \"list\",\n\t\t\tAliases: []string{\"l\"},\n\t\t\tUsage:   \"Shows a list of tokens\",\n\t\t\tAction:  execTokenList,\n\t\t\tDescription: `Shows a list of all tokens.\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.`,\n\t\t},\n\t\t{\n\t\t\tName:   \"generate\",\n\t\t\tUsage:  \"Generates a random token\",\n\t\t\tAction: execTokenGenerate,\n\t\t\tDescription: `Randomly generate a token to be used in provisioned tokens.\n\nThis command only generates the token value, but does not persist it anywhere.\nThe output can be used in the 'auth-tokens' config option.`,\n\t\t},\n\t},\n\tDescription: `Manage access tokens for individual users.\n\nUser access tokens can be used to publish, subscribe, or perform any other user-specific tasks.\nTokens have full access, and can perform any task a user can do. They are meant to be used to \navoid spreading the password to various places.\n\nThis is a server-only command. It directly manages the user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n\nExamples:\n  ntfy token list                               # Shows list of tokens for all users\n  ntfy token list phil                          # Shows list of tokens for user phil\n  ntfy token add phil                           # Create token for user phil which never expires\n  ntfy token add --expires=2d phil              # Create token for user phil which expires in 2 days\n  ntfy token remove phil tk_th2srHVlxr...       # Delete token`,\n}\n\nfunc execTokenAdd(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\texpiresStr := c.String(\"expires\")\n\tlabel := c.String(\"label\")\n\tif username == \"\" {\n\t\treturn errors.New(\"username expected, type 'ntfy token add --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\texpires := time.Unix(0, 0)\n\tif expiresStr != \"\" {\n\t\tvar err error\n\t\texpires, err = util.ParseFutureTime(expiresStr, time.Now())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := manager.User(username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t} else if err != nil {\n\t\treturn err\n\t}\n\ttoken, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif expires.Unix() == 0 {\n\t\tfmt.Fprintf(c.App.Writer, \"token %s created for user %s, never expires\\n\", token.Value, u.Name)\n\t} else {\n\t\tfmt.Fprintf(c.App.Writer, \"token %s created for user %s, expires %v\\n\", token.Value, u.Name, expires.Format(time.UnixDate))\n\t}\n\treturn nil\n}\n\nfunc execTokenDel(c *cli.Context) error {\n\tusername, token := c.Args().Get(0), c.Args().Get(1)\n\tif username == \"\" || token == \"\" {\n\t\treturn errors.New(\"username and token expected, type 'ntfy token remove --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := manager.User(username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t} else if err != nil {\n\t\treturn err\n\t}\n\tif err := manager.RemoveToken(u.ID, token); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"token %s for user %s removed\\n\", token, username)\n\treturn nil\n}\n\nfunc execTokenList(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\tif username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar users []*user.User\n\tif username != \"\" {\n\t\tu, err := manager.User(username)\n\t\tif errors.Is(err, user.ErrUserNotFound) {\n\t\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t\t} else if err != nil {\n\t\t\treturn err\n\t\t}\n\t\tusers = append(users, u)\n\t} else {\n\t\tusers, err = manager.Users()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tusersWithTokens := 0\n\tfor _, u := range users {\n\t\ttokens, err := manager.Tokens(u.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if len(tokens) == 0 && username != \"\" {\n\t\t\tfmt.Fprintf(c.App.Writer, \"user %s has no access tokens\\n\", username)\n\t\t\treturn nil\n\t\t} else if len(tokens) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tusersWithTokens++\n\t\tfmt.Fprintf(c.App.Writer, \"user %s\\n\", u.Name)\n\t\tfor _, t := range tokens {\n\t\t\tvar label, expires, provisioned string\n\t\t\tif t.Label != \"\" {\n\t\t\t\tlabel = fmt.Sprintf(\" (%s)\", t.Label)\n\t\t\t}\n\t\t\tif t.Expires.Unix() == 0 {\n\t\t\t\texpires = \"never expires\"\n\t\t\t} else {\n\t\t\t\texpires = fmt.Sprintf(\"expires %s\", t.Expires.Format(time.RFC822))\n\t\t\t}\n\t\t\tif t.Provisioned {\n\t\t\t\tprovisioned = \" (server config)\"\n\t\t\t}\n\t\t\tfmt.Fprintf(c.App.Writer, \"- %s%s, %s, accessed from %s at %s%s\\n\", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned)\n\t\t}\n\t}\n\tif usersWithTokens == 0 {\n\t\tfmt.Fprintf(c.App.Writer, \"no users with tokens\\n\")\n\t}\n\treturn nil\n}\n\nfunc execTokenGenerate(c *cli.Context) error {\n\tfmt.Fprintln(c.App.Writer, user.GenerateToken())\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/token_test.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"regexp\"\n\t\"testing\"\n)\n\nfunc TestCLI_Token_AddListRemove(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTokenCommand(app, conf, \"add\", \"phil\"))\n\trequire.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String())\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTokenCommand(app, conf, \"list\", \"phil\"))\n\trequire.Regexp(t, `user phil\\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String())\n\tre := regexp.MustCompile(`tk_\\w+`)\n\ttoken := re.FindString(stdout.String())\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTokenCommand(app, conf, \"remove\", \"phil\", token))\n\trequire.Regexp(t, fmt.Sprintf(\"token %s for user phil removed\", token), stdout.String())\n\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runTokenCommand(app, conf, \"list\"))\n\trequire.Equal(t, \"no users with tokens\\n\", stdout.String())\n}\n\nfunc runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {\n\tuserArgs := []string{\n\t\t\"ntfy\",\n\t\t\"--log-level=ERROR\",\n\t\t\"token\",\n\t\t\"--config=\" + conf.File, // Dummy config file to avoid lookups of real file\n\t\t\"--auth-file=\" + conf.AuthFile,\n\t}\n\treturn app.Run(append(userArgs, args...))\n}\n"
  },
  {
    "path": "cmd/user.go",
    "content": "//go:build !noserver\n\npackage cmd\n\nimport (\n\t\"crypto/subtle\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/db/pg\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\ttierReset = \"-\"\n)\n\nfunc init() {\n\tcommands = append(commands, cmdUser)\n}\n\nvar flagsUser = append(\n\tappend([]cli.Flag{}, flagsDefault...),\n\t&cli.StringFlag{Name: \"config\", Aliases: []string{\"c\"}, EnvVars: []string{\"NTFY_CONFIG_FILE\"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: \"config file\"},\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-file\", Aliases: []string{\"auth_file\", \"H\"}, EnvVars: []string{\"NTFY_AUTH_FILE\"}, Usage: \"auth database file used for access control\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-default-access\", Aliases: []string{\"auth_default_access\", \"p\"}, EnvVars: []string{\"NTFY_AUTH_DEFAULT_ACCESS\"}, Value: \"read-write\", Usage: \"default permissions if no matching entries in the auth database are found\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"database-url\", Aliases: []string{\"database_url\"}, EnvVars: []string{\"NTFY_DATABASE_URL\"}, Usage: \"PostgreSQL connection string for database-backed stores\"}),\n)\n\nvar cmdUser = &cli.Command{\n\tName:      \"user\",\n\tUsage:     \"Manage/show users\",\n\tUsageText: \"ntfy user [list|add|remove|change-pass|change-role] ...\",\n\tFlags:     flagsUser,\n\tBefore:    initConfigFileInputSourceFunc(\"config\", flagsUser, initLogFunc),\n\tCategory:  categoryServer,\n\tSubcommands: []*cli.Command{\n\t\t{\n\t\t\tName:      \"add\",\n\t\t\tAliases:   []string{\"a\"},\n\t\t\tUsage:     \"Adds a new user\",\n\t\t\tUsageText: \"ntfy user add [--role=admin|user] USERNAME\\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME\\nNTFY_PASSWORD_HASH=... ntfy user add [--role=admin|user] USERNAME\",\n\t\t\tAction:    execUserAdd,\n\t\t\tFlags: []cli.Flag{\n\t\t\t\t&cli.StringFlag{Name: \"role\", Aliases: []string{\"r\"}, Value: string(user.RoleUser), Usage: \"user role\"},\n\t\t\t\t&cli.BoolFlag{Name: \"ignore-exists\", Usage: \"if the user already exists, perform no action and exit\"},\n\t\t\t},\n\t\t\tDescription: `Add a new user to the ntfy user database.\n\nA user can be either a regular user, or an admin. A regular user has no read or write access (unless\ngranted otherwise by the auth-default-access setting). An admin user has read and write access to all\ntopics.\n\nExamples:\n  ntfy user add phil                          # Add regular user phil\n  ntfy user add --role=admin phil             # Add admin user phil\n  NTFY_PASSWORD=... ntfy user add phil        # Add user, using env variable to set password (for scripts)\n  NTFY_PASSWORD_HASH=... ntfy user add phil   # Add user, using env variable to set password hash (for scripts)\n\nYou may set the NTFY_PASSWORD environment variable to pass the password, or NTFY_PASSWORD_HASH to pass\ndirectly the bcrypt hash. This is useful if you are creating users via scripts.\n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"remove\",\n\t\t\tAliases:   []string{\"del\", \"rm\"},\n\t\t\tUsage:     \"Removes a user\",\n\t\t\tUsageText: \"ntfy user remove USERNAME\",\n\t\t\tAction:    execUserDel,\n\t\t\tDescription: `Remove a user from the ntfy user database.\n\nExample:\n  ntfy user del phil\n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"change-pass\",\n\t\t\tAliases:   []string{\"chp\"},\n\t\t\tUsage:     \"Changes a user's password\",\n\t\t\tUsageText: \"ntfy user change-pass USERNAME\\nNTFY_PASSWORD=... ntfy user change-pass USERNAME\\nNTFY_PASSWORD_HASH=... ntfy user change-pass USERNAME\",\n\t\t\tAction:    execUserChangePass,\n\t\t\tDescription: `Change the password for the given user.\n\nThe new password will be read from STDIN, and it'll be confirmed by typing\nit twice. \n\nExample:\n  ntfy user change-pass phil\n  NTFY_PASSWORD=.. ntfy user change-pass phil\n  NTFY_PASSWORD_HASH=.. ntfy user change-pass phil\n\nYou may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass\ndirectly the bcrypt hash. This is useful if you are updating users via scripts.\n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"change-role\",\n\t\t\tAliases:   []string{\"chr\"},\n\t\t\tUsage:     \"Changes the role of a user\",\n\t\t\tUsageText: \"ntfy user change-role USERNAME ROLE\",\n\t\t\tAction:    execUserChangeRole,\n\t\t\tDescription: `Change the role for the given user to admin or user.\n\nThis command can be used to change the role of a user either from a regular user\nto an admin user, or the other way around:\n\n- admin: an admin has read/write access to all topics\n- user: a regular user only has access to what was explicitly granted via 'ntfy access'\n\nWhen changing the role of a user to \"admin\", all access control entries for that \nuser are removed, since they are no longer necessary.\n\nExample:\n  ntfy user change-role phil admin   # Make user phil an admin \n  ntfy user change-role phil user    # Remove admin role from user phil \n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"change-tier\",\n\t\t\tAliases:   []string{\"cht\"},\n\t\t\tUsage:     \"Changes the tier of a user\",\n\t\t\tUsageText: \"ntfy user change-tier USERNAME (TIER|-)\",\n\t\t\tAction:    execUserChangeTier,\n\t\t\tDescription: `Change the tier for the given user.\n\nThis command can be used to change the tier of a user. Tiers define usage limits, such\nas messages per day, attachment file sizes, etc.\n\nExample:\n  ntfy user change-tier phil pro   # Change tier to \"pro\" for user \"phil\"  \n  ntfy user change-tier phil -     # Remove tier from user \"phil\" entirely \n`,\n\t\t},\n\t\t{\n\t\t\tName:      \"hash\",\n\t\t\tUsage:     \"Create password hash for a predefined user\",\n\t\t\tUsageText: \"ntfy user hash\",\n\t\t\tAction:    execUserHash,\n\t\t\tDescription: `Asks for a password and creates a bcrypt password hash.\n\nThis command is useful to create a password hash for a user, which can then be used\nfor predefined users in the server config file, in auth-users.\n\nExample:\n  $ ntfy user hash\n  (asks for password and confirmation)\n  $2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\n`,\n\t\t},\n\t\t{\n\t\t\tName:    \"list\",\n\t\t\tAliases: []string{\"l\"},\n\t\t\tUsage:   \"Shows a list of users\",\n\t\t\tAction:  execUserList,\n\t\t\tDescription: `Shows a list of all configured users, including the everyone ('*') user.\n\nThis command is an alias to calling 'ntfy access' (display access control list).\n\nThis is a server-only command. It directly reads from user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined.\n`,\n\t\t},\n\t},\n\tDescription: `Manage users of the ntfy server.\n\nThe command allows you to add/remove/change users in the ntfy user database, as well as change \npasswords or roles.\n\nThis is a server-only command. It directly manages the user.db as defined in the server config\nfile server.yml. The command only works if 'auth-file' is properly defined. Please also refer\nto the related command 'ntfy access'.\n\nExamples:\n  ntfy user list                               # Shows list of users (alias: 'ntfy access')                      \n  ntfy user add phil                           # Add regular user phil  \n  NTFY_PASSWORD=... ntfy user add phil         # As above, using env variable to set password (for scripts)\n  ntfy user add --role=admin phil              # Add admin user phil\n  ntfy user del phil                           # Delete user phil\n  ntfy user change-pass phil                   # Change password for user phil\n  NTFY_PASSWORD=.. ntfy user change-pass phil  # As above, using env variable to set password (for scripts)\n  ntfy user change-role phil admin             # Make user phil an admin \n\nFor the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment\nvariable to pass the new password. This is useful if you are creating/updating users via scripts.\n`,\n}\n\nfunc execUserAdd(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\trole := user.Role(c.String(\"role\"))\n\tpassword, hashed := os.LookupEnv(\"NTFY_PASSWORD_HASH\")\n\n\tif !hashed {\n\t\tpassword = os.Getenv(\"NTFY_PASSWORD\")\n\t}\n\n\tif username == \"\" {\n\t\treturn errors.New(\"username expected, type 'ntfy user add --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t} else if !user.AllowedRole(role) {\n\t\treturn errors.New(\"role must be either 'user' or 'admin'\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif user, _ := manager.User(username); user != nil {\n\t\tif c.Bool(\"ignore-exists\") {\n\t\t\tfmt.Fprintf(c.App.Writer, \"user %s already exists (exited successfully)\\n\", username)\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"user %s already exists\", username)\n\t}\n\tif password == \"\" {\n\t\tp, err := readPasswordAndConfirm(c)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpassword = p\n\t}\n\tif err := manager.AddUser(username, password, role, hashed); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"user %s added with role %s\\n\", username, role)\n\treturn nil\n}\n\nfunc execUserDel(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\tif username == \"\" {\n\t\treturn errors.New(\"username expected, type 'ntfy user del --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t}\n\tif err := manager.RemoveUser(username); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"user %s removed\\n\", username)\n\treturn nil\n}\n\nfunc execUserChangePass(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\tpassword, hashed := os.LookupEnv(\"NTFY_PASSWORD_HASH\")\n\n\tif !hashed {\n\t\tpassword = os.Getenv(\"NTFY_PASSWORD\")\n\t}\n\tif username == \"\" {\n\t\treturn errors.New(\"username expected, type 'ntfy user change-pass --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t}\n\tif password == \"\" {\n\t\tpassword, err = readPasswordAndConfirm(c)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := manager.ChangePassword(username, password, hashed); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"changed password for user %s\\n\", username)\n\treturn nil\n}\n\nfunc execUserChangeRole(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\trole := user.Role(c.Args().Get(1))\n\tif username == \"\" || !user.AllowedRole(role) {\n\t\treturn errors.New(\"username and new role expected, type 'ntfy user change-role --help' for help\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t}\n\tif err := manager.ChangeRole(username, role); err != nil {\n\t\treturn err\n\t}\n\tfmt.Fprintf(c.App.Writer, \"changed role for user %s to %s\\n\", username, role)\n\treturn nil\n}\n\nfunc execUserHash(c *cli.Context) error {\n\tpassword, err := readPasswordAndConfirm(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\thash, err := user.HashPassword(password)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to hash password: %w\", err)\n\t}\n\tfmt.Fprintln(c.App.Writer, hash)\n\treturn nil\n}\n\nfunc execUserChangeTier(c *cli.Context) error {\n\tusername := c.Args().Get(0)\n\ttier := c.Args().Get(1)\n\tif username == \"\" {\n\t\treturn errors.New(\"username and new tier expected, type 'ntfy user change-tier --help' for help\")\n\t} else if !user.AllowedTier(tier) && tier != tierReset {\n\t\treturn errors.New(\"invalid tier, must be tier code, or - to reset\")\n\t} else if username == userEveryone || username == user.Everyone {\n\t\treturn errors.New(\"username not allowed\")\n\t}\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {\n\t\treturn fmt.Errorf(\"user %s does not exist\", username)\n\t}\n\tif tier == tierReset {\n\t\tif err := manager.ResetTier(username); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfmt.Fprintf(c.App.Writer, \"removed tier from user %s\\n\", username)\n\t} else {\n\t\tif err := manager.ChangeTier(username, tier); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfmt.Fprintf(c.App.Writer, \"changed tier for user %s to %s\\n\", username, tier)\n\t}\n\treturn nil\n}\n\nfunc execUserList(c *cli.Context) error {\n\tmanager, err := createUserManager(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tusers, err := manager.Users()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn showUsers(c, manager, users)\n}\n\nfunc createUserManager(c *cli.Context) (*user.Manager, error) {\n\tauthFile := c.String(\"auth-file\")\n\tauthStartupQueries := c.String(\"auth-startup-queries\")\n\tauthDefaultAccess := c.String(\"auth-default-access\")\n\tdatabaseURL := c.String(\"database-url\")\n\tauthDefault, err := user.ParsePermission(authDefaultAccess)\n\tif err != nil {\n\t\treturn nil, errors.New(\"if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'\")\n\t}\n\tauthConfig := &user.Config{\n\t\tDefaultAccess:       authDefault,\n\t\tProvisionEnabled:    false, // Hack: Do not re-provision users on manager initialization\n\t\tBcryptCost:          user.DefaultUserPasswordBcryptCost,\n\t\tQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,\n\t}\n\tif databaseURL != \"\" {\n\t\thost, dbErr := pg.Open(databaseURL)\n\t\tif dbErr != nil {\n\t\t\treturn nil, dbErr\n\t\t}\n\t\treturn user.NewPostgresManager(db.New(host, nil), authConfig)\n\t} else if authFile != \"\" {\n\t\tif !util.FileExists(authFile) {\n\t\t\treturn nil, errors.New(\"auth-file does not exist; please start the server at least once to create it\")\n\t\t}\n\t\treturn user.NewSQLiteManager(authFile, authStartupQueries, authConfig)\n\t}\n\treturn nil, errors.New(\"option database-url or auth-file not set; auth is unconfigured for this server\")\n}\n\nfunc readPasswordAndConfirm(c *cli.Context) (string, error) {\n\tfmt.Fprint(c.App.ErrWriter, \"password: \")\n\tpassword, err := util.ReadPassword(c.App.Reader)\n\tif err != nil {\n\t\treturn \"\", err\n\t} else if len(password) == 0 {\n\t\treturn \"\", errors.New(\"password cannot be empty\")\n\t}\n\tfmt.Fprintf(c.App.ErrWriter, \"\\r%s\\rconfirm: \", strings.Repeat(\" \", 25))\n\tconfirm, err := util.ReadPassword(c.App.Reader)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tfmt.Fprintf(c.App.ErrWriter, \"\\r%s\\r\", strings.Repeat(\" \", 25))\n\tif subtle.ConstantTimeCompare(confirm, password) != 1 {\n\t\treturn \"\", errors.New(\"passwords do not match: try it again, but this time type slooowwwlly\")\n\t}\n\treturn string(password), nil\n}\n"
  },
  {
    "path": "cmd/user_test.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"heckel.io/ntfy/v2/test\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestCLI_User_Add(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n}\n\nfunc TestCLI_User_Add_Exists(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n\n\tapp, stdin, _, _ = newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\terr := runUserCommand(app, conf, \"add\", \"phil\")\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"user phil already exists\")\n}\n\nfunc TestCLI_User_Add_Admin(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"--role=admin\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role admin\")\n}\n\nfunc TestCLI_User_Add_Password_Mismatch(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\tapp, stdin, _, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nNOTMATCH\")\n\terr := runUserCommand(app, conf, \"add\", \"phil\")\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"passwords do not match: try it again, but this time type slooowwwlly\")\n}\n\nfunc TestCLI_User_ChangePass(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tconf.AuthUsers = []*user.User{\n\t\t{Name: \"philuser\", Hash: \"$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC\", Role: user.RoleUser}, // philuser:philpass\n\t}\n\tdefer test.StopServer(t, s, port)\n\n\t// Add user\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n\n\t// Change pass\n\tapp, stdin, stdout, _ = newTestApp()\n\tstdin.WriteString(\"newpass\\nnewpass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"change-pass\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"changed password for user phil\")\n\n\t// Cannot change provisioned user's pass\n\tapp, stdin, _, _ = newTestApp()\n\tstdin.WriteString(\"newpass\\nnewpass\")\n\trequire.Error(t, runUserCommand(app, conf, \"change-pass\", \"philuser\"))\n}\n\nfunc TestCLI_User_ChangeRole(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\t// Add user\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n\n\t// Change role\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runUserCommand(app, conf, \"change-role\", \"phil\", \"admin\"))\n\trequire.Contains(t, stdout.String(), \"changed role for user phil to admin\")\n}\n\nfunc TestCLI_User_Delete(t *testing.T) {\n\ts, conf, port := newTestServerWithAuth(t)\n\tdefer test.StopServer(t, s, port)\n\n\t// Add user\n\tapp, stdin, stdout, _ := newTestApp()\n\tstdin.WriteString(\"mypass\\nmypass\")\n\trequire.Nil(t, runUserCommand(app, conf, \"add\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil added with role user\")\n\n\t// Delete user\n\tapp, _, stdout, _ = newTestApp()\n\trequire.Nil(t, runUserCommand(app, conf, \"del\", \"phil\"))\n\trequire.Contains(t, stdout.String(), \"user phil removed\")\n\n\t// Delete user again (does not exist)\n\tapp, _, _, _ = newTestApp()\n\terr := runUserCommand(app, conf, \"del\", \"phil\")\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"user phil does not exist\")\n}\n\nfunc newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {\n\tconfigFile := filepath.Join(t.TempDir(), \"server-dummy.yml\")\n\trequire.Nil(t, os.WriteFile(configFile, []byte(\"\"), 0600)) // Dummy config file to avoid lookup of real server.yml\n\tconf = server.NewConfig()\n\tconf.File = configFile\n\tconf.AuthFile = filepath.Join(t.TempDir(), \"user.db\")\n\tconf.AuthDefault = user.PermissionDenyAll\n\ts, port = test.StartServerWithConfig(t, conf)\n\treturn\n}\n\nfunc runUserCommand(app *cli.App, conf *server.Config, args ...string) error {\n\tuserArgs := []string{\n\t\t\"ntfy\",\n\t\t\"--log-level=ERROR\",\n\t\t\"user\",\n\t\t\"--config=\" + conf.File, // Dummy config file to avoid lookups of real file\n\t\t\"--auth-file=\" + conf.AuthFile,\n\t\t\"--auth-default-access=\" + conf.AuthDefault.String(),\n\t}\n\treturn app.Run(append(userArgs, args...))\n}\n"
  },
  {
    "path": "cmd/webpush.go",
    "content": "//go:build !noserver && !nowebpush\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/SherClockHolmes/webpush-go\"\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n)\n\nvar flagsWebPush = append(\n\t[]cli.Flag{},\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"output-file\", Aliases: []string{\"f\"}, Usage: \"write VAPID keys to this file\"}),\n)\n\nfunc init() {\n\tcommands = append(commands, cmdWebPush)\n}\n\nvar cmdWebPush = &cli.Command{\n\tName:      \"webpush\",\n\tUsage:     \"Generate keys, in the future manage web push subscriptions\",\n\tUsageText: \"ntfy webpush [keys]\",\n\tCategory:  categoryServer,\n\n\tSubcommands: []*cli.Command{\n\t\t{\n\t\t\tAction:    generateWebPushKeys,\n\t\t\tName:      \"keys\",\n\t\t\tUsage:     \"Generate VAPID keys to enable browser background push notifications\",\n\t\t\tUsageText: \"ntfy webpush keys\",\n\t\t\tCategory:  categoryServer,\n\t\t\tFlags:     flagsWebPush,\n\t\t},\n\t},\n}\n\nfunc generateWebPushKeys(c *cli.Context) error {\n\tprivateKey, publicKey, err := webpush.GenerateVAPIDKeys()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif outputFile := c.String(\"output-file\"); outputFile != \"\" {\n\t\tcontents := fmt.Sprintf(`---\nweb-push-public-key: %s\nweb-push-private-key: %s\n`, publicKey, privateKey)\n\t\terr = os.WriteFile(outputFile, []byte(contents), 0660)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = fmt.Fprintf(c.App.Writer, \"Web Push keys written to %s.\\n\", outputFile)\n\t} else {\n\t\t_, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:\n\nweb-push-public-key: %s\nweb-push-private-key: %s\nweb-push-file: /var/cache/ntfy/webpush.db # or similar\nweb-push-email-address: <email address>\n\nSee https://ntfy.sh/docs/config/#web-push for details.\n`, publicKey, privateKey)\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "cmd/webpush_test.go",
    "content": "package cmd\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/server\"\n)\n\nfunc TestCLI_WebPush_GenerateKeys(t *testing.T) {\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, runWebPushCommand(app, server.NewConfig(), \"keys\"))\n\trequire.Contains(t, stdout.String(), \"Web Push keys generated.\")\n}\n\nfunc TestCLI_WebPush_WriteKeysToFile(t *testing.T) {\n\ttempDir := t.TempDir()\n\tt.Chdir(tempDir)\n\tapp, _, stdout, _ := newTestApp()\n\trequire.Nil(t, runWebPushCommand(app, server.NewConfig(), \"keys\", \"--output-file=key-file.yaml\"))\n\trequire.Contains(t, stdout.String(), \"Web Push keys written to key-file.yaml\")\n\trequire.FileExists(t, filepath.Join(tempDir, \"key-file.yaml\"))\n}\n\nfunc runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {\n\twebPushArgs := []string{\n\t\t\"ntfy\",\n\t\t\"--log-level=ERROR\",\n\t\t\"webpush\",\n\t}\n\treturn app.Run(append(webPushArgs, args...))\n}\n"
  },
  {
    "path": "db/db.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n)\n\nconst (\n\ttag                            = \"db\"\n\treplicaHealthCheckInitialDelay = 5 * time.Second\n\treplicaHealthCheckInterval     = 30 * time.Second\n\treplicaHealthCheckTimeout      = 10 * time.Second\n)\n\n// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods\n// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica\n// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.\ntype DB struct {\n\tprimary  *Host\n\treplicas []*Host\n\tcounter  atomic.Uint64\n\tcancel   context.CancelFunc\n}\n\n// New creates a new DB that wraps the given primary and optional replica connections.\n// If replicas is nil or empty, ReadOnly() simply returns the primary.\n// Replicas start unhealthy and are checked immediately by a background goroutine.\nfunc New(primary *Host, replicas []*Host) *DB {\n\tctx, cancel := context.WithCancel(context.Background())\n\td := &DB{\n\t\tprimary:  primary,\n\t\treplicas: replicas,\n\t\tcancel:   cancel,\n\t}\n\tif len(d.replicas) > 0 {\n\t\tgo d.healthCheckLoop(ctx)\n\t}\n\treturn d\n}\n\n// Query delegates to the primary database.\nfunc (d *DB) Query(query string, args ...any) (*sql.Rows, error) {\n\treturn d.primary.DB.Query(query, args...)\n}\n\n// QueryRow delegates to the primary database.\nfunc (d *DB) QueryRow(query string, args ...any) *sql.Row {\n\treturn d.primary.DB.QueryRow(query, args...)\n}\n\n// Exec delegates to the primary database.\nfunc (d *DB) Exec(query string, args ...any) (sql.Result, error) {\n\treturn d.primary.DB.Exec(query, args...)\n}\n\n// Begin delegates to the primary database.\nfunc (d *DB) Begin() (*sql.Tx, error) {\n\treturn d.primary.DB.Begin()\n}\n\n// Ping delegates to the primary database.\nfunc (d *DB) Ping() error {\n\treturn d.primary.DB.Ping()\n}\n\n// Primary returns the underlying primary *sql.DB. This is only intended for\n// one-time schema setup during store initialization, not for regular queries.\nfunc (d *DB) Primary() *sql.DB {\n\treturn d.primary.DB\n}\n\n// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy\n// replicas. If all replicas are unhealthy or none are configured, the primary is returned.\nfunc (d *DB) ReadOnly() *sql.DB {\n\tif len(d.replicas) == 0 {\n\t\treturn d.primary.DB\n\t}\n\tn := len(d.replicas)\n\tstart := int(d.counter.Add(1) - 1)\n\tfor i := 0; i < n; i++ {\n\t\tr := d.replicas[(start+i)%n]\n\t\tif r.healthy.Load() {\n\t\t\treturn r.DB\n\t\t}\n\t}\n\treturn d.primary.DB\n}\n\n// Close closes the primary database and all replicas, and stops the health-check goroutine.\nfunc (d *DB) Close() error {\n\td.cancel()\n\tfor _, r := range d.replicas {\n\t\tr.DB.Close()\n\t}\n\treturn d.primary.DB.Close()\n}\n\n// healthCheckLoop checks replicas immediately, then periodically on a ticker.\nfunc (d *DB) healthCheckLoop(ctx context.Context) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn\n\tcase <-time.After(replicaHealthCheckInitialDelay):\n\t\td.checkReplicas(ctx)\n\t}\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(replicaHealthCheckInterval):\n\t\t\td.checkReplicas(ctx)\n\t\t}\n\t}\n}\n\n// checkReplicas pings each replica with a timeout and updates its health status.\nfunc (d *DB) checkReplicas(ctx context.Context) {\n\tfor _, r := range d.replicas {\n\t\twasHealthy := r.healthy.Load()\n\t\tpingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout)\n\t\terr := r.DB.PingContext(pingCtx)\n\t\tcancel()\n\t\tif err != nil {\n\t\t\tr.healthy.Store(false)\n\t\t\tlog.Tag(tag).Error(\"Database replica %s is unhealthy: %s\", r.Addr, err)\n\t\t} else {\n\t\t\tr.healthy.Store(true)\n\t\t\tif !wasHealthy {\n\t\t\t\tlog.Tag(tag).Info(\"Database replica %s is healthy\", r.Addr)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "db/pg/pg.go",
    "content": "package pg\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t_ \"github.com/jackc/pgx/v5/stdlib\" // PostgreSQL driver\n\n\t\"heckel.io/ntfy/v2/db\"\n)\n\n// Open opens a PostgreSQL connection pool for a primary database. It pings the database\n// to verify connectivity before returning.\nfunc Open(dsn string) (*db.Host, error) {\n\td, err := open(dsn)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open database: %w\", err)\n\t}\n\tif err := d.DB.Ping(); err != nil {\n\t\treturn nil, fmt.Errorf(\"database ping failed on %v: %w\", d.Addr, err)\n\t}\n\treturn d, nil\n}\n\n// OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does\n// not ping the database, since replicas are health-checked in the background by db.DB.\nfunc OpenReplica(dsn string) (*db.Host, error) {\n\treturn open(dsn)\n}\n\n// open opens a PostgreSQL database connection pool from a DSN string. It supports custom\n// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns,\n// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from\n// the DSN before passing it to the driver.\nfunc open(dsn string) (*db.Host, error) {\n\tu, err := url.Parse(dsn)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid database URL: %w\", err)\n\t}\n\tswitch u.Scheme {\n\tcase \"postgres\", \"postgresql\":\n\t\t// OK\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"invalid database URL scheme %q, must be \\\"postgres\\\" or \\\"postgresql\\\" (URL: %s)\", u.Scheme, censorPassword(u))\n\t}\n\tq := u.Query()\n\tmaxOpenConns, err := extractIntParam(q, \"pool_max_conns\", 10)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tmaxIdleConns, err := extractIntParam(q, \"pool_max_idle_conns\", 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tconnMaxLifetime, err := extractDurationParam(q, \"pool_conn_max_lifetime\", 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tconnMaxIdleTime, err := extractDurationParam(q, \"pool_conn_max_idle_time\", 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tu.RawQuery = q.Encode()\n\td, err := sql.Open(\"pgx\", u.String())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\td.SetMaxOpenConns(maxOpenConns)\n\tif maxIdleConns > 0 {\n\t\td.SetMaxIdleConns(maxIdleConns)\n\t}\n\tif connMaxLifetime > 0 {\n\t\td.SetConnMaxLifetime(connMaxLifetime)\n\t}\n\tif connMaxIdleTime > 0 {\n\t\td.SetConnMaxIdleTime(connMaxIdleTime)\n\t}\n\treturn &db.Host{\n\t\tAddr: u.Host,\n\t\tDB:   d,\n\t}, nil\n}\n\nfunc extractIntParam(q url.Values, key string, defaultValue int) (int, error) {\n\ts := q.Get(key)\n\tif s == \"\" {\n\t\treturn defaultValue, nil\n\t}\n\tq.Del(key)\n\tv, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid %s value %q: %w\", key, s, err)\n\t}\n\treturn v, nil\n}\n\n// censorPassword returns a string representation of the URL with the password replaced by \"*****\".\nfunc censorPassword(u *url.URL) string {\n\tif password, hasPassword := u.User.Password(); hasPassword {\n\t\treturn strings.Replace(u.String(), \":\"+password+\"@\", \":*****@\", 1)\n\t}\n\treturn u.String()\n}\n\nfunc extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {\n\ts := q.Get(key)\n\tif s == \"\" {\n\t\treturn defaultValue, nil\n\t}\n\tq.Del(key)\n\td, err := time.ParseDuration(s)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid %s value %q: %w\", key, s, err)\n\t}\n\treturn d, nil\n}\n"
  },
  {
    "path": "db/pg/pg_test.go",
    "content": "package pg\n\nimport (\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestOpen_InvalidScheme(t *testing.T) {\n\t_, err := Open(\"postgresql+psycopg2://user:pass@localhost/db\")\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), `invalid database URL scheme \"postgresql+psycopg2\"`)\n\trequire.Contains(t, err.Error(), \"*****\")\n\trequire.NotContains(t, err.Error(), \"pass\")\n}\n\nfunc TestOpen_InvalidURL(t *testing.T) {\n\t_, err := Open(\"not a valid url\\x00\")\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid database URL\")\n}\n\nfunc TestCensorPassword(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\turl      string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"with password\",\n\t\t\turl:      \"postgres://user:secret@localhost/db\",\n\t\t\texpected: \"postgres://user:*****@localhost/db\",\n\t\t},\n\t\t{\n\t\t\tname:     \"without password\",\n\t\t\turl:      \"postgres://localhost/db\",\n\t\t\texpected: \"postgres://localhost/db\",\n\t\t},\n\t\t{\n\t\t\tname:     \"user only\",\n\t\t\turl:      \"postgres://user@localhost/db\",\n\t\t\texpected: \"postgres://user@localhost/db\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tu, err := url.Parse(tt.url)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, tt.expected, censorPassword(u))\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "db/test/test.go",
    "content": "package dbtest\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/db/pg\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst testPoolMaxConns = \"2\"\n\n// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it.\n// It registers a cleanup function to drop the schema when the test finishes.\n// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.\nfunc CreateTestPostgresSchema(t *testing.T) string {\n\tt.Helper()\n\tdsn := os.Getenv(\"NTFY_TEST_DATABASE_URL\")\n\tif dsn == \"\" {\n\t\tt.Skip(\"NTFY_TEST_DATABASE_URL not set\")\n\t}\n\tschema := fmt.Sprintf(\"test_%s\", util.RandomString(10))\n\tu, err := url.Parse(dsn)\n\trequire.Nil(t, err)\n\tq := u.Query()\n\tq.Set(\"pool_max_conns\", testPoolMaxConns)\n\tu.RawQuery = q.Encode()\n\tdsn = u.String()\n\tsetupHost, err := pg.Open(dsn)\n\trequire.Nil(t, err)\n\t_, err = setupHost.DB.Exec(fmt.Sprintf(\"CREATE SCHEMA %s\", schema))\n\trequire.Nil(t, err)\n\trequire.Nil(t, setupHost.DB.Close())\n\tq.Set(\"search_path\", schema)\n\tu.RawQuery = q.Encode()\n\tschemaDSN := u.String()\n\tt.Cleanup(func() {\n\t\tcleanHost, err := pg.Open(dsn)\n\t\tif err == nil {\n\t\t\tcleanHost.DB.Exec(fmt.Sprintf(\"DROP SCHEMA %s CASCADE\", schema))\n\t\t\tcleanHost.DB.Close()\n\t\t}\n\t})\n\treturn schemaDSN\n}\n\n// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.\n// It registers cleanup functions to close the DB and drop the schema when the test finishes.\n// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.\nfunc CreateTestPostgres(t *testing.T) *db.DB {\n\tt.Helper()\n\tschemaDSN := CreateTestPostgresSchema(t)\n\ttestHost, err := pg.Open(schemaDSN)\n\trequire.Nil(t, err)\n\td := db.New(testHost, nil)\n\tt.Cleanup(func() {\n\t\td.Close()\n\t})\n\treturn d\n}\n"
  },
  {
    "path": "db/types.go",
    "content": "package db\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n)\n\n// Beginner is an interface for types that can begin a database transaction.\n// Both *sql.DB and *DB implement this.\ntype Beginner interface {\n\tBegin() (*sql.Tx, error)\n}\n\n// Querier is an interface for types that can execute SQL queries.\n// *sql.DB, *sql.Tx, and *DB all implement this.\ntype Querier interface {\n\tQuery(query string, args ...any) (*sql.Rows, error)\n}\n\n// Host pairs a *sql.DB with the host:port it was opened against.\ntype Host struct {\n\tAddr    string // \"host:port\"\n\tDB      *sql.DB\n\thealthy atomic.Bool\n}\n"
  },
  {
    "path": "db/util.go",
    "content": "package db\n\nimport \"database/sql\"\n\n// ExecTx executes a function within a database transaction. If the function returns an error,\n// the transaction is rolled back. Otherwise, the transaction is committed.\nfunc ExecTx(db Beginner, f func(tx *sql.Tx) error) error {\n\ttx, err := db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\tif err := f(tx); err != nil {\n\t\treturn err\n\t}\n\treturn tx.Commit()\n}\n\n// QueryTx executes a function within a database transaction and returns the result. If the function\n// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.\nfunc QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) {\n\ttx, err := db.Begin()\n\tif err != nil {\n\t\tvar zero T\n\t\treturn zero, err\n\t}\n\tdefer tx.Rollback()\n\tt, err := f(tx)\n\tif err != nil {\n\t\treturn t, err\n\t}\n\tif err := tx.Commit(); err != nil {\n\t\treturn t, err\n\t}\n\treturn t, nil\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  ntfy:\n    image: binwiederhier/ntfy\n    container_name: ntfy\n    command:\n      - serve\n    environment:\n      - TZ=UTC    # optional: Change to your desired timezone\n    user: UID:GID # optional: Set custom user/group or uid/gid\n    volumes:\n      - /var/cache/ntfy:/var/cache/ntfy\n      - /etc/ntfy:/etc/ntfy\n    ports:\n      - 80:80\n    restart: unless-stopped\n"
  },
  {
    "path": "docs/_overrides/main.html",
    "content": "{% extends \"base.html\" %}\n\n{% block announce %}\n<style>\n    div[data-md-component=\"announce\"] {\n        z-index: 10;\n    }\n\n    div[data-md-component=\"announce\"] a {\n        color: white;\n    }\n\n    div[data-md-component=\"announce\"] a:hover, div[data-md-component=\"announce\"] a:focus {\n        transition: ease-in 150ms;\n        color: #ccc;\n    }\n\n    div[data-md-component=\"announce\"] .md-banner__button {\n        color: #ccc;\n    }\n\n    div[data-md-component=\"announce\"] .md-banner.hidden {\n        display: none;\n    }\n\n    div[data-md-component=\"announce\"] .twemoji {\n        margin-top: 2px;\n    }\n</style>\n<button id=\"announce-bar-close\" class=\"md-banner__button md-icon\" aria-label=\"Don't show this again\">\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path d=\"M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z\"></path>\n    </svg>\n</button>\nIf you like ntfy, please consider sponsoring me via <a target=\"_blank\" href=\"https://github.com/sponsors/binwiederhier\"><strong>GitHub Sponsors</strong></a>\nor <a target=\"_blank\" href=\"https://en.liberapay.com/ntfy/\"><strong>Liberapay</strong></a>\n<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" viewBox=\"0 0 36 36\" class=\"twemoji md-footer-custom-text\">\n    <path fill=\"#DD2E44\" d=\"M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z\"/>\n</svg>, or subscribing to <a target=\"_blank\" href=\"https://ntfy.sh/app\"><strong>ntfy Pro</strong></a>.\n<script>\n    announceBarKey = 'announce-bar-closed-sponsor';\n    document.getElementById('announce-bar-close').addEventListener('click', (e) => {\n        localStorage.setItem(announceBarKey, 'true');\n        document.querySelector('div[data-md-component=\"announce\"] .md-banner').style.display = 'none';\n    });\n    if (localStorage.getItem(announceBarKey) === 'true') {\n        document.querySelector('div[data-md-component=\"announce\"] .md-banner').style.display = 'none';\n    }\n</script>\n{% endblock %}\n"
  },
  {
    "path": "docs/config.md",
    "content": "# Configuring the ntfy server\nThe ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`, \nsee [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments \nor using environment variables.\n\n## Quick start\nBy default, simply running `ntfy serve` will start the server at port 80. No configuration needed. Batteries included 😀. \nIf everything works as it should, you'll see something like this:\n```\n$ ntfy serve\n2021/11/30 19:59:08 Listening on :80\n```\n\nYou can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),\n[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure \nthe server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to\nget a list of [command line options](#command-line-options).\n\n## Example config\n!!! info\n    Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings.\n    You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository.\n\nThe most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`\nand `listen-https`), and socket path (`listen-unix`). All the other things are additional features.\n\nHere are a few working sample configs using a `/etc/ntfy/server.yml` file:\n\n=== \"server.yml (HTTP-only, with cache + attachments)\"\n    ``` yaml\n    base-url: \"http://ntfy.example.com\"\n    cache-file: \"/var/cache/ntfy/cache.db\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    ```\n\n=== \"server.yml (HTTP+HTTPS, with cache + attachments)\"\n    ``` yaml\n    base-url: \"http://ntfy.example.com\"\n    listen-http: \":80\"\n    listen-https: \":443\"\n    key-file: \"/etc/letsencrypt/live/ntfy.example.com.key\"\n    cert-file: \"/etc/letsencrypt/live/ntfy.example.com.crt\"\n    cache-file: \"/var/cache/ntfy/cache.db\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    ```\n\n=== \"server.yml (behind proxy, with cache + attachments)\"\n    ``` yaml\n    base-url: \"http://ntfy.example.com\"\n    listen-http: \":2586\"\n    cache-file: \"/var/cache/ntfy/cache.db\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    behind-proxy: true\n    ```\n\n=== \"server.yml (PostgreSQL, behind proxy)\"\n    ``` yaml\n    base-url: \"https://ntfy.example.com\"\n    listen-http: \":2586\"\n    database-url: \"postgres://ntfy:mypassword@db.example.com:5432/ntfy?sslmode=require\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    behind-proxy: true\n    auth-default-access: \"deny-all\"\n    ```\n\n=== \"server.yml (ntfy.sh config)\"\n    ``` yaml\n    # All the things: Behind a proxy, Firebase, cache, attachments, \n    # SMTP publishing & receiving\n\n    base-url: \"https://ntfy.sh\"\n    listen-http: \"127.0.0.1:2586\"\n    firebase-key-file: \"/etc/ntfy/firebase.json\"\n    cache-file: \"/var/cache/ntfy/cache.db\"\n    behind-proxy: true\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    smtp-sender-addr: \"email-smtp.us-east-2.amazonaws.com:587\"\n    smtp-sender-user: \"AKIDEADBEEFAFFE12345\"\n    smtp-sender-pass: \"Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG.\"\n    smtp-sender-from: \"ntfy@ntfy.sh\"\n    smtp-server-listen: \":25\"\n    smtp-server-domain: \"ntfy.sh\"\n    smtp-server-addr-prefix: \"ntfy-\"\n    keepalive-interval: \"45s\"\n    ```\n\nAlternatively, you can also use command line arguments or environment variables to configure the server. Here's an example\nusing Docker Compose (i.e. `docker-compose.yml`):\n\n=== \"Docker Compose (w/ auth, cache, attachments)\"\n    ``` yaml\n\tservices:\n\t  ntfy:\n\t    image: binwiederhier/ntfy\n\t    restart: unless-stopped\n\t    environment:\n\t      NTFY_BASE_URL: http://ntfy.example.com\n\t      NTFY_CACHE_FILE: /var/lib/ntfy/cache.db\n\t      NTFY_AUTH_FILE: /var/lib/ntfy/auth.db\n\t      NTFY_AUTH_DEFAULT_ACCESS: deny-all\n\t      NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'\n\t      NTFY_BEHIND_PROXY: true\n\t      NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments\n\t      NTFY_ENABLE_LOGIN: true\n\t    volumes:\n\t      - ./:/var/lib/ntfy\n\t    ports:\n\t      - 80:80\n\t    command: serve\n    ```\n\n=== \"Docker Compose (w/ auth, cache, web push, iOS)\"\n    ``` yaml\n\tservices:\n\t  ntfy:\n\t    image: binwiederhier/ntfy\n\t    restart: unless-stopped\n\t    environment:\n\t      NTFY_BASE_URL: http://ntfy.example.com\n\t      NTFY_CACHE_FILE: /var/lib/ntfy/cache.db\n\t      NTFY_AUTH_FILE: /var/lib/ntfy/auth.db\n\t      NTFY_AUTH_DEFAULT_ACCESS: deny-all\n\t      NTFY_BEHIND_PROXY: true\n\t      NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments\n\t      NTFY_ENABLE_LOGIN: true\n\t      NTFY_UPSTREAM_BASE_URL: https://ntfy.sh\n\t      NTFY_WEB_PUSH_PUBLIC_KEY: <public_key>\n\t      NTFY_WEB_PUSH_PRIVATE_KEY: <private_key>\n\t      NTFY_WEB_PUSH_FILE: /var/lib/ntfy/webpush.db\n\t      NTFY_WEB_PUSH_EMAIL_ADDRESS: <email>\n\t    volumes:\n\t      - ./:/var/lib/ntfy\n\t    ports:\n\t      - 8093:80\n\t    command: serve\n    ```\n\n## Config generator\n\nThis generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details.\n\n<div style=\"text-align: center;\">\n<button type=\"button\" id=\"cg-open-btn\" class=\"cg-open-btn\">Open config generator</button>\n</div>\n\n<figure markdown style=\"padding-left: 50px; padding-right: 50px; cursor: pointer;\" onclick=\"document.getElementById('cg-open-btn').click();\">\n  <img src=\"../../static/img/config-generator.png\"/>\n  <figcaption>The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.</figcaption>\n</figure>\n\n<div id=\"cg-modal\" class=\"cg-modal\" style=\"display:none\">\n<div class=\"cg-modal-backdrop\"></div>\n<div class=\"cg-modal-dialog\">\n<div class=\"cg-modal-header\">\n<div class=\"cg-modal-header-left\">\n<span class=\"cg-modal-title\">Config generator</span><span class=\"cg-badge-beta\">BETA</span>\n<span class=\"cg-modal-desc\">This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point.</span>\n</div>\n<div class=\"cg-modal-header-actions\">\n<button type=\"button\" id=\"cg-reset-btn\" class=\"cg-modal-reset\" title=\"Reset all values\">Reset</button>\n<button type=\"button\" id=\"cg-close-btn\" class=\"cg-modal-close\" title=\"Close\">&times;</button>\n</div>\n</div>\n<div class=\"cg-modal-body\">\n<div class=\"cg-mobile-toggle\">\n  <button class=\"cg-mobile-toggle-btn active\" data-show=\"left\">Edit</button>\n  <button class=\"cg-mobile-toggle-btn\" data-show=\"right\">Preview</button>\n</div>\n<div id=\"cg-left\">\n<div class=\"cg-nav\">\n<div class=\"cg-nav-tab active\" data-panel=\"cg-panel-general\">General</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-database\" id=\"cg-nav-database\">Database</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-auth\" id=\"cg-nav-auth\">Users</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-cache\" id=\"cg-nav-cache\">Message Cache</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-attach\" id=\"cg-nav-attach\">Attachments</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-webpush\" id=\"cg-nav-webpush\">Web Push</div>\n<div class=\"cg-nav-tab cg-hidden\" data-panel=\"cg-panel-email\" id=\"cg-nav-email\">Email</div>\n</div>\n<div class=\"cg-panels\">\n<div class=\"cg-panel active\" id=\"cg-panel-general\">\n<div class=\"cg-field cg-inline-field\">\n<label>What URL will ntfy be reachable on?</label>\n<input type=\"text\" data-key=\"base-url\" placeholder=\"https://ntfy.example.com\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Will ntfy run behind a proxy (e.g. nginx, Caddy)? <a href=\"/config/#behind-a-proxy-tls-etc\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-proxy\" value=\"no\" checked><span>No</span></label>\n<label><input type=\"radio\" name=\"cg-proxy\" value=\"yes\"><span>Yes</span></label>\n</div>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Will this ntfy server be open or private? <a href=\"/config/#access-control\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-server-type\" value=\"open\" checked><span>Open</span></label>\n<label><input type=\"radio\" name=\"cg-server-type\" value=\"private\"><span>Private</span></label>\n<label><input type=\"radio\" name=\"cg-server-type\" value=\"custom\"><span>Custom</span></label>\n</div>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Will iOS/iPhone users use this server? <a href=\"/config/#ios-instant-notifications\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-ios\" value=\"no\" checked><span>No</span></label>\n<label><input type=\"radio\" name=\"cg-ios\" value=\"yes\"><span>Yes</span></label>\n</div>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Do you want to use ntfy as a UnifiedPush distributor? <a href=\"/config/#example-unifiedpush\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-unifiedpush\" value=\"no\" checked><span>No</span></label>\n<label><input type=\"radio\" name=\"cg-unifiedpush\" value=\"yes\"><span>Yes</span></label>\n</div>\n</div>\n<div class=\"cg-field\">\n<label>Which features do you want to enable?</label>\n<div class=\"cg-feature-grid\">\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-auth\"> User management and access control</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-auth\">Configure</button></div>\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-cache\"> Persistent message cache</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-cache\">Configure</button></div>\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-attach\"> Attachments</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-attach\">Configure</button></div>\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-webpush\"> Web push</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-webpush\">Configure</button></div>\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-smtp-out\"> Email notifications</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-email\">Configure</button></div>\n<div class=\"cg-feature-row\"><label><input type=\"checkbox\" id=\"cg-feat-smtp-in\"> Email publishing</label><button type=\"button\" class=\"cg-btn-configure cg-hidden\" data-panel=\"cg-panel-email\">Configure</button></div>\n</div>\n</div>\n<div class=\"cg-field cg-inline-field cg-hidden\" id=\"cg-wizard-db\">\n<label>Which database backend would you like to use? <a href=\"/config/#database-options\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-db-type\" value=\"sqlite\" checked><span>SQLite</span></label>\n<label><input type=\"radio\" name=\"cg-db-type\" value=\"postgres\"><span>PostgreSQL</span></label>\n</div>\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-auth\">\n<div class=\"cg-panel-desc\">Configure user management, access control, and provisioned users/ACLs. See <a href=\"/config/#access-control\" target=\"_blank\">access control</a> for details.</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Where should the user database be stored?</label>\n<input type=\"text\" data-key=\"auth-file\" placeholder=\"/var/lib/ntfy/auth.db\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>What should the default access policy be? <a href=\"/config/#access-control\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<select id=\"cg-default-access-select\">\n<option value=\"read-write\" selected>Read &amp; Write</option>\n<option value=\"read-only\">Read Only</option>\n<option value=\"write-only\">Write Only</option>\n<option value=\"deny-all\">Deny All</option>\n</select>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Should login to the web app be enabled?</label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-login-mode\" value=\"disabled\" checked><span>Disabled</span></label>\n<label><input type=\"radio\" name=\"cg-login-mode\" value=\"enabled\"><span>Enabled</span></label>\n<label><input type=\"radio\" name=\"cg-login-mode\" value=\"required\"><span>Required</span></label>\n</div>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Should it be possible to sign up via the web app?</label>\n<div class=\"cg-btn-group\">\n<label><input type=\"radio\" name=\"cg-enable-signup\" value=\"no\" checked><span>No</span></label>\n<label><input type=\"radio\" name=\"cg-enable-signup\" value=\"yes\"><span>Yes</span></label>\n</div>\n</div>\n<input type=\"hidden\" data-key=\"auth-default-access\">\n<input type=\"checkbox\" data-key=\"enable-login\" id=\"cg-enable-login-hidden\" style=\"display:none\">\n<input type=\"checkbox\" data-key=\"require-login\" id=\"cg-require-login-hidden\" style=\"display:none\">\n<input type=\"checkbox\" data-key=\"enable-signup\" id=\"cg-enable-signup-hidden\" style=\"display:none\">\n<div class=\"cg-field\">\n<label>Provisioned users <a href=\"/config/#users-and-roles\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-repeatable-container\" id=\"cg-auth-users-container\"></div>\n<button type=\"button\" class=\"cg-btn-add\" data-add-type=\"user\">+ Add user</button>\n</div>\n<div class=\"cg-field\">\n<label>Provisioned ACLs <a href=\"/config/#access-control-list-acl\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-repeatable-container\" id=\"cg-auth-acls-container\"></div>\n<button type=\"button\" class=\"cg-btn-add\" data-add-type=\"acl\">+ Add ACL</button>\n</div>\n<div class=\"cg-field\">\n<label>Provisioned tokens <a href=\"/config/#access-tokens\" target=\"_blank\" class=\"cg-help\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z\"/></svg></a></label>\n<div class=\"cg-repeatable-container\" id=\"cg-auth-tokens-container\"></div>\n<button type=\"button\" class=\"cg-btn-add\" data-add-type=\"token\">+ Add token</button>\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-cache\">\n<div class=\"cg-panel-desc\">Configure the message cache to allow devices to retrieve missed notifications. See <a href=\"/config/#message-cache\" target=\"_blank\">message cache</a> for details.</div>\n<div class=\"cg-field cg-inline-field\" id=\"cg-cache-file-field\">\n<label>Where should the cache be stored?</label>\n<input type=\"text\" data-key=\"cache-file\" placeholder=\"/var/cache/ntfy/cache.db\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>How long should messages be cached?</label>\n<input type=\"text\" data-key=\"cache-duration\" placeholder=\"12h\">\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-attach\">\n<div class=\"cg-panel-desc\">Allow users to upload and attach files to notifications. See <a href=\"/config/#attachments\" target=\"_blank\">attachments</a> for details.</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Where should attachments be stored?</label>\n<input type=\"text\" data-key=\"attachment-cache-dir\" placeholder=\"/var/cache/ntfy/attachments\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Max file size per attachment?</label>\n<input type=\"text\" data-key=\"attachment-file-size-limit\" placeholder=\"15M\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Total attachment storage limit?</label>\n<input type=\"text\" data-key=\"attachment-total-size-limit\" placeholder=\"5G\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>How long before attachments expire?</label>\n<input type=\"text\" data-key=\"attachment-expiry-duration\" placeholder=\"3h\">\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-webpush\">\n<div class=\"cg-panel-desc\">Enable browser push notifications via the Web Push API. VAPID keys are generated automatically. See <a href=\"/config/#web-push\" target=\"_blank\">web push</a> for details.</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Where should web push data be stored?</label>\n<input type=\"text\" data-key=\"web-push-file\" placeholder=\"/var/lib/ntfy/webpush.db\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Contact email address</label>\n<input type=\"text\" data-key=\"web-push-email-address\" placeholder=\"admin@example.com\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Private key</label>\n<input type=\"text\" data-key=\"web-push-private-key\" placeholder=\"Auto-generated\" readonly>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Public key</label>\n<input type=\"text\" data-key=\"web-push-public-key\" placeholder=\"Auto-generated\" readonly>\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label></label>\n<button type=\"button\" id=\"cg-regen-keys\" class=\"cg-btn-add\">Regenerate keys</button>\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-email\">\n<div class=\"cg-panel-desc\">Configure outgoing email notifications and/or incoming email publishing. See <a href=\"/config/#e-mail-notifications\" target=\"_blank\">email notifications</a> and <a href=\"/config/#e-mail-publishing\" target=\"_blank\">email publishing</a> for details.</div>\n<div id=\"cg-email-out-section\" class=\"cg-hidden\">\n<div class=\"cg-field\"><label><strong>Outgoing (notifications)</strong></label></div>\n<div class=\"cg-field cg-inline-field\">\n<label>SMTP server address</label>\n<input type=\"text\" data-key=\"smtp-sender-addr\" placeholder=\"smtp.example.com:587\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Sender email</label>\n<input type=\"text\" data-key=\"smtp-sender-from\" placeholder=\"ntfy@example.com\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>SMTP username</label>\n<input type=\"text\" data-key=\"smtp-sender-user\" placeholder=\"Username\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>SMTP password</label>\n<input type=\"password\" data-key=\"smtp-sender-pass\" placeholder=\"Password\">\n</div>\n</div>\n<div id=\"cg-email-in-section\" class=\"cg-hidden\">\n<div class=\"cg-field\"><label><strong>Incoming (publishing)</strong></label></div>\n<div class=\"cg-field cg-inline-field\">\n<label>Listen address</label>\n<input type=\"text\" data-key=\"smtp-server-listen\" placeholder=\":25\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Domain</label>\n<input type=\"text\" data-key=\"smtp-server-domain\" placeholder=\"ntfy.example.com\">\n</div>\n<div class=\"cg-field cg-inline-field\">\n<label>Address prefix</label>\n<input type=\"text\" data-key=\"smtp-server-addr-prefix\" placeholder=\"ntfy-\">\n</div>\n</div>\n</div>\n<div class=\"cg-panel\" id=\"cg-panel-database\">\n<div class=\"cg-panel-desc\">Configure the PostgreSQL connection. See <a href=\"/config/#postgresql-experimental\" target=\"_blank\">PostgreSQL</a> for details.</div>\n<div class=\"cg-field\">\n<label>Database URL</label>\n<input type=\"text\" data-key=\"database-url\" placeholder=\"postgres://user:pass@host:5432/ntfy\">\n</div>\n</div>\n<input type=\"hidden\" data-key=\"upstream-base-url\">\n<input type=\"checkbox\" data-key=\"behind-proxy\" id=\"cg-behind-proxy\" style=\"display:none\">\n</div>\n</div>\n<div id=\"cg-right\">\n<div class=\"cg-output-tabs\">\n<div class=\"cg-output-tab active\" data-format=\"server-yml\">server.yml</div>\n<div class=\"cg-output-tab\" data-format=\"docker-compose\">docker-compose.yml</div>\n<div class=\"cg-output-tab\" data-format=\"env-vars\">Env variables</div>\n<button type=\"button\" id=\"cg-copy-btn\" class=\"cg-btn-copy\" title=\"Copy to clipboard\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg></button>\n</div>\n<div class=\"cg-output-wrap\">\n<pre><code id=\"cg-code\"><span class=\"cg-empty-msg\">Configure options on the left to generate your config...</span></code></pre>\n<div id=\"cg-warnings\" class=\"cg-hidden\"></div>\n</div>\n</div>\n</div>\n</div>\n</div>\n\n## Database options\nntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions.\nYou can choose between **SQLite** and **PostgreSQL** as the database backend.\n\n### SQLite\nBy default, ntfy uses SQLite with separate database files for each store. This is the simplest setup and requires\nno external dependencies:\n\n* `cache-file`: Database file for the [message cache](#message-cache).\n* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.\n* `web-push-file`: Database file for [web push](#web-push) subscriptions.\n\n### PostgreSQL (EXPERIMENTAL)\nAs an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the\n`database-url` option to a PostgreSQL connection string.\n\nWhen `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache),\n[access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`,\n`auth-file`, and `web-push-file` options **must not** be set in this case.\n\nNote that setting `database-url` implicitly enables authentication and access control (equivalent to setting\n`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all\ntopics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)).\n\nYou can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.\n\nTo offload read-heavy queries from the primary database, you can optionally configure one or more read replicas\nusing the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, checking access permissions, etc)\nare distributed across the replicas using round-robin, while all writes and correctness-critical reads continue to go\nto the primary. If a replica becomes unhealthy, ntfy automatically falls back to the primary until the replica recovers.\nYou can also set this via the environment variable `NTFY_DATABASE_REPLICA_URLS` (comma-separated) or the command line\nflag `--database-replica-urls`.\n\nExamples:\n\n=== \"Simple\"\n    ```yaml\n    database-url: \"postgres://user:pass@host:5432/ntfy\"\n    ```\n    \n=== \"With SSL and pool tuning\"\n    ```yaml\n    database-url: \"postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m\"\n    ```\n    \n=== \"With CA certificate\"\n    ```yaml\n    database-url: \"postgres://user:pass@host:25060/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30\"\n    ```\n\n=== \"With read replicas\"\n    ```yaml\n    database-url: \"postgres://user:pass@primary:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30\"\n    database-replica-urls:\n      - \"postgres://user:pass@replica1:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30\"\n      - \"postgres://user:pass@replica2:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30\"\n    ```\n\nThe database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS)\nas query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`.\nSee the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters.\n\nIn addition, ntfy supports the following custom query parameters to tune the connection pool (these apply to both\nthe primary and replica URLs):\n\n| Parameter                 | Default | Description                                                                      |\n|---------------------------|---------|----------------------------------------------------------------------------------|\n| `pool_max_conns`          | 10      | Maximum number of open connections to the database                               |\n| `pool_max_idle_conns`     | -       | Maximum number of idle connections in the pool                                   |\n| `pool_conn_max_lifetime`  | -       | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) |\n| `pool_conn_max_idle_time` | -       | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`)  |\n\n\n## Message cache\nIf desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period\nof time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve\nnotifications that they may have missed. \n\nBy default, ntfy keeps messages **in-memory for 12 hours**, which means that **cached messages do not survive an application\nrestart**. You can override this behavior by setting `cache-file` (SQLite) or `database-url` (PostgreSQL).\n\n* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`). \n\nYou can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only\npassed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward\nthe message to the subscribers.\n\nSubscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the\n[`since=` parameter](subscribe/api.md#fetch-cached-messages).\n\n## Attachments\nIf desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable\nthis feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). \nOnce these options are set and the directory is writable by the server user, you can upload attachments via PUT.\n\nBy default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues\nand such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download \nfeature) to download the file. The following config options are relevant to attachments:\n\n* `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs\n* `attachment-cache-dir` is the cache directory for attached files\n* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G)\n* `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M)\n* `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h)\n\nHere's an example config using mostly the defaults (except for the cache directory, which is empty by default): \n\n=== \"/etc/ntfy/server.yml (minimal)\"\n    ``` yaml\n    base-url: \"https://ntfy.sh\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    ```\n\n=== \"/etc/ntfy/server.yml (all options)\"\n    ``` yaml\n    base-url: \"https://ntfy.sh\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    attachment-total-size-limit: \"5G\"\n    attachment-file-size-limit: \"15M\"\n    attachment-expiry-duration: \"3h\"\n    visitor-attachment-total-size-limit: \"100M\"\n    visitor-attachment-daily-bandwidth-limit: \"500M\"\n    ```\n\nPlease also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`\nand `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.\n\n## Access control\nBy default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how\nntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. \n\nntfy's auth implements two roles (`user` and `admin`) and per-topic `read` and `write` permissions using an \n[access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied \nto users as well as the special everyone user (`*`), which represents anonymous API access. \n\nTo set up auth, **configure the following options**:\n\n* `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested \n  location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set, \n  auth is automatically enabled using PostgreSQL (see [database options](#database-options)).\n* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be\n  set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance,\n  you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).\n\nOnce configured, you can use \n\n- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)\n- the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl)\nand topic patterns, and\n- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users.\n\nThese commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, \nand only if the user accessing them has the right permissions.\n\n### Users and roles\nUsers can be added to the ntfy user database in two different ways\n\n* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users.\n* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key.\n\n#### Users via the CLI\nThe `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change\npasswords or roles (`user` or `admin`). In practice, you'll often just create one admin \nuser with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).\n\n**Roles:**\n\n* Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access`\n  (see [below](#access-control-list-acl)).\n* Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary.\n\n**Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details):\n\n```\nntfy user list                     # Shows list of users (alias: 'ntfy access')\nntfy user add phil                 # Add regular user phil  \nntfy user add --role=admin phil    # Add admin user phil\nntfy user del phil                 # Delete user phil\nntfy user change-pass phil         # Change password for user phil\nntfy user change-role phil admin   # Make user phil an admin\nntfy user change-tier phil pro     # Change phil's tier to \"pro\"\nntfy user hash                     # Generate password hash, use with auth-users config option\n```\n\n#### Users via the config\nAs an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in\nthe `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to\ndeploy your ntfy server via Docker/Ansible without manually editing the database.\n\nThe `auth-users` option is a list of users that are automatically created/updated when the server starts. Users\npreviously defined in the config but later removed will be deleted. Each entry is defined in the format `<username>:<password-hash>:<role>`.\n\nHere's an example with two users: `phil` is an admin, `ben` is a regular user.\n\n=== \"Declarative users in /etc/ntfy/server.yml\"\n    ``` yaml\n    auth-file: \"/var/lib/ntfy/user.db\"\n    auth-users:\n      - \"phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin\"\n      - \"ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user\"\n    ```\n\n=== \"Declarative users via env variables\"\n    ```\n    # Comma-separated list, use single quotes to avoid issues with the bcrypt hash \n    NTFY_AUTH_FILE='/var/lib/ntfy/user.db'\n    NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'\n    ```\n\nThe password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though\nnote that you're putting your password in an untrusted website).\n\n!!! important\n    Users added declaratively via the config file are marked in the database as \"provisioned users\". Removing users\n    from the config file will **delete them from the database** the next time ntfy is restarted.\n\n    Also, users that were originally manually created will be \"upgraded\" to be provisioned users if they are added to\n    the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence\n    lead to the **deletion of that user**.\n\n### Access control list (ACL)\nThe access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.\nEach entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in\ntwo different ways:\n\n* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list.\n* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key.\n\n#### ACL entries via the CLI\nThe ACL can be displayed or modified with the `ntfy access` command:\n\n```\nntfy access                            # Shows access control list (alias: 'ntfy user list')\nntfy access USERNAME                   # Shows access control entries for USERNAME\nntfy access USERNAME TOPIC PERMISSION  # Allow/deny access for USERNAME to TOPIC\n```\n\nA `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the \nanonymous user `everyone` or `*`, which represents clients that access the API without username/password.\n\nA `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any\nnumber of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any \nnumber of characters.\n\nA `PERMISSION` is any of the following supported permissions:\n\n* `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as \n  [subscribing](subscribe/api.md) and reading messages\n* `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic\n* `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it\n* `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic \n\n**Example commands** (type `ntfy access --help` for more details):\n```\nntfy access                        # Shows entire access control list\nntfy access phil                   # Shows access for user phil\nntfy access phil mytopic rw        # Allow read-write access to mytopic for user phil\nntfy access everyone mytopic rw    # Allow anonymous read-write access to mytopic\nntfy access everyone \"up*\" write   # Allow anonymous write-only access to topics \"up...\"\nntfy access --reset                # Reset entire access control list\nntfy access --reset phil           # Reset all access for user phil\nntfy access --reset phil mytopic   # Reset access for user phil and topic mytopic\n```\n\n**Example ACL:**\n```\n$ ntfy access\nuser phil (admin)\n- read-write access to all topics (admin role)\nuser ben (user)\n- read-write access to topic garagedoor\n- read-write access to topic alerts*\n- read-only access to topic furnace\nuser * (anonymous)\n- read-only access to topic announcements\n- read-only access to topic server-stats\n- no access to any (other) topics (server config)\n```\n\nIn this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary).\nUser `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access\nto topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated\n(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.\n\n#### ACL entries via the config\nAs an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control\nentries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users` \noption (see [users via the config](#users-via-the-config).\n\nThe `auth-access` option is a list of access control entries that are automatically created/updated when the server starts.\nWhen entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<topic-pattern>:<access>`.\n\nThe `<username>` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)),\nor `everyone`/`*` for anonymous access. The `<topic-pattern>` can be a specific topic name or a pattern with wildcards (`*`). The \n`<access>` can be one of the following:\n\n* `read-write` or `rw`: Allows both publishing to and subscribing to the topic\n* `read-only`, `read`, or `ro`: Allows only subscribing to the topic\n* `write-only`, `write`, or `wo`: Allows only publishing to the topic\n* `deny-all`, `deny`, or `none`: Denies all access to the topic\n\nHere's an example with several ACL entries:\n\n=== \"Declarative ACL entries in /etc/ntfy/server.yml\"\n    ``` yaml\n    auth-file: \"/var/lib/ntfy/user.db\"\n    auth-users:\n      - \"phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user\"\n      - \"ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user\"\n    auth-access:\n      - \"phil:mytopic:rw\"\n      - \"ben:alerts-*:rw\"\n      - \"ben:system-logs:ro\"\n      - \"*:announcements:ro\" # or: \"everyone:announcements,ro\"\n    ```\n\n=== \"Declarative ACL entries via env variables\"\n    ```\n    # Comma-separated list\n    NTFY_AUTH_FILE='/var/lib/ntfy/user.db'\n    NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'\n    NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro'\n    ```\n\nIn this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines\naccess control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write\naccess to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows\nanonymous users (i.e. clients that do not authenticate) to read the `announcements` topic.\n\n### Access tokens\nIn addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful\nto avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may\nwant to use a dedicated token to publish from your backup host, and one from your home automation system.\n\n!!! info\n    As of today, access tokens grant users **full access to the user account**. Aside from changing the password,\n    and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,\n    but not yet implemented.\n\nYou can create access tokens in two different ways:\n\n* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens.\n* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key.\n\n#### Tokens via the CLI\nThe `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire\nautomatically (or never expire). Each user can have up to 60 tokens (hardcoded). \n\n**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):\n```\nntfy token list                      # Shows list of tokens for all users\nntfy token list phil                 # Shows list of tokens for user phil\nntfy token add phil                  # Create token for user phil which never expires\nntfy token add --expires=2d phil     # Create token for user phil which expires in 2 days\nntfy token remove phil tk_th2sxr...  # Delete token\nntfy token generate                  # Generate random token, can be used in auth-tokens config option\n```\n\n**Creating an access token:**\n```\n$ ntfy token add --expires=30d --label=\"backups\" phil\n$ ntfy token list\nuser phil\n- tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST\n```\n\nOnce an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or\nsubscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).\n\n#### Tokens via the config\nAccess tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option.\nThis is useful for automated setups, Docker environments, or when you want to define tokens declaratively.\n\nThe `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts.\nWhen entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<token>[:<label>]`.\n\nThe `<username>` must be an existing, provisioned user, as defined in the `auth-users` section (see [users via the config](#users-via-the-config)).\nThe `<token>` is a valid access token, which must start with `tk_` and be 32 characters long (including the prefix). You can generate\nrandom tokens using the `ntfy token generate` command. The optional `<label>` is a human-readable label for the token, \nwhich can be used to identify it later.\n\nOnce configured, these tokens can be used to authenticate API requests just like tokens created via the CLI.\nFor usage examples, see [authenticate via access tokens](publish.md#access-tokens).\n\nHere's an example:\n\n=== \"Declarative tokens in /etc/ntfy/server.yml\"\n    ``` yaml\n    auth-file: \"/var/lib/ntfy/user.db\"\n    auth-users:\n      - \"phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin\"\n      - \"backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user\"\n    auth-tokens:\n      - \"phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76\"\n      - \"backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script\"\n    ```\n\n=== \"Declarative tokens via env variables\"\n    ```\n    # Comma-separated list\n    NTFY_AUTH_FILE='/var/lib/ntfy/user.db'\n    NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'\n    NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script'\n    ```\n\nIn this example, the `auth-users` section defines two users, `phil` and `backup-service`. The `auth-tokens` section\ndefines access tokens for these users. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76`, while `backup-service`\nhas a token `tk_f099we8uzj7xi5qshzajwp6jffvkz` with the label \"Backup script\".\n\n### Example: Private instance\nThe easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`,\nand to configure users in the `auth-users` section (see [users via the config](#users-via-the-config)), \naccess control entries in the `auth-access` section (see [ACL entries via the config](#acl-entries-via-the-config)),\nand access tokens in the `auth-tokens` section (see [access tokens via the config](#tokens-via-the-config)).\n\nHere's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script`\nwith the password `backup-script`. The admin user has full access to all topics, while regular user can only\naccess the `backups` topic with read-write permissions. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76` \nwith the label \"My personal token\". The `auth-default-access` is set to `deny-all`, which means\nthat all other users and anonymous access are denied by default.\n\n=== \"Config via /etc/ntfy/server.yml\"\n    ``` yaml\n    auth-file: \"/var/lib/ntfy/user.db\"\n    auth-default-access: \"deny-all\"\n    auth-users:\n      - \"phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin\"\n      - \"backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user\"\n    auth-access:\n      - \"backup-script:backups:rw\"\n    auth-tokens:\n      - \"phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token\"\n    ```\n\n=== \"Config via env variables\"\n    ``` yaml\n    NTFY_AUTH_FILE='/var/lib/ntfy/user.db'\n    NTFY_AUTH_DEFAULT_ACCESS='deny-all'\n    NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'\n    NTFY_AUTH_ACCESS='backup-script:backups:rw'\n    NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'\n    ```\n\nOnce you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) \nwith the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. \n\nHere's a simple example (using the credentials of the `phil` user):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -u phil:mypass \\\n        -d \"Look ma, with auth\" \\\n        https://ntfy.example.com/mysecrets\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        -u phil:mypass \\\n        ntfy.example.com/mysecrets \\\n        \"Look ma, with auth\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mysecrets HTTP/1.1\n    Host: ntfy.example.com\n    Authorization: Basic cGhpbDpteXBhc3M=\n\n    Look ma, with auth\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mysecrets', {\n        method: 'POST', // PUT works too\n        body: 'Look ma, with auth',\n        headers: {\n            'Authorization': 'Basic cGhpbDpteXBhc3M='\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mysecrets\",\n        strings.NewReader(\"Look ma, with auth\"))\n    req.Header.Set(\"Authorization\", \"Basic cGhpbDpteXBhc3M=\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mysecrets\",\n        data=\"Look ma, with auth\",\n        headers={\n            \"Authorization\": \"Basic cGhpbDpteXBhc3M=\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' => \n                'Content-Type: text/plain\\r\\n' .\n                'Authorization: Basic cGhpbDpteXBhc3M=',\n            'content' => 'Look ma, with auth'\n        ]\n    ]));\n    ```\n\n### Example: UnifiedPush\n[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) \nhas anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages. \nThe topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the \n**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.\n\nTo enable support for UnifiedPush for private servers (i.e. `auth-default-access: \"deny-all\"`), you should either \nallow anonymous write access for the entire prefix or explicitly per topic:\n\n=== \"Prefix\"\n    ```\n    $ ntfy access '*' 'up*' write-only\n    ```\n\n=== \"Explicitly\"\n    ```\n    $ ntfy access '*' upYzMtZGZiYTY5 write-only\n    ```\n\n## E-mail notifications\nTo allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured, \nyou can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. \n`curl -d \"hi there\" -H \"X-Email: phil@example.com\" ntfy.sh/mytopic`).\n\nAs of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the \nfollowing settings:\n\n* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer\n* `smtp-sender-addr` is the hostname:port of the SMTP server\n* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user\n* `smtp-sender-from` is the e-mail address of the sender\n\nHere's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is \nconfigured for `ntfy.sh`):\n\n=== \"/etc/ntfy/server.yml\"\n    ``` yaml\n    base-url: \"https://ntfy.sh\"\n    smtp-sender-addr: \"email-smtp.us-east-2.amazonaws.com:587\"\n    smtp-sender-user: \"AKIDEADBEEFAFFE12345\"\n    smtp-sender-pass: \"Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG.\"\n    smtp-sender-from: \"ntfy@ntfy.sh\"\n    ```\n\nPlease also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` \nand `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.\n\n## E-mail publishing\nTo allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured, \nusers can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or \n`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for \nstatuspage.io (though these days most services also support webhooks and HTTP calls).\n\nTo configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:\n\n* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`\n* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)\n* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,\n  only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be\n  accepted (which may obviously be a spam problem).\n\nHere's an example config (this is how it is configured for `ntfy.sh`):\n\n=== \"/etc/ntfy/server.yml\"\n    ``` yaml\n    smtp-server-listen: \":25\"\n    smtp-server-domain: \"ntfy.sh\"\n    smtp-server-addr-prefix: \"ntfy-\"\n    ```\n\nIn addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record) \nand a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is \nconfigured (in [Amazon Route 53](https://aws.amazon.com/route53/)):\n\n<figure markdown>\n  ![DNS records for incoming mail](static/img/screenshot-email-publishing-dns.png){ width=600 }\n  <figcaption>DNS records for incoming mail</figcaption>\n</figure>\n\nYou can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g. \n`email.txt`\n\n```\nEHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nSubject: Email for you\nContent-Type: text/plain; charset=\"UTF-8\"\n\nHello from 🇩🇪\n.\n```\n\nAnd then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the \nntfy server. Read them carefully.\n\n```\n$ cat email.txt | nc -N ntfy.sh 25\n220 ntfy.sh ESMTP Service Ready\n250-Hello example.com\n...\n250 2.0.0 Roger, accepting mail from <phil@example.com>\n250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this\n```\n\nAs for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:\n\n```\n$ dig MX ntfy.sh +short \n10 mx1.ntfy.sh.\n$ dig A mx1.ntfy.sh +short \n3.139.215.220\n```\n\n### Local-only email\nIf you want to send emails from an internal service on the same network as your ntfy instance, you do not need to\nworry about DNS records at all. Define a port for the SMTP server and pick an SMTP server domain (can be\nanything).\n\n=== \"/etc/ntfy/server.yml\"\n    ``` yaml\n    smtp-server-listen: \":25\"\n    smtp-server-domain: \"example.com\"\n    smtp-server-addr-prefix: \"ntfy-\"  # optional\n    ```\n\nThen, in the email settings of your internal service, set the SMTP server address to the IP address of your\nntfy instance. Set the port to the value you defined in `smtp-server-listen`. Leave any username and password\nfields empty. In the \"From\" address, pick anything (e.g., \"alerts@ntfy.sh\"); the value doesn't matter.\nIn the \"To\" address, put in an email address that follows this pattern: `[topic]@[smtp-server-domain]` (or\n`[smtp-server-addr-prefix][topic]@[smtp-server-domain]` if you set `smtp-server-addr-prefix`).\n\nSo if you used `example.com` as the SMTP server domain, and you want to send a message to the `email-alerts`\ntopic, set the \"To\" address to `email-alerts@example.com`. If the topic has access restrictions, you will need\nto include an access token in the \"To\" address, such as `email-alerts+tk_AbC123dEf456@example.com`.\n\nIf the internal service lets you use define an email \"Subject\", it will become the title of the notification.\nThe body of the email will become the message of the notification.\n\n## Behind a proxy (TLS, etc.)\n!!! warning\n    If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are\n    [rate limited](#rate-limiting) as if they are one.\n\nIt may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates \nusing Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services. \nWhatever your reasons may be, there are a few things to consider. \n\n### IP-based rate limiting\nIf you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the \n[rate limiting](#rate-limiting) logic to use the header configured in `proxy-forwarded-header` (default is `X-Forwarded-For`)\nas the primary identifier for a visitor, as opposed to the remote IP address. \n\nIf the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the\nntfy server, they all share the proxy's IP address.\n\nRelevant flags to consider:\n\n* `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`.\n  Without this, the remote address of the incoming connection is used (default: `false`).\n* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),\n  a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style\n header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).\n* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header \n  to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to\n  the forwarded header (default: empty).\n* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire\n  IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that\n  if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance,\n  set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor.\n* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet). \n  In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and \n  `2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior.\n  See [IPv6 considerations](#ipv6-considerations) for more details.\n\n=== \"/etc/ntfy/server.yml (behind a proxy)\"\n    ``` yaml\n    # Tell ntfy to use \"X-Forwarded-For\" header to identify visitors for rate limiting\n    #\n    # Example: If \"X-Forwarded-For: 9.9.9.9, 1.2.3.4\" is set, \n    #          the visitor IP will be 1.2.3.4 (right-most address).\n    #\n    behind-proxy: true\n    ```\n\n=== \"/etc/ntfy/server.yml (X-Client-IP header)\"\n    ``` yaml\n    # Tell ntfy to use \"X-Client-IP\" header to identify visitors for rate limiting\n    #\n    # Example: If \"X-Client-IP: 9.9.9.9\" is set, \n    #          the visitor IP will be 9.9.9.9.\n    #\n    behind-proxy: true\n    proxy-forwarded-header: \"X-Client-IP\"\n    ```\n\n=== \"/etc/ntfy/server.yml (Forwarded header)\"\n    ``` yaml\n    # Tell ntfy to use \"Forwarded\" header (RFC 7239) to identify visitors for rate limiting\n    #\n    # Example: If \"Forwarded: for=1.2.3.4;by=proxy.example.com, for=9.9.9.9\" is set, \n    #          the visitor IP will be 9.9.9.9.\n    #\n    behind-proxy: true\n    proxy-forwarded-header: \"Forwarded\"\n    ```\n\n=== \"/etc/ntfy/server.yml (multiple proxies)\"\n    ``` yaml\n    # Tell ntfy to use \"X-Forwarded-For\" header to identify visitors for rate limiting,\n    # and to strip the IP addresses of the proxies 1.2.3.4 and 1.2.3.5\n    #\n    # Example: If \"X-Forwarded-For: 9.9.9.9, 1.2.3.4\" is set, \n    #          the visitor IP will be 9.9.9.9 (right-most unknown address).\n    #\n    behind-proxy: true\n    proxy-trusted-hosts: \"1.2.3.0/24, 1.2.2.2, 2001:db8::/64\"\n    ```\n\n=== \"/etc/ntfy/server.yml (adjusted IPv4/IPv6 prefixes proxies)\"\n    ``` yaml\n    # Tell ntfy to treat visitors as being in a /24 subnet (IPv4) or /48 subnet (IPv6)\n    # as one visitor, so that they are counted as one for rate limiting.\n    #\n    # Example 1: If 1.2.3.4 and 1.2.3.5 publish a message, the visitor 1.2.3.0 will have\n    #            used 2 messages.\n    # Example 2: If 2001:db8:2500:1::1 and 2001:db8:2500:2::1 publish a message, the visitor\n    #            2001:db8:2500:: will have used 2 messages.\n    #\n    visitor-prefix-bits-ipv4: 24\n    visitor-prefix-bits-ipv6: 48\n    ```\n\n### TLS/SSL\nntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you \nare behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).\n\nI highly recommend using [certbot](https://certbot.eff.org/). I use it with the [dns-route53 plugin](https://certbot-dns-route53.readthedocs.io/en/stable/), \nwhich lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challenge. That's much easier than using the\nHTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to\nbe incredibly helpful.\n\n### nginx/Apache2/caddy\nFor your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets**\nby forwarding the `Connection` and `Upgrade` headers accordingly. \n\nIn this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic\nor the root domain:\n\n=== \"nginx (convenient)\"\n    ```\n    # /etc/nginx/sites-*/ntfy\n    #\n    # This config allows insecure HTTP POST/PUT requests against topics to allow a short curl syntax (without -L\n    # and \"https://\" prefix). It also disables output buffering, which has worked well for the ntfy.sh server.\n    #\n    # This is pretty much how ntfy.sh is configured. To see the exact configuration,\n    # see https://github.com/binwiederhier/ntfy-ansible/\n\n    server {\n      listen 80;\n      server_name ntfy.sh;\n\n      location / {\n        # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want \n        # it to work with curl without the annoying https:// prefix\n        set $redirect_https \"\";\n        if ($request_method = GET) {\n          set $redirect_https \"yes\";\n        }\n        if ($request_uri ~* \"^/([-_a-z0-9]{0,64}$|docs/|static/)\") {\n          set $redirect_https \"${redirect_https}yes\";\n        }\n        if ($redirect_https = \"yesyes\") {\n          return 302 https://$http_host$request_uri$is_args$query_string;\n        }\n\n        proxy_pass http://127.0.0.1:2586;\n        proxy_http_version 1.1;\n    \n        proxy_buffering off;\n        proxy_request_buffering off;\n        proxy_redirect off;\n     \n        proxy_set_header Host $http_host;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    \n        proxy_connect_timeout 3m;\n        proxy_send_timeout 3m;\n        proxy_read_timeout 3m;\n\n        client_max_body_size 0; # Stream request body to backend\n      }\n    }\n    \n    server {\n      listen 443 ssl http2;\n      server_name ntfy.sh;\n    \n      # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6\n      ssl_session_timeout 1d;\n      ssl_session_cache shared:MozSSL:10m; # about 40000 sessions\n      ssl_session_tickets off;\n      ssl_protocols TLSv1.2 TLSv1.3;\n      ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;\n      ssl_prefer_server_ciphers off;\n \n      ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem;\n      ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem;\n    \n      location / {\n        proxy_pass http://127.0.0.1:2586;\n        proxy_http_version 1.1;\n\n        proxy_buffering off;\n        proxy_request_buffering off;\n        proxy_redirect off;\n     \n        proxy_set_header Host $http_host;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    \n        proxy_connect_timeout 3m;\n        proxy_send_timeout 3m;\n        proxy_read_timeout 3m;\n        \n        client_max_body_size 0; # Stream request body to backend\n      }\n    }\n    ```\n\n=== \"nginx (more secure)\"\n    ```\n    # /etc/nginx/sites-*/ntfy\n    #\n    # This config requires the use of the -L flag in curl to redirect to HTTPS, and it keeps nginx output buffering\n    # enabled. While recommended, I have had issues with that in the past.\n    \n    server {\n      listen 80;\n      server_name ntfy.sh;\n\n      location / {\n        return 302 https://$http_host$request_uri$is_args$query_string;\n\n        proxy_pass http://127.0.0.1:2586;\n        proxy_http_version 1.1;\n\n        proxy_set_header Host $http_host;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n        proxy_connect_timeout 3m;\n        proxy_send_timeout 3m;\n        proxy_read_timeout 3m;\n\n        client_max_body_size 0; # Stream request body to backend\n      }\n    }\n    \n    server {\n      listen 443 ssl http2;\n      server_name ntfy.sh;\n    \n      # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6\n      ssl_session_timeout 1d;\n      ssl_session_cache shared:MozSSL:10m; # about 40000 sessions\n      ssl_session_tickets off;\n      ssl_protocols TLSv1.2 TLSv1.3;\n      ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;\n      ssl_prefer_server_ciphers off;\n    \n      ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem;\n      ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem;\n    \n      location / {\n        proxy_pass http://127.0.0.1:2586;\n        proxy_http_version 1.1;\n\n        proxy_set_header Host $http_host;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n        proxy_connect_timeout 3m;\n        proxy_send_timeout 3m;\n        proxy_read_timeout 3m;\n\n        client_max_body_size 0; # Stream request body to backend\n      }\n    }\n    ```\n\n=== \"Apache2\"\n    ```\n    # /etc/apache2/sites-*/ntfy.conf\n\n    <VirtualHost *:80>\n        ServerName ntfy.sh\n\n        # Proxy connections to ntfy (requires \"a2enmod proxy proxy_http\")\n        ProxyPass / http://127.0.0.1:2586/ upgrade=websocket\n        ProxyPassReverse / http://127.0.0.1:2586/\n\n        SetEnv proxy-nokeepalive 1\n        SetEnv proxy-sendchunked 1\n\n        # Higher than the max message size of 4096 bytes\n        LimitRequestBody 102400\n        \n        # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want \n        # it to work with curl without the annoying https:// prefix (requires \"a2enmod alias\")\n        <If \"%{REQUEST_METHOD} == 'GET'\">\n            RedirectMatch permanent \"^/([-_A-Za-z0-9]{0,64})$\" \"https://%{SERVER_NAME}/$1\"\n        </If>\n\n    </VirtualHost>\n    \n    <VirtualHost *:443>\n        ServerName ntfy.sh\n        \n        SSLEngine on\n        SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem\n        SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem\n        Include /etc/letsencrypt/options-ssl-apache.conf\n\n        # Proxy connections to ntfy (requires \"a2enmod proxy proxy_http\")\n        ProxyPass / http://127.0.0.1:2586/ upgrade=websocket\n        ProxyPassReverse / http://127.0.0.1:2586/\n\n        SetEnv proxy-nokeepalive 1\n        SetEnv proxy-sendchunked 1\n\n        # Higher than the max message size of 4096 bytes \n        LimitRequestBody 102400\n\t\n    </VirtualHost>\n    ```\n\n=== \"caddy\"\n    ```\n    # Note that this config is most certainly incomplete. Please help out and let me know what's missing\n    # via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.\n    # Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy \n\n    ntfy.sh, http://nfty.sh {\n        reverse_proxy 127.0.0.1:2586\n\n        # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want\n        # it to work with curl without the annoying https:// prefix\n        @httpget {\n            protocol http\n            method GET\n            path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)\n        }\n        redir @httpget https://{host}{uri}\n    }\n    ```\n\t\n=== \"ferron\"\n    ``` kdl\n    // /etc/ferron.kdl\t\n    // Note that this config is most certainly incomplete. Please help out and let me know what's missing\n    // via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.\n    // Note: Ferron automatically handles both HTTP and WebSockets with proxy \n\n    ntfy.sh {\n        auto_tls\n        auto_tls_letsencrypt_production\n        protocols \"h1\" \"h2\" \"h3\"\n\n        proxy \"http://127.0.0.1:2586\"\n\n        // Redirect HTTP to HTTPS, but only for GET topic addresses, since we want\n        // it to work with curl without the annoying https:// prefix\n\n        no_redirect_to_https #true\n\n        condition \"is_get_topic\" {\n            is_equal \"{method}\" \"GET\"\n            is_regex \"{path}\" \"^/([-_a-z0-9]{0,64}$|docs/|static/)\"\n        }\n\n        if \"is_get_topic\" {\n              no_redirect_to_https #false\n        }\n    }\n    ```\n\n## Firebase (FCM)\n!!! info\n    Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).\n    For a self-hosted instance, it's easier to just not bother with FCM.\n\n[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) is the Google approved way to send\npush messages to Android devices. FCM is the only method that an Android app can receive messages without having to run a\n[foreground service](https://developer.android.com/guide/components/foreground-services).\n\nFor the main host [ntfy.sh](https://ntfy.sh), the [ntfy Android app](subscribe/phone.md) uses Firebase to send messages\nto the device. For other hosts, instant delivery is used and FCM is not involved.\n\nTo configure FCM for your self-hosted instance of the ntfy server, follow these steps:\n\n1. Sign up for a [Firebase account](https://console.firebase.google.com/)\n2. Create a Firebase app and download the key file (e.g. `myapp-firebase-adminsdk-...json`)\n3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `server.yml` accordingly and restart the ntfy server\n4. Build your own Android .apk following [these instructions](develop.md#android-app)\n\nExample:\n```\n# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.\n# This is optional and only required to support Android apps (which don't allow background services anymore).\n#\nfirebase-key-file: \"/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json\"\n```\n\n## iOS instant notifications\nUnlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant \npush notifications without a central server. \n\nTo still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request` \nmessages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app),\nwhich will then forward it to Firebase/APNS.\n\nTo configure it, simply set `upstream-base-url` like so:\n\n``` yaml\nupstream-base-url: \"https://ntfy.sh\"\nupstream-access-token: \"...\" # optional, only if rate limits exceeded, or upstream server protected\n```\n\nIf set, all incoming messages will publish a poll request to the configured upstream server, containing\nthe message ID of the original message, instructing the iOS app to poll this server for the actual message contents.\n\nIf `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours,\ndepending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though.\n\nIn case you're curious, here's an example of the entire flow: \n\n- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`\n- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)\n- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a \n  poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server \n  contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).\n- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device\n- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it\n\nHere's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:\n\n```\ncurl -X POST -H \"X-Poll-ID: s4PdJozxM8na\" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b\n{\"id\":\"4HsClFEuCIcs\",\"time\":1654087955,\"event\":\"poll_request\",\"topic\":\"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b\",\"message\":\"New message\",\"poll_id\":\"s4PdJozxM8na\"}\n```\n\nNote that the self-hosted server literally sends the message `New message` for every message, even if your message \nmay be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all), \nit'll show `New message` as a popup.\n\n## Web Push\n[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))\nallows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed. \nWhen enabled, the user can enable **background notifications** for their topics in the web app under Settings. Once enabled by the\nuser, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then\nforward it to the browser.\n\nTo configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`),\na database to keep track of the browser's subscriptions, and an admin email address (you):\n\n- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890\n- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890\n- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` (not required if `database-url` is set)\n- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`\n- `web-push-startup-queries` is an optional list of queries to run on startup`\n- `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`)\n- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)\n\nAlternatively, you can use PostgreSQL instead of SQLite by setting `database-url`\n(see [PostgreSQL database](#postgresql-experimental)).\n\nLimitations:\n\n- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_\n  certificate is required, as service workers will not run on origins with untrusted certificates.\n\n- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This\n  is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin.\n\nTo configure VAPID keys, first generate them:\n\n```sh\n$ ntfy webpush keys\nWeb Push keys generated.\n...\n```\n\nThen copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments:\n\n```yaml\nweb-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890\nweb-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890\nweb-push-file: /var/cache/ntfy/webpush.db\nweb-push-email-address: sysadmin@example.com\n```\n\nThe `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url`\nis set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out\na warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error\n(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically.\n\nThe web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription\nfile is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.\n\nChanging your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and\nif you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission.\n\n## Tiers\nntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as \ndaily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),\ntiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way\nto switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).\n\nBy default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.\nOnce a user is associated with a tier, some limits are overridden based on the tier.\n\nThe `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.\n\n**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):\n```\nntfy tier add pro                     # Add tier with code \"pro\", using the defaults\nntfy tier change --name=\"Pro\" pro     # Update the name of an existing tier\nntfy tier del starter                 # Delete an existing tier\nntfy user change-tier phil pro        # Switch user \"phil\" to tier \"pro\"\n```\n\n**Creating a tier (full example):**\n```\nntfy tier add \\\n  --name=\"Pro\" \\\n  --message-limit=10000 \\\n  --message-expiry-duration=24h \\\n  --email-limit=50 \\\n  --call-limit=10 \\\n  --reservation-limit=10 \\\n  --attachment-file-size-limit=100M \\\n  --attachment-total-size-limit=1G \\\n  --attachment-expiry-duration=12h \\\n  --attachment-bandwidth-limit=5G \\\n  --stripe-price-id=price_123456 \\\n  pro\n```\n\n## Payments\nntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled,\nusers can register, login and switch plans in the web app. The web app will behave slightly differently if payments \nare enabled (e.g. showing an upgrade banner, or \"ntfy Pro\" tags).\n\n!!! info\n    The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use\n    cases.\n\nTo enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key`\nconfig options: \n\n* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values\n   enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).\n* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.\n   Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).\n* `billing-contact` is an email address or website displayed in the \"Upgrade tier\" dialog to let people reach\n   out with billing questions. If unset, nothing will be displayed.\n\nIn addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)\nfor the `customer.subscription.updated` and `customer.subscription.deleted` event, which points \nto `https://ntfy.example.com/v1/account/billing/webhook`.\n\nHere's an example:\n\n``` yaml\nstripe-secret-key: \"sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG\"\nstripe-webhook-key: \"whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR\"\nbilling-contact: \"phil@example.com\"\n```\n\n## Phone calls\nntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled,\nusers can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header.\nSee [publishing page](publish.md#phone-calls) for more details.\n\nTo enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers\nare the easiest), and then configure the following options:\n\n* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586\n* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586\n* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 \n* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586\n* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)\n\nAfter you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),\nand then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.\n\nTo customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is\nrendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:\n\n* `{{.Topic}}` is the topic name\n* `{{.Message}}` is the message body\n* `{{.Title}}` is the message title\n* `{{.Tags}}` is a list of tags\n* `{{.Priority}}` is the message priority\n* `{{.Sender}}` is the IP address or username of the sender\n\nHere's an example:\n\n=== \"Custom TwiML (English)\"\n    ``` yaml\n    twilio-account: \"AC12345beefbeef67890beefbeef122586\"\n    twilio-auth-token: \"affebeef258625862586258625862586\"\n    twilio-phone-number: \"+18775132586\"\n    twilio-verify-service: \"VA12345beefbeef67890beefbeef122586\"\n    twilio-call-format: |\n      <Response>\n        <Pause length=\"1\"/>\n        <Say loop=\"3\">\n          Yo yo yo, you should totally check out this message for {{.Topic}}.\n          {{ if eq .Priority 5 }}\n            It's really really important, dude. So listen up!\n          {{ end }}\n          <break time=\"1s\"/>\n          {{ if neq .Title \"\" }}\n            Bro, it's titled: {{.Title}}.\n          {{ end }}\n          <break time=\"1s\"/>\n          {{.Message}}\n          <break time=\"1s\"/>\n          That is all.\n          <break time=\"1s\"/>\n          You know who this message is from? It is from {{.Sender}}.\n          <break time=\"3s\"/>\n        </Say>\n        <Say>See ya!</Say>\n      </Response>\n    ```\n\n=== \"Custom TwiML (German)\"\n    ``` yaml\n    twilio-account: \"AC12345beefbeef67890beefbeef122586\"\n    twilio-auth-token: \"affebeef258625862586258625862586\"\n    twilio-phone-number: \"+18775132586\"\n    twilio-verify-service: \"VA12345beefbeef67890beefbeef122586\"\n    twilio-call-format: |\n      <Response>\n        <Pause length=\"1\"/>\n        <Say loop=\"3\" voice=\"alice\" language=\"de-DE\">\n          Du hast eine Nachricht zum Thema {{.Topic}}.\n          {{ if eq .Priority 5 }}\n            Achtung. Die Nachricht ist sehr wichtig.\n          {{ end }}\n          <break time=\"1s\"/>\n          {{ if neq .Title \"\" }}\n            Titel der Nachricht: {{.Title}}.\n          {{ end }}\n          <break time=\"1s\"/>\n          Nachricht:\n          <break time=\"1s\"/>\n          {{.Message}}\n          <break time=\"1s\"/>\n          Ende der Nachricht.\n          <break time=\"1s\"/>\n          Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.\n          <break time=\"3s\"/>\n        </Say>\n        <Say voice=\"alice\" language=\"de-DE\">Alla mol!</Say>\n      </Response>\n    ```\n\n## Message limits\nThere are a few message limits that you can configure:\n\n* `message-size-limit` defines the max size of a message body. Please note message sizes >4K are **not recommended,\n   and largely untested**. The Android/iOS and other clients may not work, or work properly. If FCM and/or APNS is used,\n   the limit should stay 4K, because their limits are around that size. If you increase this size limit regardless, \n   FCM and APNS will NOT work for large messages.\n* `message-delay-limit` defines the max delay of a message when using the \"Delay\" header and [scheduled delivery](publish.md#scheduled-delivery).\n\n## Rate limiting\n!!! info\n    Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. \n    Otherwise, all visitors are rate limited as if they are one.\n\nBy default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.\nThere are various limits and rate limits in place that you can use to configure the server:\n\n* **Global limit**: A global limit applies across all visitors (IPs, clients, users)\n* **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address \n  (or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply \n  only on a per-visitor basis.\n\nDuring normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails\n(e.g. when you reconnect after a connection drop), it shouldn't have any effect.\n\n### General limits\nLet's do the easy limits first:\n\n* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000.\n* `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30.\n\n### Request limits\nIn addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests.\nThis limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)):\n\nEach visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`). \nAfter the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one\nrequest every 5s (defined by `visitor-request-limit-replenish`)\n\n* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.\n* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.\n* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate \n  limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.\n\n### Message limits\nBy default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits). \nFor instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests\nto publish messages, then that is the daily message limit.\n\nTo limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number \nof messages a visitor can send in a day. This counter is reset every day at midnight (UTC).\n\n### Attachment limits\nAside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant \nper-visitor limits:\n\n* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.\n  The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, \n  see [publishing docs](publish.md#attachments)) do not count here. \n* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, \n  including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in\n  most cloud providers. This defaults to 500M.\n\n### E-mail limits\nSimilarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) \nare enabled):\n\n* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.\n* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.\n\n### Firebase limits\nIf [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` \nis set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)\non how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time \nof day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to \nthe same topic**. \n\nIn ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message\nis **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously,\nthere is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected.\nAfter the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor.\n\nIf this ever happens, there will be a log message that looks something like this:\n```\nWARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor\n```\n\n### IPv6 considerations\nBy default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors\nin the same `/64` subnet are treated as one visitor. This is done to prevent abuse, as IPv6 subnet assignments are typically\nmuch larger than IPv4 subnets (and much cheaper), and it is common for ISPs to assign large subnets to their customers.\n\nOther than that, rate limiting for IPv6 is done the same way as for IPv4, using the visitor's IP address or subnet to identify them.\n\nThere are two options to configure the number of bits used for rate limiting (for IPv4 and IPv6):\n\n- `visitor-prefix-bits-ipv4` is number of bits of the IPv4 address to use for rate limiting (default: 32, full address)\n- `visitor-prefix-bits-ipv6` is number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)\n\n### Subscriber-based rate limiting\nBy default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment\nsize are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits\nof a topic's subscriber, instead of the limits of the publisher.**\n\nIf subscriber-based rate limiting is enabled, **messages published on UnifiedPush topics** (topics starting with `up`, e.g. `up123456789012`) \nwill be counted towards the \"rate visitor\" of the topic. A \"rate visitor\" is the first subscriber to the topic. \n\nOnce enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as\na \"rate visitor\", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor\nrequires **read-write permission** on the topic.\n\nIf this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`\nresponse if no \"rate visitor\" has been previously registered. This is to avoid burning the publisher's \n`visitor-message-daily-limit`.\n\nTo enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.\n\n!!! info\n    Due to a [denial-of-service issue](https://github.com/binwiederhier/ntfy/issues/1048), support for the `Rate-Topics`\n    header was removed entirely. This is unfortunate, but subscriber-based rate limiting will still work for `up*` topics.\n\n## Tuning for scale\nIf you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,\nif it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.\nThis limit is typically called `nofile`. Other than that, RAM and CPU are obviously relevant. You may also want to check\nout [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/how_many_actively_connected_http_clients_can_a_go/).\n\nDepending on *how you run it*, here are a few limits that are relevant:\n\n### Message cache\nBy default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it\nsyncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh,\nthe [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted. \nSee [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details.\n\nIn addition to that, for very high load servers (such as ntfy.sh), it may be beneficial to write messages to the cache\nin batches, and asynchronously. This can be enabled with the `cache-batch-size` and `cache-batch-timeout`. If you start\nseeing `database locked` messages in the logs, you should probably enable that.\n\nHere's how ntfy.sh has been tuned in the `server.yml` file:\n\n``` yaml\ncache-batch-size: 25\ncache-batch-timeout: \"1s\"\ncache-startup-queries: |\n    pragma journal_mode = WAL;\n    pragma synchronous = normal;\n    pragma temp_store = memory;\n    pragma busy_timeout = 15000;\n    vacuum;\n```\n\n### For systemd services\nIf you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the\n`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it\nby creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf`\nis not relevant.\n\n=== \"/etc/systemd/system/ntfy.service.d/override.conf\"\n    ```\n    # Allow 20,000 ntfy connections (and give room for other file handles)\n    [Service]\n    LimitNOFILE=20500\n    ```\n\n### Outside of systemd\nIf you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to\nincrease the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting\nby running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`.\n\n=== \"/etc/security/limits.conf\"\n    ```\n    # Increase open files limit globally\n    * hard nofile 20500\n    ```\n\n### Proxy limits (nginx, Apache2)\nIf you are running [behind a proxy](#behind-a-proxy-tls-etc) (e.g. nginx, Apache), the open files limit of the proxy is also\nrelevant. So if your proxy runs inside of systemd, increase the limits in systemd for the proxy. Typically, the proxy\nopen files limit has to be **double the number of how many connections you'd like to support**, because the proxy has\nto maintain the client connection and the connection to ntfy.\n\n=== \"/etc/nginx/nginx.conf\"\n    ```\n    events {\n      # Allow 40,000 proxy connections (2x of the desired ntfy connection count;\n      # and give room for other file handles)\n      worker_connections 40500;\n    }\n    ```\n\n=== \"/etc/systemd/system/nginx.service.d/override.conf\"\n    ```\n    # Allow 40,000 proxy connections (2x of the desired ntfy connection count;\n    # and give room for other file handles)\n    [Service]\n    LimitNOFILE=40500\n    ```\n\n### Banning bad actors (fail2ban)\nIf you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/)\nand nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used\nto ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server.\n\nHere's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/) \nand [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)):\n\n=== \"/etc/nginx/nginx.conf\"\n    ```\n    # Rate limit all IP addresses\n    http {\n\t  limit_req_zone $binary_remote_addr zone=one:10m rate=45r/m;\n    }\n\n    # Alternatively, whitelist certain IP addresses\n    http {\n      geo $limited {\n        default 1;\n        116.203.112.46/32 0;\n        132.226.42.65/32 0;\n        ...\n      }\n      map $limited $limitkey {\n        1 $binary_remote_addr;\n        0 \"\";\n      }\n      limit_req_zone $limitkey zone=one:10m rate=45r/m;\n    }\n    ```\n\n=== \"/etc/nginx/sites-enabled/ntfy.sh\"\n    ```\n    # For each server/location block\n    server {\n      location / {\n        limit_req zone=one burst=1000 nodelay;\n      }\n    }    \n    ```\n\n=== \"/etc/fail2ban/filter.d/nginx-req-limit.conf\"\n    ```\n    [Definition]\n    failregex = limiting requests, excess:.* by zone.*client: <HOST>\n    ignoreregex =\n    ```\n\n=== \"/etc/fail2ban/jail.local\"\n    ```\n    [nginx-req-limit]\n    enabled = true\n    filter = nginx-req-limit\n    action = iptables-multiport[name=ReqLimit, port=\"http,https\", protocol=tcp]\n    logpath = /var/log/nginx/error.log\n    findtime = 600\n    bantime = 14400\n    maxretry = 10\n    ```\n\nNote that if you run nginx in a container, append `, chain=DOCKER-USER` to the jail.local action. By default, the jail action chain\nis `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`\nchain.\n\nThe official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to\n4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.\n\n## IPv6 support\nntfy fully supports IPv6, though there are a few things to keep in mind.\n\n- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to\n  explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on\n  IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx.\n- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64`\n  subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different\n  value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details.\n- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands\n  support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details.\n\n!!! info\n    The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to\n    configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban).\n\n## Health checks\nA preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.\nIf a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.\n\n```json\n{\"healthy\":true}\n```\n\nSee [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment.\n\n## Monitoring\nIf configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to\ncreate dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).\n\nTo configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated\nlisten address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are\ndoing, and/or secure access to the endpoint in your reverse proxy.\n\n- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)\n- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly\n  enables metrics as well, e.g. \"10.0.1.1:9090\" or \":9090\"\n\n=== \"server.yml (Using default port)\"\n    ```yaml\n    enable-metrics: true\n    ```\n\n=== \"server.yml (Using dedicated IP/port)\"\n    ```yaml\n    metrics-listen-http: \"10.0.1.1:9090\"\n    ```\n\nIn Prometheus, an example scrape config would look like this:\n\n=== \"prometheus.yml\"\n    ```yaml\n    scrape_configs:\n      - job_name: \"ntfy\"\n        static_configs:\n          - targets: [\"10.0.1.1:9090\"]\n    ```\n\nHere's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)):\n\n<figure markdown style=\"padding-left: 50px; padding-right: 50px\">\n  <a href=\"../../static/img/grafana-dashboard.png\" target=\"_blank\"><img src=\"../../static/img/grafana-dashboard.png\"/></a>\n  <figcaption>ntfy Grafana dashboard</figcaption>\n</figure>\n\n## Profiling\nntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server. \nIf enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://<ip>:<port>/debug/pprof/`.\nThis can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option.\n\n## Logging & debugging\nBy default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.\n\nntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular\nlog level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded\nby calling `kill -HUP $pid` or `systemctl reload ntfy`.\n\nThe following config options define the logging behavior:\n\n* `log-format` defines the output format, can be `text` (default) or `json`\n* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.\n* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.\n  Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.\n* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful\n  for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).\n  This is an array of strings in the format:\n    - `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`\n    - `field -> level` to match any value, e.g. `time_taken_ms -> debug`\n\n**Logging config (good for production use):**\n``` yaml\nlog-level: info\nlog-format: json\nlog-file: /var/log/ntfy.log\n```\n\n**Temporary debugging:**   \nIf something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`\nto `debug` or `trace`. The `debug` setting will output information about each published message, but not the message\ncontents. The `trace` setting will also print the message contents.\n\nAlternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`), \na username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what \nthey are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).\n\nHere's an example that will output only `info` log events, except when they match either of the defined overrides:\n``` yaml\nlog-level: info\nlog-level-overrides:\n  - \"tag=manager -> trace\"\n  - \"visitor_ip=1.2.3.4 -> debug\"\n  - \"time_taken_ms -> debug\"\n```\n\n!!! warning\n    The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a \n    performance penalty. Only use it for temporary debugging.\n\nYou can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after \nediting the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), \nor by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:\n\n```\n$ ntfy serve\n2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO\n2022/06/02 10:29:34 INFO Partially hot reloading configuration ...\n2022/06/02 10:29:34 INFO Log level is TRACE\n```\n\n## Config options\nEach config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a\nCLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment\nvariable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).\n\n!!! info\n    All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g. \n    `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do \n    not support dashes.\n\n| Config option                              | Env variable                                    | Format                                              | Default           | Description                                                                                                                                                                                                                     |\n|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -                 | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  |\n| `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`             | Listen address for the HTTP web server                                                                                                                                                                                          |\n| `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -                 | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               |\n| `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -                 | Path to a Unix socket to listen on                                                                                                                                                                                              |\n| `listen-unix-mode`                         | `NTFY_LISTEN_UNIX_MODE`                         | *file mode*                                         | *system default*  | File mode of the Unix socket, e.g. 0700 or 0777                                                                                                                                                                                 |\n| `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -                 | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 |\n| `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -                 | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 |\n| `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -                 | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm).                       |\n| `database-url`                             | `NTFY_DATABASE_URL`                             | *string (connection URL)*                           | -                 | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). |\n| `database-replica-urls`                    | `NTFY_DATABASE_REPLICA_URLS`                    | *list of strings (connection URLs)*                 | -                 | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. See [read replicas](#read-replicas). |\n| `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -                 | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             |\n| `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h               | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        |\n| `cache-startup-queries`                    | `NTFY_CACHE_STARTUP_QUERIES`                    | *string (SQL queries)*                              | -                 | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache)                                                                                                                   |\n| `cache-batch-size`                         | `NTFY_CACHE_BATCH_SIZE`                         | *int*                                               | 0                 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous)                                                                                                                          |\n| `cache-batch-timeout`                      | `NTFY_CACHE_BATCH_TIMEOUT`                      | *duration*                                          | 0s                | Timeout for batched async writes to the message cache (if zero, writes are synchronous)                                                                                                                                         |\n| `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -                 | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control).                                           |\n| `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write`      | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             |\n| `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false             | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)                                                                                                            |\n| `proxy-forwarded-header`                   | `NTFY_PROXY_FORWARDED_HEADER`                   | *string*                                            | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting)                                                                                                                                                        |\n| `proxy-trusted-hosts`                      | `NTFY_PROXY_TRUSTED_HOSTS`                      | *comma-separated host/IP/CIDR list*                 | -                 | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header                                                                                                                                   |\n| `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -                 | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  |\n| `attachment-total-size-limit`              | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT`              | *size*                                              | 5G                | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected.                                                                                                                   |\n| `attachment-file-size-limit`               | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT`               | *size*                                              | 15M               | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected.                                                                                                                                       |\n| `attachment-expiry-duration`               | `NTFY_ATTACHMENT_EXPIRY_DURATION`               | *duration*                                          | 3h                | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`.                                                                                               |\n| `smtp-sender-addr`                         | `NTFY_SMTP_SENDER_ADDR`                         | `host:port`                                         | -                 | SMTP server address to allow email sending                                                                                                                                                                                      |\n| `smtp-sender-user`                         | `NTFY_SMTP_SENDER_USER`                         | *string*                                            | -                 | SMTP user; only used if e-mail sending is enabled                                                                                                                                                                               |\n| `smtp-sender-pass`                         | `NTFY_SMTP_SENDER_PASS`                         | *string*                                            | -                 | SMTP password; only used if e-mail sending is enabled                                                                                                                                                                           |\n| `smtp-sender-from`                         | `NTFY_SMTP_SENDER_FROM`                         | *e-mail address*                                    | -                 | SMTP sender e-mail address; only used if e-mail sending is enabled                                                                                                                                                              |\n| `smtp-server-listen`                       | `NTFY_SMTP_SERVER_LISTEN`                       | `[ip]:port`                                         | -                 | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`                                                                                                                                      |\n| `smtp-server-domain`                       | `NTFY_SMTP_SERVER_DOMAIN`                       | *domain name*                                       | -                 | SMTP server e-mail domain, e.g. `ntfy.sh`                                                                                                                                                                                       |\n| `smtp-server-addr-prefix`                  | `NTFY_SMTP_SERVER_ADDR_PREFIX`                  | *string*                                            | -                 | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-`                                                                                                                                                          |\n| `twilio-account`                           | `NTFY_TWILIO_ACCOUNT`                           | *string*                                            | -                 | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586                                                                                                                                                                     |\n| `twilio-auth-token`                        | `NTFY_TWILIO_AUTH_TOKEN`                        | *string*                                            | -                 | Twilio auth token, e.g. affebeef258625862586258625862586                                                                                                                                                                        |\n| `twilio-phone-number`                      | `NTFY_TWILIO_PHONE_NUMBER`                      | *string*                                            | -                 | Twilio outgoing phone number, e.g. +18775132586                                                                                                                                                                                 |\n| `twilio-verify-service`                    | `NTFY_TWILIO_VERIFY_SERVICE`                    | *string*                                            | -                 | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586                                                                                                                                                              |\n| `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s               | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |\n| `manager-interval`                         | `NTFY_MANAGER_INTERVAL`                         | *duration*                                          | 1m                | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         |\n| `message-size-limit`                       | `NTFY_MESSAGE_SIZE_LIMIT`                       | *size*                                              | 4K                | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages.                       |\n| `message-delay-limit`                      | `NTFY_MESSAGE_DELAY_LIMIT`                      | *duration*                                          | 3d                | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header                                                                                                        |\n| `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000            | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     |\n| `upstream-base-url`                        | `NTFY_UPSTREAM_BASE_URL`                        | *URL*                                               | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers                                                                                                                   |\n| `upstream-access-token`                    | `NTFY_UPSTREAM_ACCESS_TOKEN`                    | *string*                                            | `tk_zyYLYj...`    | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth                                                                                                  |\n| `visitor-attachment-total-size-limit`      | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT`      | *size*                                              | 100M              | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`.                                                 |\n| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size*                                              | 500M              | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding.                                                                                        |\n| `visitor-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16                | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              |\n| `visitor-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h                | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        |\n| `visitor-message-daily-limit`              | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT`              | *number*                                            | -                 | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset.                                                                                              |\n| `visitor-request-limit-burst`              | `NTFY_VISITOR_REQUEST_LIMIT_BURST`              | *number*                                            | 60                | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has                                                                                           |\n| `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s                | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      |\n| `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP/CIDR list*                 | -                 | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                |\n| `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30                | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 |\n| `visitor-subscriber-rate-limiting`         | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING`         | *bool*                                              | `false`           | Rate limiting: Enables subscriber-based rate limiting                                                                                                                                                                           |\n| `visitor-prefix-bits-ipv4`                 | `NTFY_VISITOR_PREFIX_BITS_IPV4`                 | *number*                                            | 32                | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24                                                                                                                                                   |\n| `visitor-prefix-bits-ipv6`                 | `NTFY_VISITOR_PREFIX_BITS_IPV6`                 | *number*                                            | 64                | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48                                                                                                                                                   |\n| `web-root`                                 | `NTFY_WEB_ROOT`                                 | *path*, e.g. `/` or `/app`, or `disable`            | `/`               | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable)                                                                                                                                                   |\n| `enable-signup`                            | `NTFY_ENABLE_SIGNUP`                            | *boolean* (`true` or `false`)                       | `false`           | Allows users to sign up via the web app, or API                                                                                                                                                                                 |\n| `enable-login`                             | `NTFY_ENABLE_LOGIN`                             | *boolean* (`true` or `false`)                       | `false`           | Allows users to log in via the web app, or API                                                                                                                                                                                  |\n| `enable-reservations`                      | `NTFY_ENABLE_RESERVATIONS`                      | *boolean* (`true` or `false`)                       | `false`           | Allows users to reserve topics (if their tier allows it)                                                                                                                                                                        |\n| `require-login`                            | `NTFY_REQUIRE_LOGIN`                            | *boolean* (`true` or `false`)                       | `false`           | All actions via the web app require a login                                                                                                                                                                        |\n| `stripe-secret-key`                        | `NTFY_STRIPE_SECRET_KEY`                        | *string*                                            | -                 | Payments: Key used for the Stripe API communication, this enables payments                                                                                                                                                      |\n| `stripe-webhook-key`                       | `NTFY_STRIPE_WEBHOOK_KEY`                       | *string*                                            | -                 | Payments: Key required to validate the authenticity of incoming webhooks from Stripe                                                                                                                                            |\n| `billing-contact`                          | `NTFY_BILLING_CONTACT`                          | *email address* or *website*                        | -                 | Payments: Email or website displayed in Upgrade dialog as a billing contact                                                                                                                                                     |\n| `web-push-public-key`                      | `NTFY_WEB_PUSH_PUBLIC_KEY`                      | *string*                                            | -                 | Web Push: Public Key. Run `ntfy webpush keys` to generate                                                                                                                                                                       |\n| `web-push-private-key`                     | `NTFY_WEB_PUSH_PRIVATE_KEY`                     | *string*                                            | -                 | Web Push: Private Key. Run `ntfy webpush keys` to generate                                                                                                                                                                      |\n| `web-push-file`                            | `NTFY_WEB_PUSH_FILE`                            | *string*                                            | -                 | Web Push: Database file that stores subscriptions                                                                                                                                                                               |\n| `web-push-email-address`                   | `NTFY_WEB_PUSH_EMAIL_ADDRESS`                   | *string*                                            | -                 | Web Push: Sender email address                                                                                                                                                                                                  |\n| `web-push-startup-queries`                 | `NTFY_WEB_PUSH_STARTUP_QUERIES`                 | *string*                                            | -                 | Web Push: SQL queries to run against subscription database at startup                                                                                                                                                           |\n| `web-push-expiry-duration`                 | `NTFY_WEB_PUSH_EXPIRY_DURATION`                 | *duration*                                          | 60d               | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions.                                                                                                  |\n| `web-push-expiry-warning-duration`         | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION`         | *duration*                                          | 55d               | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions.                                                                               |\n| `log-format`                               | `NTFY_LOG_FORMAT`                               | *string*                                            | `text`            | Defines the output format, can be text or json                                                                                                                                                                                  |\n| `log-file`                                 | `NTFY_LOG_FILE`                                 | *string*                                            | -                 | Defines the filename to write logs to. If this is not set, ntfy logs to stderr                                                                                                                                                  |\n| `log-level`                                | `NTFY_LOG_LEVEL`                                | *string*                                            | `info`            | Defines the default log level, can be one of trace, debug, info, warn or error                                                                                                                                                  |\n\nThe format for a *duration* is: `<number>(smhd)`, e.g. 30s, 20m, 1h or 3d.   \nThe format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.\n\n## Command line options\n```\nNAME:\n   ntfy serve - Run the ntfy server\n\nUSAGE:\n   ntfy serve [OPTIONS..]\n\nCATEGORY:\n   Server commands\n\nDESCRIPTION:\n   Run the ntfy server and listen for incoming requests\n\n   The command will load the configuration from /etc/ntfy/server.yml. Config options can \n   be overridden using the command line options.\n\n   Examples:\n     ntfy serve                      # Starts server in the foreground (on port 80)\n     ntfy serve --listen-http :8080  # Starts server with alternate port\n\nOPTIONS:\n   --debug, -d                                                                                                            enable debug logging (default: false) [$NTFY_DEBUG]\n   --trace                                                                                                                enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]\n   --no-log-dates, --no_log_dates                                                                                         disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]\n   --log-level value, --log_level value                                                                                   set log level (default: \"INFO\") [$NTFY_LOG_LEVEL]\n   --log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ]  set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]\n   --log-format value, --log_format value                                                                                 set log format (default: \"text\") [$NTFY_LOG_FORMAT]\n   --log-file value, --log_file value                                                                                     set log file, default is STDOUT [$NTFY_LOG_FILE]\n   --config value, -c value                                                                                               config file (default: \"/etc/ntfy/server.yml\") [$NTFY_CONFIG_FILE]\n   --base-url value, --base_url value, -B value                                                                           externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]\n   --listen-http value, --listen_http value, -l value                                                                     ip:port used as HTTP listen address (default: \":80\") [$NTFY_LISTEN_HTTP]\n   --listen-https value, --listen_https value, -L value                                                                   ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]\n   --listen-unix value, --listen_unix value, -U value                                                                     listen on unix socket path [$NTFY_LISTEN_UNIX]\n   --listen-unix-mode value, --listen_unix_mode value                                                                     file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]\n   --key-file value, --key_file value, -K value                                                                           private key file, if listen-https is set [$NTFY_KEY_FILE]\n   --cert-file value, --cert_file value, -E value                                                                         certificate file, if listen-https is set [$NTFY_CERT_FILE]\n   --firebase-key-file value, --firebase_key_file value, -F value                                                         Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]\n   --cache-file value, --cache_file value, -C value                                                                       cache file used for message caching [$NTFY_CACHE_FILE]\n   --cache-duration since, --cache_duration since, -b since                                                               buffer messages for this time to allow since requests (default: \"12h\") [$NTFY_CACHE_DURATION]\n   --cache-batch-size value, --cache_batch_size value                                                                     max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]\n   --cache-batch-timeout value, --cache_batch_timeout value                                                               timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: \"0s\") [$NTFY_CACHE_BATCH_TIMEOUT]\n   --cache-startup-queries value, --cache_startup_queries value                                                           queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]\n   --auth-file value, --auth_file value, -H value                                                                         auth database file used for access control [$NTFY_AUTH_FILE]\n   --auth-startup-queries value, --auth_startup_queries value                                                             queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]\n   --auth-default-access value, --auth_default_access value, -p value                                                     default permissions if no matching entries in the auth database are found (default: \"read-write\") [$NTFY_AUTH_DEFAULT_ACCESS]\n   --attachment-cache-dir value, --attachment_cache_dir value                                                             cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]\n   --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                                     limit of the on-disk attachment cache (default: \"5G\") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]\n   --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                                       per-file attachment size limit (e.g. 300k, 2M, 100M) (default: \"15M\") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]\n   --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                                       duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: \"3h\") [$NTFY_ATTACHMENT_EXPIRY_DURATION]\n   --keepalive-interval value, --keepalive_interval value, -k value                                                       interval of keepalive messages (default: \"45s\") [$NTFY_KEEPALIVE_INTERVAL]\n   --manager-interval value, --manager_interval value, -m value                                                           interval of for message pruning and stats printing (default: \"1m\") [$NTFY_MANAGER_INTERVAL]\n   --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ]          topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]\n   --web-root value, --web_root value                                                                                     sets root of the web app (e.g. /, or /app), or disables it (disable) (default: \"/\") [$NTFY_WEB_ROOT]\n   --enable-signup, --enable_signup                                                                                       allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]\n   --enable-login, --enable_login                                                                                         allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]\n   --enable-reservations, --enable_reservations                                                                           allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]\n   --upstream-base-url value, --upstream_base_url value                                                                   forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]\n   --upstream-access-token value, --upstream_access_token value                                                           access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN]\n   --smtp-sender-addr value, --smtp_sender_addr value                                                                     SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]\n   --smtp-sender-user value, --smtp_sender_user value                                                                     SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]\n   --smtp-sender-pass value, --smtp_sender_pass value                                                                     SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]\n   --smtp-sender-from value, --smtp_sender_from value                                                                     SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]\n   --smtp-server-listen value, --smtp_server_listen value                                                                 SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]\n   --smtp-server-domain value, --smtp_server_domain value                                                                 SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]\n   --smtp-server-addr-prefix value, --smtp_server_addr_prefix value                                                       SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]\n   --twilio-account value, --twilio_account value                                                                         Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT]\n   --twilio-auth-token value, --twilio_auth_token value                                                                   Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]\n   --twilio-phone-number value, --twilio_phone_number value                                                               Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]\n   --twilio-verify-service value, --twilio_verify_service value                                                           Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]\n   --message-size-limit value, --message_size_limit value                                                                 size limit for the message (see docs for limitations) (default: \"4K\") [$NTFY_MESSAGE_SIZE_LIMIT]\n   --message-delay-limit value, --message_delay_limit value                                                               max duration a message can be scheduled into the future (default: \"3d\") [$NTFY_MESSAGE_DELAY_LIMIT]\n   --global-topic-limit value, --global_topic_limit value, -T value                                                       total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]\n   --visitor-subscription-limit value, --visitor_subscription_limit value                                                 number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]\n   --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting                                                 enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]\n   --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value                               total storage limit used for attachments per visitor (default: \"100M\") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]\n   --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value                     total daily attachment download/upload bandwidth limit per visitor (default: \"500M\") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]\n   --visitor-request-limit-burst value, --visitor_request_limit_burst value                                               initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]\n   --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                                       interval at which burst limit is replenished (one per x) (default: \"5s\") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]\n   --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value                                 hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]\n   --visitor-message-daily-limit value, --visitor_message_daily_limit value                                               max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]\n   --visitor-email-limit-burst value, --visitor_email_limit_burst value                                                   initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]\n   --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                                           interval at which burst limit is replenished (one per x) (default: \"1h\") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]\n   --visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value                                                     number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4]\n   --visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value                                                     number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6]\n   --behind-proxy, --behind_proxy, -P                                                                                     if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]\n   --proxy-forwarded-header value, --proxy_forwarded_header value                                                         use specified header to determine visitor IP address (for rate limiting) (default: \"X-Forwarded-For\") [$NTFY_PROXY_FORWARDED_HEADER]\n   --proxy-trusted-hosts value, --proxy_trusted_hosts value                                                               comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header [$NTFY_PROXY_TRUSTED_HOSTS]\n   --stripe-secret-key value, --stripe_secret_key value                                                                   key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]\n   --stripe-webhook-key value, --stripe_webhook_key value                                                                 key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]\n   --billing-contact value, --billing_contact value                                                                       e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]\n   --enable-metrics, --enable_metrics                                                                                     if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS]\n   --metrics-listen-http value, --metrics_listen_http value                                                               ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP]\n   --profile-listen-http value, --profile_listen_http value                                                               ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP]\n   --web-push-public-key value, --web_push_public_key value                                                               public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY]\n   --web-push-private-key value, --web_push_private_key value                                                             private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]\n   --web-push-file value, --web_push_file value                                                                           file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]\n   --web-push-email-address value, --web_push_email_address value                                                         e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]\n   --web-push-startup-queries value, --web_push_startup_queries value                                                     queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]\n   --web-push-expiry-duration value, --web_push_expiry_duration value                                                     automatically expire unused subscriptions after this time (default: \"60d\") [$NTFY_WEB_PUSH_EXPIRY_DURATION]\n   --web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value                                     send web push warning notification after this time before expiring unused subscriptions (default: \"55d\") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]\n   --help, -h \n```\n"
  },
  {
    "path": "docs/contact.md",
    "content": "# Contact\n\nThis service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the \nntfy community. Please choose the appropriate channel based on your needs.\n\n## Support\n\n### Community support\n\nFor general questions, feature discussions, and community help, please use one of these public channels:\n\n| Channel           | Link                                                                                 | Description                                                |\n|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------|\n| **Discord**       | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w)                               | Real-time chat with the community (I'm `binwiederhier`)    |\n| **Matrix**        | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org)                             | Bridged from Discord, same community (I'm `binwiederhier`) |\n| **Matrix Space**  | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org)                 | Matrix space with all ntfy rooms                           |\n| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests                           |\n\n!!! info \"Why public channels?\"\n    Answering questions in public channels benefits the entire community. Other users can learn from the \n    discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support.\n\n### Paid support\n\nIf you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support \nvia the following channels:\n\n| Channel               | Contact                                             | Description                              |\n|-----------------------|-----------------------------------------------------|------------------------------------------|\n| **General Support**   | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |\n| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues             |\n| **Discord/Matrix**    | Mention your Pro status                             | Priority responses in community channels |\n\nPlease include your ntfy.sh username when contacting support so we can verify your subscription status.\n\n## Security issues\n\nIf you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).\n\n## Other inquiries\n\nFor questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights \n(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).\n\nFor business inquiries, partnerships, press, or other general questions that don't fit the categories above, please\nuse [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh).\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "# Contributing\n\nThank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer,\ntranslator, or just an enthusiastic user.\n\n## Code contributions\n\nIf you'd like to contribute code to ntfy:\n\n1. Check out the [development guide](develop.md) to set up your environment\n2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own\n3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss \n   before investing significant time\n4. Submit a pull request on GitHub\n\nAll contributions are welcome, from small bug fixes to major features.\n\n## Translations\n\nHelp make ntfy accessible to users around the world! We use Hosted Weblate for translations:\n\n- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/)\n\nYou can start translating immediately without any coding knowledge.\n\n## Documentation\n\nFound a typo? Want to improve the docs? Documentation contributions are very welcome:\n\n- Edit any page directly on GitHub using the edit button\n- Submit a pull request with your improvements\n\n## Bug reports and feature requests\n\n- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues)\n\nPlease search existing issues before creating a new one to avoid duplicates.\n\n## Code of Conduct\n\nPlease be respectful and constructive in all interactions. See the \n[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details.\n\n"
  },
  {
    "path": "docs/deprecations.md",
    "content": "# Deprecations and breaking changes\nThis page is used to list deprecation notices for ntfy. Deprecated commands and options will be \n**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated\nbefore the behavior is changed depends on the severity of the change, and how prominent the feature is.\n\n## Active deprecations\n_No active deprecations_\n\n## Previous deprecations\n\n### ntfy CLI: `ntfy publish --env-topic` will be removed\n> Active since 2022-06-20, behavior changed with v1.30.1\n\nThe `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the\n`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag.\n\n=== \"Before\"\n    ```\n    $ NTFY_TOPIC=mytopic ntfy publish --env-topic \"this is the message\"\n    ```\n\n=== \"After\"\n    ```\n    $ NTFY_TOPIC=mytopic ntfy publish \"this is the message\"\n    ```\n\n### <del>Android app: WebSockets will become the default connection protocol</del>\n> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20)\n\nInstant delivery connections and connections to self-hosted servers in the Android app were going to switch\nto use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default\nand add a notice banner in the Android app instead.\n\n### Android app: Using `since=<timestamp>` instead of `since=<id>`\n> Active since 2022-02-27, behavior changed with v1.14.0\n\nThe Android app started using `since=<id>` instead of `since=<timestamp>`, which means as of Android app v1.14.0, \nit will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.\n\nThe `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.\n\n### Running server via `ntfy` (instead of `ntfy serve`)\n> Deprecated 2021-12-17, behavior changed with v1.10.0\n\nAs more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical\nanymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than\njust the server.\n\n=== \"Before\"\n    ```\n    $ ntfy\n    2021/12/17 08:16:01 Listening on :80/http\n    ```\n\n=== \"After\"\n    ```\n    $ ntfy serve\n    2021/12/17 08:16:01 Listening on :80/http\n    ```\n\n"
  },
  {
    "path": "docs/develop.md",
    "content": "# Development\nHurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎\n\nI tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't \nhesitate to reach out via one of the channels listed on the [contact page](contact.md).\n\n## ntfy server\nThe ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the\nserver consists of three components:\n\n* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at \n  [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is \n  in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a \n  [SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires \n  [Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).\n* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),\n  which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to\n  build the docs.\n* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/)\n  to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`) \n  and install all the 100,000 dependencies (*sigh*).\n\nAll of these components are built and then **baked into one binary**. \n\n### Navigating the code\nCode:\n\n* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client\n* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`\n* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic\n* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`\n* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`\n\nBuild related:\n\n* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building\n* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))\n* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file\n* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))\n* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)\n\n\nThe `web/` and `docs/` folder are the sources for web app and documentation. During the build process,\nthe generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).\n\n### Build/test on Gitpod\nTo get a quick working development environment you can use [Gitpod](https://gitpod.io), an in-browser IDE \nthat makes it easy to develop ntfy without having to set up a desktop IDE. For any real development,\nI do suggest a proper IDE like [IntelliJ IDEA](https://www.jetbrains.com/idea/).\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)\n\n### Build requirements\n\n* [Go](https://go.dev/) (required for main server)\n* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)\n* [Make](https://www.gnu.org/software/make/) (required for convenience)\n* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)\n* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)\n* [Python](https://www.python.org/) (for `pip`, only to build the docs) \n* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)\n\n### Install dependencies\nThese steps **assume Ubuntu**. Steps may vary on different Linux distributions.\n\nFirst, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):\n``` shell\nwget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz\nsudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz\nexport PATH=$PATH:/usr/local/go/bin:$HOME/go/bin\ngo version   # verifies that it worked\n```\n\nInstall [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):\n``` shell\ngo install github.com/goreleaser/goreleaser@latest\ngoreleaser -v   # verifies that it worked\n```\n\nInstall [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):\n``` shell\ncurl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -\nsudo apt-get install -y nodejs\nnpm -v   # verifies that it worked\n```\n\nThen install a few other things required:\n``` shell\nsudo apt install \\\n    build-essential \\\n    libsqlite3-dev \\\n    gcc-arm-linux-gnueabi \\\n    gcc-aarch64-linux-gnu \\\n    python3-pip \\\n    git\n```\n\n### Check out code\nNow check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):\n\n=== \"via HTTPS\"\n    ``` shell\n    git clone https://github.com/binwiederhier/ntfy.git\n    cd ntfy\n    ```\n\n=== \"via SSH\"\n    ``` shell\n    git clone git@github.com:binwiederhier/ntfy.git \n    cd ntfy\n    ```\n\n### Build all the things\nNow you can finally build everything. There are tons of `make` targets, so maybe just review what's there first \nby typing `make`:\n\n``` shell\n$ make \nTypical commands (more see below):\n  make build                   - Build web app, documentation and server/client (sloowwww)\n  make cli-linux-amd64         - Build server/client binary (amd64, no web app or docs)\n  make install-linux-amd64     - Install ntfy binary to /usr/bin/ntfy (amd64)\n  make web                     - Build the web app\n  make docs                    - Build the documentation\n  make check                   - Run all tests, vetting/formatting checks and linters\n...\n```\n\nIf you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64), \nyou can simply run `make build`:\n\n``` shell\n$ make build\n...\n# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64). \n# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?\n```\n\nYou'll see all the outputs in the `dist/` folder afterwards:\n\n``` bash\n$ find dist \ndist\ndist/metadata.json\ndist/ntfy_arm64_linux_arm64\ndist/ntfy_arm64_linux_arm64/ntfy\ndist/ntfy_armv7_linux_arm_7\ndist/ntfy_armv7_linux_arm_7/ntfy\ndist/ntfy_amd64_linux_amd64\ndist/ntfy_amd64_linux_amd64/ntfy\ndist/config.yaml\ndist/artifacts.json\n```\n\nIf you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can \nuse the `make release-snapshot` target:\n\n``` shell\n$ make release-snapshot\n...\n# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)\n```\n\nDuring development, you may want to be more picky and build only certain things. Here are a few examples.\n\n### Build a Docker image only for Linux\n\nThis is useful to test the final build with web app, docs, and server without any dependencies locally\n\n``` shell\n$ make docker-dev\n$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve\n```\n\n### Build the ntfy binary\nTo build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:\n\n``` shell\n$ make\nBuild server & client (using GoReleaser, not release version):\n  make cli                        - Build server & client (all architectures)\n  make cli-linux-amd64            - Build server & client (Linux, amd64 only)\n  make cli-linux-armv6            - Build server & client (Linux, armv6 only)\n  make cli-linux-armv7            - Build server & client (Linux, armv7 only)\n  make cli-linux-arm64            - Build server & client (Linux, arm64 only)\n  make cli-windows-amd64          - Build client (Windows, amd64 only)\n  make cli-darwin-all             - Build client (macOS, arm64+amd64 universal binary)\n```\n\nSo if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern\nsystem, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary\nright away:\n\n``` shell\n$ make cli-linux-amd64 install-linux-amd64\n$ ntfy serve\n```\n\n**During development of the main app, you can also just use `go run main.go`**, as long as you run \n`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`:\n\n``` shell\n$ export CGO_ENABLED=1\n$ make cli-deps-static-sites\n$ go run main.go serve\n2022/03/18 08:43:55 Listening on :2586[http]\n...\n```\n\nIf you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:\n```\n$ go run main.go serve\nserver/server.go:85:13: pattern docs: no matching files found\n```\n\nThis is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be\npresent at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites`\ntarget creates dummy files that ensure that you'll be able to build.\n\nWhile not officially supported (or released), you can build and run the server **on macOS** as well. Simply run \n`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it.\n\n### Build the web app\nThe sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app \nis really simple. Just type `make web` and you're in business:\n\n``` shell\n$ make web\n...\n```\n\nThis will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so \nthat when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary.\n\nIf you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser\nat `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser \nwill automatically refresh:\n\n``` shell\n$ cd web\n$ npm start\n```\n\n### Testing Web Push locally\n\nReference: <https://stackoverflow.com/questions/34160509/options-for-testing-service-workers-via-http>\n\n#### With the dev servers\n\n1. Get web push keys `go run main.go webpush keys`\n\n2. Run the server with web push enabled\n\n    ```sh\n    go run main.go \\\n      --log-level debug \\\n      serve \\\n        --web-push-public-key KEY \\\n        --web-push-private-key KEY \\\n        --web-push-email-address <email> \\\n        --web-push-file=/tmp/webpush.db\n    ```\n\n3. In `web/public/config.js`:\n\n   - Set `base_url` to `http://localhost`, This is required as web push can only be used with the server matching the `base_url`.\n\n   - Set the `web_push_public_key` correctly.\n\n4. Run `npm run start`\n\n#### With a built package\n\n1. Run `make web-build`\n\n2. Run the server (step 2 above)\n\n3. Open <http://localhost/>\n### Build the docs\nThe sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the \ndocumentation. As long as you have `mkdocs` installed (see above), this should work fine:\n\n``` shell\n$ make docs\n...\n```\n\nIf you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation, \nserve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files: \n\n```\n$ mkdocs serve\nINFO     -  Building documentation...\nINFO     -  Cleaning site directory\nINFO     -  Documentation built in 5.53 seconds\nINFO     -  [16:28:14] Serving on http://127.0.0.1:8000/\n```\n\nThen you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.\n\n## Android app\nThe ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).\nThe Android app has two flavors:\n\n* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account\n* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies\n\n### Navigating the code\n* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code\n* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code\n* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs\n* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file\n\n### IDE/Environment\nYou should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/) \nwith the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀 \n\n### Check out the code\nFirst check out the repository:\n\n=== \"via HTTPS\"\n    ``` shell\n    git clone https://github.com/binwiederhier/ntfy-android.git\n    cd ntfy-android\n    ```\n\n=== \"via SSH\"\n    ``` shell\n    git clone git@github.com:binwiederhier/ntfy-android.git\n    cd ntfy-android\n    ```\n\nThen either follow the steps for building with or without Firebase.\n\n### Build F-Droid flavor (no FCM)\n!!! info\n    I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will\n    work without issues. Please give me feedback if it does/doesn't work for you.\n\nWithout Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)\nif you're self-hosting the server. Then run:\n```\n# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)\n./gradlew assembleFdroidRelease\n\n# To build a bundle .aab (app/fdroid/release/*.aab)\n./gradlew bundleFdroidRelease\n```\n\nThe F-Droid flavor automatically excludes Google Services dependencies.\n\n### Build Play flavor (FCM)\n!!! info\n    I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will\n    work without issues. Please give me feedback if it does/doesn't work for you.\n\nTo build your own version with Firebase, you must:\n\n* Create a Firebase/FCM account\n* Place your account file at `app/google-services.json`\n* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)\n* Then run:\n```\n# To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)\n./gradlew assemblePlayRelease\n\n# To build a bundle .aab (app/play/release/*.aab)\n./gradlew bundlePlayRelease\n```\n\n## iOS app\nBuilding the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are \nstrictly based off of my development on this app. There may be other versions of macOS / XCode that work.\n\n### Requirements\n1. macOS Monterey or later\n1. XCode 13.2+\n1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)\n1. Firebase account\n1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)\n\n### Apple setup\n\n!!! info\n    Along with this step, the [PLIST Deployment](#plist-config) step is also required \n    for these changes to take effect in the iOS app.\n\n1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)\n  1. Select \"Apple Push Notifications service (APNs)\"\n1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)\n1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page\n1. Next, navigate to \"Project Settings\" in the firebase console for your project, and select the iOS app you created. Then, click \"Cloud Messaging\" in the left sidebar, and scroll down to the \"APNs Authentication Key\" section. Click \"Upload Key\", and upload the key you downloaded from Apple Developer.\n\n!!! warning  \n    If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.\n\nIf you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered\ninstantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application\nsends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,\ndays or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly\nrecommended.\n\n### Firebase setup\n\n1. If you haven't already, create a Google / Firebase account\n1. Visit the [Firebase console](https://console.firebase.google.com)\n1. Create a new Firebase project:\n  1. Enter a project name\n  1. Disable Google Analytics (currently iOS app does not support analytics)\n1. On the \"Project settings\" page, add an iOS app\n  1. Apple bundle ID - \"com.copephobia.ntfy-ios\" (this can be changed to match XCode's ntfy.sh target > \"Bundle Identifier\" value)\n  1. Register the app\n  1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)\n1. Generate a new service account private key for the ntfy server\n  1. Go to \"Project settings\" > \"Service accounts\"\n  1. Click \"Generate new private key\" to generate and download a private key to use for sending messages via the ntfy server\n\n### ntfy server\nNote that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these\nsteps:\n\n1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder\n1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`\n1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key\n1. Install go: `brew install go`\n1. In the ntfy repository, run `make cli-darwin-server`.\n\n### XCode setup\n\n1. Follow step 4 of [Add Firebase to your Apple project](https://firebase.google.com/docs/ios/setup) to install the \n   `firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging\n1. Similarly, install the SQLite.swift package dependency in XCode\n1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators\n\n### PLIST config\nTo have instant notifications/better notification delivery when using firebase, you will need to add the \n`GoogleService-Info.plist` file to your project. Here's how to do that:\n\n1. In XCode, find the NTFY app target. **Not** the NSE app target.\n1. Find the Asset/ folder in the project navigator\n1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be \n   found in the \"Project settings\" > \"General\" > \"Your apps\"  with a button labeled \"GoogleService-Info.plist\"\n\nAfter that, you should be all set!\n"
  },
  {
    "path": "docs/emojis.md",
    "content": "# Emoji reference\n\n<!-- This file was generated by scripts/emoji-convert.sh -->\n\nYou can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically\nconverted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the\n[tagging and emojis page](publish.md#tags-emojis).\n\n<table class=\"remove-md-box emoji-table\"><tr>\n\n<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>\n<tr><td class=c><code>grinning</code></td><td class=e>😀</td></tr>\n<tr><td class=c><code>smiley</code></td><td class=e>😃</td></tr>\n<tr><td class=c><code>smile</code></td><td class=e>😄</td></tr>\n<tr><td class=c><code>grin</code></td><td class=e>😁</td></tr>\n<tr><td class=c><code>laughing</code></td><td class=e>😆</td></tr>\n<tr><td class=c><code>sweat_smile</code></td><td class=e>😅</td></tr>\n<tr><td class=c><code>rofl</code></td><td class=e>🤣</td></tr>\n<tr><td class=c><code>joy</code></td><td class=e>😂</td></tr>\n<tr><td class=c><code>slightly_smiling_face</code></td><td class=e>🙂</td></tr>\n<tr><td class=c><code>upside_down_face</code></td><td class=e>🙃</td></tr>\n<tr><td class=c><code>wink</code></td><td class=e>😉</td></tr>\n<tr><td class=c><code>blush</code></td><td class=e>😊</td></tr>\n<tr><td class=c><code>innocent</code></td><td class=e>😇</td></tr>\n<tr><td class=c><code>smiling_face_with_three_hearts</code></td><td class=e>🥰</td></tr>\n<tr><td class=c><code>heart_eyes</code></td><td class=e>😍</td></tr>\n<tr><td class=c><code>star_struck</code></td><td class=e>🤩</td></tr>\n<tr><td class=c><code>kissing_heart</code></td><td class=e>😘</td></tr>\n<tr><td class=c><code>kissing</code></td><td class=e>😗</td></tr>\n<tr><td class=c><code>relaxed</code></td><td class=e>☺️</td></tr>\n<tr><td class=c><code>kissing_closed_eyes</code></td><td class=e>😚</td></tr>\n<tr><td class=c><code>kissing_smiling_eyes</code></td><td class=e>😙</td></tr>\n<tr><td class=c><code>smiling_face_with_tear</code></td><td class=e>🥲</td></tr>\n<tr><td class=c><code>yum</code></td><td class=e>😋</td></tr>\n<tr><td class=c><code>stuck_out_tongue</code></td><td class=e>😛</td></tr>\n<tr><td class=c><code>stuck_out_tongue_winking_eye</code></td><td class=e>😜</td></tr>\n<tr><td class=c><code>zany_face</code></td><td class=e>🤪</td></tr>\n<tr><td class=c><code>stuck_out_tongue_closed_eyes</code></td><td class=e>😝</td></tr>\n<tr><td class=c><code>money_mouth_face</code></td><td class=e>🤑</td></tr>\n<tr><td class=c><code>hugs</code></td><td class=e>🤗</td></tr>\n<tr><td class=c><code>hand_over_mouth</code></td><td class=e>🤭</td></tr>\n<tr><td class=c><code>shushing_face</code></td><td class=e>🤫</td></tr>\n<tr><td class=c><code>thinking</code></td><td class=e>🤔</td></tr>\n<tr><td class=c><code>zipper_mouth_face</code></td><td class=e>🤐</td></tr>\n<tr><td class=c><code>raised_eyebrow</code></td><td class=e>🤨</td></tr>\n<tr><td class=c><code>neutral_face</code></td><td class=e>😐</td></tr>\n<tr><td class=c><code>expressionless</code></td><td class=e>😑</td></tr>\n<tr><td class=c><code>no_mouth</code></td><td class=e>😶</td></tr>\n<tr><td class=c><code>face_in_clouds</code></td><td class=e>😶‍🌫️</td></tr>\n<tr><td class=c><code>smirk</code></td><td class=e>😏</td></tr>\n<tr><td class=c><code>unamused</code></td><td class=e>😒</td></tr>\n<tr><td class=c><code>roll_eyes</code></td><td class=e>🙄</td></tr>\n<tr><td class=c><code>grimacing</code></td><td class=e>😬</td></tr>\n<tr><td class=c><code>face_exhaling</code></td><td class=e>😮‍💨</td></tr>\n<tr><td class=c><code>lying_face</code></td><td class=e>🤥</td></tr>\n<tr><td class=c><code>relieved</code></td><td class=e>😌</td></tr>\n<tr><td class=c><code>pensive</code></td><td class=e>😔</td></tr>\n<tr><td class=c><code>sleepy</code></td><td class=e>😪</td></tr>\n<tr><td class=c><code>drooling_face</code></td><td class=e>🤤</td></tr>\n<tr><td class=c><code>sleeping</code></td><td class=e>😴</td></tr>\n<tr><td class=c><code>mask</code></td><td class=e>😷</td></tr>\n<tr><td class=c><code>face_with_thermometer</code></td><td class=e>🤒</td></tr>\n<tr><td class=c><code>face_with_head_bandage</code></td><td class=e>🤕</td></tr>\n<tr><td class=c><code>nauseated_face</code></td><td class=e>🤢</td></tr>\n<tr><td class=c><code>vomiting_face</code></td><td class=e>🤮</td></tr>\n<tr><td class=c><code>sneezing_face</code></td><td class=e>🤧</td></tr>\n<tr><td class=c><code>hot_face</code></td><td class=e>🥵</td></tr>\n<tr><td class=c><code>cold_face</code></td><td class=e>🥶</td></tr>\n<tr><td class=c><code>woozy_face</code></td><td class=e>🥴</td></tr>\n<tr><td class=c><code>dizzy_face</code></td><td class=e>😵</td></tr>\n<tr><td class=c><code>face_with_spiral_eyes</code></td><td class=e>😵‍💫</td></tr>\n<tr><td class=c><code>exploding_head</code></td><td class=e>🤯</td></tr>\n<tr><td class=c><code>cowboy_hat_face</code></td><td class=e>🤠</td></tr>\n<tr><td class=c><code>partying_face</code></td><td class=e>🥳</td></tr>\n<tr><td class=c><code>disguised_face</code></td><td class=e>🥸</td></tr>\n<tr><td class=c><code>sunglasses</code></td><td class=e>😎</td></tr>\n<tr><td class=c><code>nerd_face</code></td><td class=e>🤓</td></tr>\n<tr><td class=c><code>monocle_face</code></td><td class=e>🧐</td></tr>\n<tr><td class=c><code>confused</code></td><td class=e>😕</td></tr>\n<tr><td class=c><code>worried</code></td><td class=e>😟</td></tr>\n<tr><td class=c><code>slightly_frowning_face</code></td><td class=e>🙁</td></tr>\n<tr><td class=c><code>frowning_face</code></td><td class=e>☹️</td></tr>\n<tr><td class=c><code>open_mouth</code></td><td class=e>😮</td></tr>\n<tr><td class=c><code>hushed</code></td><td class=e>😯</td></tr>\n<tr><td class=c><code>astonished</code></td><td class=e>😲</td></tr>\n<tr><td class=c><code>flushed</code></td><td class=e>😳</td></tr>\n<tr><td class=c><code>pleading_face</code></td><td class=e>🥺</td></tr>\n<tr><td class=c><code>frowning</code></td><td class=e>😦</td></tr>\n<tr><td class=c><code>anguished</code></td><td class=e>😧</td></tr>\n<tr><td class=c><code>fearful</code></td><td class=e>😨</td></tr>\n<tr><td class=c><code>cold_sweat</code></td><td class=e>😰</td></tr>\n<tr><td class=c><code>disappointed_relieved</code></td><td class=e>😥</td></tr>\n<tr><td class=c><code>cry</code></td><td class=e>😢</td></tr>\n<tr><td class=c><code>sob</code></td><td class=e>😭</td></tr>\n<tr><td class=c><code>scream</code></td><td class=e>😱</td></tr>\n<tr><td class=c><code>confounded</code></td><td class=e>😖</td></tr>\n<tr><td class=c><code>persevere</code></td><td class=e>😣</td></tr>\n<tr><td class=c><code>disappointed</code></td><td class=e>😞</td></tr>\n<tr><td class=c><code>sweat</code></td><td class=e>😓</td></tr>\n<tr><td class=c><code>weary</code></td><td class=e>😩</td></tr>\n<tr><td class=c><code>tired_face</code></td><td class=e>😫</td></tr>\n<tr><td class=c><code>yawning_face</code></td><td class=e>🥱</td></tr>\n<tr><td class=c><code>triumph</code></td><td class=e>😤</td></tr>\n<tr><td class=c><code>rage</code></td><td class=e>😡</td></tr>\n<tr><td class=c><code>angry</code></td><td class=e>😠</td></tr>\n<tr><td class=c><code>cursing_face</code></td><td class=e>🤬</td></tr>\n<tr><td class=c><code>smiling_imp</code></td><td class=e>😈</td></tr>\n<tr><td class=c><code>imp</code></td><td class=e>👿</td></tr>\n<tr><td class=c><code>skull</code></td><td class=e>💀</td></tr>\n<tr><td class=c><code>skull_and_crossbones</code></td><td class=e>☠️</td></tr>\n<tr><td class=c><code>hankey</code></td><td class=e>💩</td></tr>\n<tr><td class=c><code>clown_face</code></td><td class=e>🤡</td></tr>\n<tr><td class=c><code>japanese_ogre</code></td><td class=e>👹</td></tr>\n<tr><td class=c><code>japanese_goblin</code></td><td class=e>👺</td></tr>\n<tr><td class=c><code>ghost</code></td><td class=e>👻</td></tr>\n<tr><td class=c><code>alien</code></td><td class=e>👽</td></tr>\n<tr><td class=c><code>space_invader</code></td><td class=e>👾</td></tr>\n<tr><td class=c><code>robot</code></td><td class=e>🤖</td></tr>\n<tr><td class=c><code>smiley_cat</code></td><td class=e>😺</td></tr>\n<tr><td class=c><code>smile_cat</code></td><td class=e>😸</td></tr>\n<tr><td class=c><code>joy_cat</code></td><td class=e>😹</td></tr>\n<tr><td class=c><code>heart_eyes_cat</code></td><td class=e>😻</td></tr>\n<tr><td class=c><code>smirk_cat</code></td><td class=e>😼</td></tr>\n<tr><td class=c><code>kissing_cat</code></td><td class=e>😽</td></tr>\n<tr><td class=c><code>scream_cat</code></td><td class=e>🙀</td></tr>\n<tr><td class=c><code>crying_cat_face</code></td><td class=e>😿</td></tr>\n<tr><td class=c><code>pouting_cat</code></td><td class=e>😾</td></tr>\n<tr><td class=c><code>see_no_evil</code></td><td class=e>🙈</td></tr>\n<tr><td class=c><code>hear_no_evil</code></td><td class=e>🙉</td></tr>\n<tr><td class=c><code>speak_no_evil</code></td><td class=e>🙊</td></tr>\n<tr><td class=c><code>kiss</code></td><td class=e>💋</td></tr>\n<tr><td class=c><code>love_letter</code></td><td class=e>💌</td></tr>\n<tr><td class=c><code>cupid</code></td><td class=e>💘</td></tr>\n<tr><td class=c><code>gift_heart</code></td><td class=e>💝</td></tr>\n<tr><td class=c><code>sparkling_heart</code></td><td class=e>💖</td></tr>\n<tr><td class=c><code>heartpulse</code></td><td class=e>💗</td></tr>\n<tr><td class=c><code>heartbeat</code></td><td class=e>💓</td></tr>\n<tr><td class=c><code>revolving_hearts</code></td><td class=e>💞</td></tr>\n<tr><td class=c><code>two_hearts</code></td><td class=e>💕</td></tr>\n<tr><td class=c><code>heart_decoration</code></td><td class=e>💟</td></tr>\n<tr><td class=c><code>heavy_heart_exclamation</code></td><td class=e>❣️</td></tr>\n<tr><td class=c><code>broken_heart</code></td><td class=e>💔</td></tr>\n<tr><td class=c><code>heart_on_fire</code></td><td class=e>❤️‍🔥</td></tr>\n<tr><td class=c><code>mending_heart</code></td><td class=e>❤️‍🩹</td></tr>\n<tr><td class=c><code>heart</code></td><td class=e>❤️</td></tr>\n<tr><td class=c><code>orange_heart</code></td><td class=e>🧡</td></tr>\n<tr><td class=c><code>yellow_heart</code></td><td class=e>💛</td></tr>\n<tr><td class=c><code>green_heart</code></td><td class=e>💚</td></tr>\n<tr><td class=c><code>blue_heart</code></td><td class=e>💙</td></tr>\n<tr><td class=c><code>purple_heart</code></td><td class=e>💜</td></tr>\n<tr><td class=c><code>brown_heart</code></td><td class=e>🤎</td></tr>\n<tr><td class=c><code>black_heart</code></td><td class=e>🖤</td></tr>\n<tr><td class=c><code>white_heart</code></td><td class=e>🤍</td></tr>\n<tr><td class=c><code>100</code></td><td class=e>💯</td></tr>\n<tr><td class=c><code>anger</code></td><td class=e>💢</td></tr>\n<tr><td class=c><code>boom</code></td><td class=e>💥</td></tr>\n<tr><td class=c><code>dizzy</code></td><td class=e>💫</td></tr>\n<tr><td class=c><code>sweat_drops</code></td><td class=e>💦</td></tr>\n<tr><td class=c><code>dash</code></td><td class=e>💨</td></tr>\n<tr><td class=c><code>hole</code></td><td class=e>🕳️</td></tr>\n<tr><td class=c><code>bomb</code></td><td class=e>💣</td></tr>\n<tr><td class=c><code>speech_balloon</code></td><td class=e>💬</td></tr>\n<tr><td class=c><code>eye_speech_bubble</code></td><td class=e>👁️‍🗨️</td></tr>\n<tr><td class=c><code>left_speech_bubble</code></td><td class=e>🗨️</td></tr>\n<tr><td class=c><code>right_anger_bubble</code></td><td class=e>🗯️</td></tr>\n<tr><td class=c><code>thought_balloon</code></td><td class=e>💭</td></tr>\n<tr><td class=c><code>zzz</code></td><td class=e>💤</td></tr>\n<tr><td class=c><code>wave</code></td><td class=e>👋</td></tr>\n<tr><td class=c><code>raised_back_of_hand</code></td><td class=e>🤚</td></tr>\n<tr><td class=c><code>raised_hand_with_fingers_splayed</code></td><td class=e>🖐️</td></tr>\n<tr><td class=c><code>hand</code></td><td class=e>✋</td></tr>\n<tr><td class=c><code>vulcan_salute</code></td><td class=e>🖖</td></tr>\n<tr><td class=c><code>ok_hand</code></td><td class=e>👌</td></tr>\n<tr><td class=c><code>pinched_fingers</code></td><td class=e>🤌</td></tr>\n<tr><td class=c><code>pinching_hand</code></td><td class=e>🤏</td></tr>\n<tr><td class=c><code>v</code></td><td class=e>✌️</td></tr>\n<tr><td class=c><code>crossed_fingers</code></td><td class=e>🤞</td></tr>\n<tr><td class=c><code>love_you_gesture</code></td><td class=e>🤟</td></tr>\n<tr><td class=c><code>metal</code></td><td class=e>🤘</td></tr>\n<tr><td class=c><code>call_me_hand</code></td><td class=e>🤙</td></tr>\n<tr><td class=c><code>point_left</code></td><td class=e>👈</td></tr>\n<tr><td class=c><code>point_right</code></td><td class=e>👉</td></tr>\n<tr><td class=c><code>point_up_2</code></td><td class=e>👆</td></tr>\n<tr><td class=c><code>middle_finger</code></td><td class=e>🖕</td></tr>\n<tr><td class=c><code>point_down</code></td><td class=e>👇</td></tr>\n<tr><td class=c><code>point_up</code></td><td class=e>☝️</td></tr>\n<tr><td class=c><code>+1</code></td><td class=e>👍</td></tr>\n<tr><td class=c><code>-1</code></td><td class=e>👎</td></tr>\n<tr><td class=c><code>fist_raised</code></td><td class=e>✊</td></tr>\n<tr><td class=c><code>fist_oncoming</code></td><td class=e>👊</td></tr>\n<tr><td class=c><code>fist_left</code></td><td class=e>🤛</td></tr>\n<tr><td class=c><code>fist_right</code></td><td class=e>🤜</td></tr>\n<tr><td class=c><code>clap</code></td><td class=e>👏</td></tr>\n<tr><td class=c><code>raised_hands</code></td><td class=e>🙌</td></tr>\n<tr><td class=c><code>open_hands</code></td><td class=e>👐</td></tr>\n<tr><td class=c><code>palms_up_together</code></td><td class=e>🤲</td></tr>\n<tr><td class=c><code>handshake</code></td><td class=e>🤝</td></tr>\n<tr><td class=c><code>pray</code></td><td class=e>🙏</td></tr>\n<tr><td class=c><code>writing_hand</code></td><td class=e>✍️</td></tr>\n<tr><td class=c><code>nail_care</code></td><td class=e>💅</td></tr>\n<tr><td class=c><code>selfie</code></td><td class=e>🤳</td></tr>\n<tr><td class=c><code>muscle</code></td><td class=e>💪</td></tr>\n<tr><td class=c><code>mechanical_arm</code></td><td class=e>🦾</td></tr>\n<tr><td class=c><code>mechanical_leg</code></td><td class=e>🦿</td></tr>\n<tr><td class=c><code>leg</code></td><td class=e>🦵</td></tr>\n<tr><td class=c><code>foot</code></td><td class=e>🦶</td></tr>\n<tr><td class=c><code>ear</code></td><td class=e>👂</td></tr>\n<tr><td class=c><code>ear_with_hearing_aid</code></td><td class=e>🦻</td></tr>\n<tr><td class=c><code>nose</code></td><td class=e>👃</td></tr>\n<tr><td class=c><code>brain</code></td><td class=e>🧠</td></tr>\n<tr><td class=c><code>anatomical_heart</code></td><td class=e>🫀</td></tr>\n<tr><td class=c><code>lungs</code></td><td class=e>🫁</td></tr>\n<tr><td class=c><code>tooth</code></td><td class=e>🦷</td></tr>\n<tr><td class=c><code>bone</code></td><td class=e>🦴</td></tr>\n<tr><td class=c><code>eyes</code></td><td class=e>👀</td></tr>\n<tr><td class=c><code>eye</code></td><td class=e>👁️</td></tr>\n<tr><td class=c><code>tongue</code></td><td class=e>👅</td></tr>\n<tr><td class=c><code>lips</code></td><td class=e>👄</td></tr>\n<tr><td class=c><code>baby</code></td><td class=e>👶</td></tr>\n<tr><td class=c><code>child</code></td><td class=e>🧒</td></tr>\n<tr><td class=c><code>boy</code></td><td class=e>👦</td></tr>\n<tr><td class=c><code>girl</code></td><td class=e>👧</td></tr>\n<tr><td class=c><code>adult</code></td><td class=e>🧑</td></tr>\n<tr><td class=c><code>blond_haired_person</code></td><td class=e>👱</td></tr>\n<tr><td class=c><code>man</code></td><td class=e>👨</td></tr>\n<tr><td class=c><code>bearded_person</code></td><td class=e>🧔</td></tr>\n<tr><td class=c><code>man_beard</code></td><td class=e>🧔‍♂️</td></tr>\n<tr><td class=c><code>woman_beard</code></td><td class=e>🧔‍♀️</td></tr>\n<tr><td class=c><code>red_haired_man</code></td><td class=e>👨‍🦰</td></tr>\n<tr><td class=c><code>curly_haired_man</code></td><td class=e>👨‍🦱</td></tr>\n<tr><td class=c><code>white_haired_man</code></td><td class=e>👨‍🦳</td></tr>\n<tr><td class=c><code>bald_man</code></td><td class=e>👨‍🦲</td></tr>\n<tr><td class=c><code>woman</code></td><td class=e>👩</td></tr>\n<tr><td class=c><code>red_haired_woman</code></td><td class=e>👩‍🦰</td></tr>\n<tr><td class=c><code>person_red_hair</code></td><td class=e>🧑‍🦰</td></tr>\n<tr><td class=c><code>curly_haired_woman</code></td><td class=e>👩‍🦱</td></tr>\n<tr><td class=c><code>person_curly_hair</code></td><td class=e>🧑‍🦱</td></tr>\n<tr><td class=c><code>white_haired_woman</code></td><td class=e>👩‍🦳</td></tr>\n<tr><td class=c><code>person_white_hair</code></td><td class=e>🧑‍🦳</td></tr>\n<tr><td class=c><code>bald_woman</code></td><td class=e>👩‍🦲</td></tr>\n<tr><td class=c><code>person_bald</code></td><td class=e>🧑‍🦲</td></tr>\n<tr><td class=c><code>blond_haired_woman</code></td><td class=e>👱‍♀️</td></tr>\n<tr><td class=c><code>blond_haired_man</code></td><td class=e>👱‍♂️</td></tr>\n<tr><td class=c><code>older_adult</code></td><td class=e>🧓</td></tr>\n<tr><td class=c><code>older_man</code></td><td class=e>👴</td></tr>\n<tr><td class=c><code>older_woman</code></td><td class=e>👵</td></tr>\n<tr><td class=c><code>frowning_person</code></td><td class=e>🙍</td></tr>\n<tr><td class=c><code>frowning_man</code></td><td class=e>🙍‍♂️</td></tr>\n<tr><td class=c><code>frowning_woman</code></td><td class=e>🙍‍♀️</td></tr>\n<tr><td class=c><code>pouting_face</code></td><td class=e>🙎</td></tr>\n<tr><td class=c><code>pouting_man</code></td><td class=e>🙎‍♂️</td></tr>\n<tr><td class=c><code>pouting_woman</code></td><td class=e>🙎‍♀️</td></tr>\n<tr><td class=c><code>no_good</code></td><td class=e>🙅</td></tr>\n<tr><td class=c><code>no_good_man</code></td><td class=e>🙅‍♂️</td></tr>\n<tr><td class=c><code>no_good_woman</code></td><td class=e>🙅‍♀️</td></tr>\n<tr><td class=c><code>ok_person</code></td><td class=e>🙆</td></tr>\n<tr><td class=c><code>ok_man</code></td><td class=e>🙆‍♂️</td></tr>\n<tr><td class=c><code>ok_woman</code></td><td class=e>🙆‍♀️</td></tr>\n<tr><td class=c><code>tipping_hand_person</code></td><td class=e>💁</td></tr>\n<tr><td class=c><code>tipping_hand_man</code></td><td class=e>💁‍♂️</td></tr>\n<tr><td class=c><code>tipping_hand_woman</code></td><td class=e>💁‍♀️</td></tr>\n<tr><td class=c><code>raising_hand</code></td><td class=e>🙋</td></tr>\n<tr><td class=c><code>raising_hand_man</code></td><td class=e>🙋‍♂️</td></tr>\n<tr><td class=c><code>raising_hand_woman</code></td><td class=e>🙋‍♀️</td></tr>\n<tr><td class=c><code>deaf_person</code></td><td class=e>🧏</td></tr>\n<tr><td class=c><code>deaf_man</code></td><td class=e>🧏‍♂️</td></tr>\n<tr><td class=c><code>deaf_woman</code></td><td class=e>🧏‍♀️</td></tr>\n<tr><td class=c><code>bow</code></td><td class=e>🙇</td></tr>\n<tr><td class=c><code>bowing_man</code></td><td class=e>🙇‍♂️</td></tr>\n<tr><td class=c><code>bowing_woman</code></td><td class=e>🙇‍♀️</td></tr>\n<tr><td class=c><code>facepalm</code></td><td class=e>🤦</td></tr>\n<tr><td class=c><code>man_facepalming</code></td><td class=e>🤦‍♂️</td></tr>\n<tr><td class=c><code>woman_facepalming</code></td><td class=e>🤦‍♀️</td></tr>\n<tr><td class=c><code>shrug</code></td><td class=e>🤷</td></tr>\n<tr><td class=c><code>man_shrugging</code></td><td class=e>🤷‍♂️</td></tr>\n<tr><td class=c><code>woman_shrugging</code></td><td class=e>🤷‍♀️</td></tr>\n<tr><td class=c><code>health_worker</code></td><td class=e>🧑‍⚕️</td></tr>\n<tr><td class=c><code>man_health_worker</code></td><td class=e>👨‍⚕️</td></tr>\n<tr><td class=c><code>woman_health_worker</code></td><td class=e>👩‍⚕️</td></tr>\n<tr><td class=c><code>student</code></td><td class=e>🧑‍🎓</td></tr>\n<tr><td class=c><code>man_student</code></td><td class=e>👨‍🎓</td></tr>\n<tr><td class=c><code>woman_student</code></td><td class=e>👩‍🎓</td></tr>\n<tr><td class=c><code>teacher</code></td><td class=e>🧑‍🏫</td></tr>\n<tr><td class=c><code>man_teacher</code></td><td class=e>👨‍🏫</td></tr>\n<tr><td class=c><code>woman_teacher</code></td><td class=e>👩‍🏫</td></tr>\n<tr><td class=c><code>judge</code></td><td class=e>🧑‍⚖️</td></tr>\n<tr><td class=c><code>man_judge</code></td><td class=e>👨‍⚖️</td></tr>\n<tr><td class=c><code>woman_judge</code></td><td class=e>👩‍⚖️</td></tr>\n<tr><td class=c><code>farmer</code></td><td class=e>🧑‍🌾</td></tr>\n<tr><td class=c><code>man_farmer</code></td><td class=e>👨‍🌾</td></tr>\n<tr><td class=c><code>woman_farmer</code></td><td class=e>👩‍🌾</td></tr>\n<tr><td class=c><code>cook</code></td><td class=e>🧑‍🍳</td></tr>\n<tr><td class=c><code>man_cook</code></td><td class=e>👨‍🍳</td></tr>\n<tr><td class=c><code>woman_cook</code></td><td class=e>👩‍🍳</td></tr>\n<tr><td class=c><code>mechanic</code></td><td class=e>🧑‍🔧</td></tr>\n<tr><td class=c><code>man_mechanic</code></td><td class=e>👨‍🔧</td></tr>\n<tr><td class=c><code>woman_mechanic</code></td><td class=e>👩‍🔧</td></tr>\n<tr><td class=c><code>factory_worker</code></td><td class=e>🧑‍🏭</td></tr>\n<tr><td class=c><code>man_factory_worker</code></td><td class=e>👨‍🏭</td></tr>\n<tr><td class=c><code>woman_factory_worker</code></td><td class=e>👩‍🏭</td></tr>\n<tr><td class=c><code>office_worker</code></td><td class=e>🧑‍💼</td></tr>\n<tr><td class=c><code>man_office_worker</code></td><td class=e>👨‍💼</td></tr>\n<tr><td class=c><code>woman_office_worker</code></td><td class=e>👩‍💼</td></tr>\n<tr><td class=c><code>scientist</code></td><td class=e>🧑‍🔬</td></tr>\n<tr><td class=c><code>man_scientist</code></td><td class=e>👨‍🔬</td></tr>\n<tr><td class=c><code>woman_scientist</code></td><td class=e>👩‍🔬</td></tr>\n<tr><td class=c><code>technologist</code></td><td class=e>🧑‍💻</td></tr>\n<tr><td class=c><code>man_technologist</code></td><td class=e>👨‍💻</td></tr>\n<tr><td class=c><code>woman_technologist</code></td><td class=e>👩‍💻</td></tr>\n<tr><td class=c><code>singer</code></td><td class=e>🧑‍🎤</td></tr>\n<tr><td class=c><code>man_singer</code></td><td class=e>👨‍🎤</td></tr>\n<tr><td class=c><code>woman_singer</code></td><td class=e>👩‍🎤</td></tr>\n<tr><td class=c><code>artist</code></td><td class=e>🧑‍🎨</td></tr>\n<tr><td class=c><code>man_artist</code></td><td class=e>👨‍🎨</td></tr>\n<tr><td class=c><code>woman_artist</code></td><td class=e>👩‍🎨</td></tr>\n<tr><td class=c><code>pilot</code></td><td class=e>🧑‍✈️</td></tr>\n<tr><td class=c><code>man_pilot</code></td><td class=e>👨‍✈️</td></tr>\n<tr><td class=c><code>woman_pilot</code></td><td class=e>👩‍✈️</td></tr>\n<tr><td class=c><code>astronaut</code></td><td class=e>🧑‍🚀</td></tr>\n<tr><td class=c><code>man_astronaut</code></td><td class=e>👨‍🚀</td></tr>\n<tr><td class=c><code>woman_astronaut</code></td><td class=e>👩‍🚀</td></tr>\n<tr><td class=c><code>firefighter</code></td><td class=e>🧑‍🚒</td></tr>\n<tr><td class=c><code>man_firefighter</code></td><td class=e>👨‍🚒</td></tr>\n<tr><td class=c><code>woman_firefighter</code></td><td class=e>👩‍🚒</td></tr>\n<tr><td class=c><code>police_officer</code></td><td class=e>👮</td></tr>\n<tr><td class=c><code>policeman</code></td><td class=e>👮‍♂️</td></tr>\n<tr><td class=c><code>policewoman</code></td><td class=e>👮‍♀️</td></tr>\n<tr><td class=c><code>detective</code></td><td class=e>🕵️</td></tr>\n<tr><td class=c><code>male_detective</code></td><td class=e>🕵️‍♂️</td></tr>\n<tr><td class=c><code>female_detective</code></td><td class=e>🕵️‍♀️</td></tr>\n<tr><td class=c><code>guard</code></td><td class=e>💂</td></tr>\n<tr><td class=c><code>guardsman</code></td><td class=e>💂‍♂️</td></tr>\n<tr><td class=c><code>guardswoman</code></td><td class=e>💂‍♀️</td></tr>\n<tr><td class=c><code>ninja</code></td><td class=e>🥷</td></tr>\n<tr><td class=c><code>construction_worker</code></td><td class=e>👷</td></tr>\n<tr><td class=c><code>construction_worker_man</code></td><td class=e>👷‍♂️</td></tr>\n<tr><td class=c><code>construction_worker_woman</code></td><td class=e>👷‍♀️</td></tr>\n<tr><td class=c><code>prince</code></td><td class=e>🤴</td></tr>\n<tr><td class=c><code>princess</code></td><td class=e>👸</td></tr>\n<tr><td class=c><code>person_with_turban</code></td><td class=e>👳</td></tr>\n<tr><td class=c><code>man_with_turban</code></td><td class=e>👳‍♂️</td></tr>\n<tr><td class=c><code>woman_with_turban</code></td><td class=e>👳‍♀️</td></tr>\n<tr><td class=c><code>man_with_gua_pi_mao</code></td><td class=e>👲</td></tr>\n<tr><td class=c><code>woman_with_headscarf</code></td><td class=e>🧕</td></tr>\n<tr><td class=c><code>person_in_tuxedo</code></td><td class=e>🤵</td></tr>\n<tr><td class=c><code>man_in_tuxedo</code></td><td class=e>🤵‍♂️</td></tr>\n<tr><td class=c><code>woman_in_tuxedo</code></td><td class=e>🤵‍♀️</td></tr>\n<tr><td class=c><code>person_with_veil</code></td><td class=e>👰</td></tr>\n<tr><td class=c><code>man_with_veil</code></td><td class=e>👰‍♂️</td></tr>\n<tr><td class=c><code>woman_with_veil</code></td><td class=e>👰‍♀️</td></tr>\n<tr><td class=c><code>pregnant_woman</code></td><td class=e>🤰</td></tr>\n<tr><td class=c><code>breast_feeding</code></td><td class=e>🤱</td></tr>\n<tr><td class=c><code>woman_feeding_baby</code></td><td class=e>👩‍🍼</td></tr>\n<tr><td class=c><code>man_feeding_baby</code></td><td class=e>👨‍🍼</td></tr>\n<tr><td class=c><code>person_feeding_baby</code></td><td class=e>🧑‍🍼</td></tr>\n<tr><td class=c><code>angel</code></td><td class=e>👼</td></tr>\n<tr><td class=c><code>santa</code></td><td class=e>🎅</td></tr>\n<tr><td class=c><code>mrs_claus</code></td><td class=e>🤶</td></tr>\n<tr><td class=c><code>mx_claus</code></td><td class=e>🧑‍🎄</td></tr>\n<tr><td class=c><code>superhero</code></td><td class=e>🦸</td></tr>\n<tr><td class=c><code>superhero_man</code></td><td class=e>🦸‍♂️</td></tr>\n<tr><td class=c><code>superhero_woman</code></td><td class=e>🦸‍♀️</td></tr>\n<tr><td class=c><code>supervillain</code></td><td class=e>🦹</td></tr>\n<tr><td class=c><code>supervillain_man</code></td><td class=e>🦹‍♂️</td></tr>\n<tr><td class=c><code>supervillain_woman</code></td><td class=e>🦹‍♀️</td></tr>\n<tr><td class=c><code>mage</code></td><td class=e>🧙</td></tr>\n<tr><td class=c><code>mage_man</code></td><td class=e>🧙‍♂️</td></tr>\n<tr><td class=c><code>mage_woman</code></td><td class=e>🧙‍♀️</td></tr>\n<tr><td class=c><code>fairy</code></td><td class=e>🧚</td></tr>\n<tr><td class=c><code>fairy_man</code></td><td class=e>🧚‍♂️</td></tr>\n<tr><td class=c><code>fairy_woman</code></td><td class=e>🧚‍♀️</td></tr>\n<tr><td class=c><code>vampire</code></td><td class=e>🧛</td></tr>\n<tr><td class=c><code>vampire_man</code></td><td class=e>🧛‍♂️</td></tr>\n<tr><td class=c><code>vampire_woman</code></td><td class=e>🧛‍♀️</td></tr>\n<tr><td class=c><code>merperson</code></td><td class=e>🧜</td></tr>\n<tr><td class=c><code>merman</code></td><td class=e>🧜‍♂️</td></tr>\n<tr><td class=c><code>mermaid</code></td><td class=e>🧜‍♀️</td></tr>\n<tr><td class=c><code>elf</code></td><td class=e>🧝</td></tr>\n<tr><td class=c><code>elf_man</code></td><td class=e>🧝‍♂️</td></tr>\n<tr><td class=c><code>elf_woman</code></td><td class=e>🧝‍♀️</td></tr>\n<tr><td class=c><code>genie</code></td><td class=e>🧞</td></tr>\n<tr><td class=c><code>genie_man</code></td><td class=e>🧞‍♂️</td></tr>\n<tr><td class=c><code>genie_woman</code></td><td class=e>🧞‍♀️</td></tr>\n<tr><td class=c><code>zombie</code></td><td class=e>🧟</td></tr>\n<tr><td class=c><code>zombie_man</code></td><td class=e>🧟‍♂️</td></tr>\n<tr><td class=c><code>zombie_woman</code></td><td class=e>🧟‍♀️</td></tr>\n<tr><td class=c><code>massage</code></td><td class=e>💆</td></tr>\n<tr><td class=c><code>massage_man</code></td><td class=e>💆‍♂️</td></tr>\n<tr><td class=c><code>massage_woman</code></td><td class=e>💆‍♀️</td></tr>\n<tr><td class=c><code>haircut</code></td><td class=e>💇</td></tr>\n<tr><td class=c><code>haircut_man</code></td><td class=e>💇‍♂️</td></tr>\n<tr><td class=c><code>haircut_woman</code></td><td class=e>💇‍♀️</td></tr>\n<tr><td class=c><code>walking</code></td><td class=e>🚶</td></tr>\n<tr><td class=c><code>walking_man</code></td><td class=e>🚶‍♂️</td></tr>\n<tr><td class=c><code>walking_woman</code></td><td class=e>🚶‍♀️</td></tr>\n<tr><td class=c><code>standing_person</code></td><td class=e>🧍</td></tr>\n<tr><td class=c><code>standing_man</code></td><td class=e>🧍‍♂️</td></tr>\n<tr><td class=c><code>standing_woman</code></td><td class=e>🧍‍♀️</td></tr>\n<tr><td class=c><code>kneeling_person</code></td><td class=e>🧎</td></tr>\n<tr><td class=c><code>kneeling_man</code></td><td class=e>🧎‍♂️</td></tr>\n<tr><td class=c><code>kneeling_woman</code></td><td class=e>🧎‍♀️</td></tr>\n<tr><td class=c><code>person_with_probing_cane</code></td><td class=e>🧑‍🦯</td></tr>\n<tr><td class=c><code>man_with_probing_cane</code></td><td class=e>👨‍🦯</td></tr>\n<tr><td class=c><code>woman_with_probing_cane</code></td><td class=e>👩‍🦯</td></tr>\n<tr><td class=c><code>person_in_motorized_wheelchair</code></td><td class=e>🧑‍🦼</td></tr>\n<tr><td class=c><code>man_in_motorized_wheelchair</code></td><td class=e>👨‍🦼</td></tr>\n<tr><td class=c><code>woman_in_motorized_wheelchair</code></td><td class=e>👩‍🦼</td></tr>\n<tr><td class=c><code>person_in_manual_wheelchair</code></td><td class=e>🧑‍🦽</td></tr>\n<tr><td class=c><code>man_in_manual_wheelchair</code></td><td class=e>👨‍🦽</td></tr>\n<tr><td class=c><code>woman_in_manual_wheelchair</code></td><td class=e>👩‍🦽</td></tr>\n<tr><td class=c><code>runner</code></td><td class=e>🏃</td></tr>\n<tr><td class=c><code>running_man</code></td><td class=e>🏃‍♂️</td></tr>\n<tr><td class=c><code>running_woman</code></td><td class=e>🏃‍♀️</td></tr>\n<tr><td class=c><code>woman_dancing</code></td><td class=e>💃</td></tr>\n<tr><td class=c><code>man_dancing</code></td><td class=e>🕺</td></tr>\n<tr><td class=c><code>business_suit_levitating</code></td><td class=e>🕴️</td></tr>\n<tr><td class=c><code>dancers</code></td><td class=e>👯</td></tr>\n<tr><td class=c><code>dancing_men</code></td><td class=e>👯‍♂️</td></tr>\n<tr><td class=c><code>dancing_women</code></td><td class=e>👯‍♀️</td></tr>\n<tr><td class=c><code>sauna_person</code></td><td class=e>🧖</td></tr>\n<tr><td class=c><code>sauna_man</code></td><td class=e>🧖‍♂️</td></tr>\n<tr><td class=c><code>sauna_woman</code></td><td class=e>🧖‍♀️</td></tr>\n<tr><td class=c><code>climbing</code></td><td class=e>🧗</td></tr>\n<tr><td class=c><code>climbing_man</code></td><td class=e>🧗‍♂️</td></tr>\n<tr><td class=c><code>climbing_woman</code></td><td class=e>🧗‍♀️</td></tr>\n<tr><td class=c><code>person_fencing</code></td><td class=e>🤺</td></tr>\n<tr><td class=c><code>horse_racing</code></td><td class=e>🏇</td></tr>\n<tr><td class=c><code>skier</code></td><td class=e>⛷️</td></tr>\n<tr><td class=c><code>snowboarder</code></td><td class=e>🏂</td></tr>\n<tr><td class=c><code>golfing</code></td><td class=e>🏌️</td></tr>\n<tr><td class=c><code>golfing_man</code></td><td class=e>🏌️‍♂️</td></tr>\n<tr><td class=c><code>golfing_woman</code></td><td class=e>🏌️‍♀️</td></tr>\n<tr><td class=c><code>surfer</code></td><td class=e>🏄</td></tr>\n<tr><td class=c><code>surfing_man</code></td><td class=e>🏄‍♂️</td></tr>\n<tr><td class=c><code>surfing_woman</code></td><td class=e>🏄‍♀️</td></tr>\n<tr><td class=c><code>rowboat</code></td><td class=e>🚣</td></tr>\n<tr><td class=c><code>rowing_man</code></td><td class=e>🚣‍♂️</td></tr>\n<tr><td class=c><code>rowing_woman</code></td><td class=e>🚣‍♀️</td></tr>\n<tr><td class=c><code>swimmer</code></td><td class=e>🏊</td></tr>\n<tr><td class=c><code>swimming_man</code></td><td class=e>🏊‍♂️</td></tr>\n<tr><td class=c><code>swimming_woman</code></td><td class=e>🏊‍♀️</td></tr>\n<tr><td class=c><code>bouncing_ball_person</code></td><td class=e>⛹️</td></tr>\n<tr><td class=c><code>bouncing_ball_man</code></td><td class=e>⛹️‍♂️</td></tr>\n<tr><td class=c><code>bouncing_ball_woman</code></td><td class=e>⛹️‍♀️</td></tr>\n<tr><td class=c><code>weight_lifting</code></td><td class=e>🏋️</td></tr>\n<tr><td class=c><code>weight_lifting_man</code></td><td class=e>🏋️‍♂️</td></tr>\n<tr><td class=c><code>weight_lifting_woman</code></td><td class=e>🏋️‍♀️</td></tr>\n<tr><td class=c><code>bicyclist</code></td><td class=e>🚴</td></tr>\n<tr><td class=c><code>biking_man</code></td><td class=e>🚴‍♂️</td></tr>\n<tr><td class=c><code>biking_woman</code></td><td class=e>🚴‍♀️</td></tr>\n<tr><td class=c><code>mountain_bicyclist</code></td><td class=e>🚵</td></tr>\n<tr><td class=c><code>mountain_biking_man</code></td><td class=e>🚵‍♂️</td></tr>\n<tr><td class=c><code>mountain_biking_woman</code></td><td class=e>🚵‍♀️</td></tr>\n<tr><td class=c><code>cartwheeling</code></td><td class=e>🤸</td></tr>\n<tr><td class=c><code>man_cartwheeling</code></td><td class=e>🤸‍♂️</td></tr>\n<tr><td class=c><code>woman_cartwheeling</code></td><td class=e>🤸‍♀️</td></tr>\n<tr><td class=c><code>wrestling</code></td><td class=e>🤼</td></tr>\n<tr><td class=c><code>men_wrestling</code></td><td class=e>🤼‍♂️</td></tr>\n<tr><td class=c><code>women_wrestling</code></td><td class=e>🤼‍♀️</td></tr>\n<tr><td class=c><code>water_polo</code></td><td class=e>🤽</td></tr>\n<tr><td class=c><code>man_playing_water_polo</code></td><td class=e>🤽‍♂️</td></tr>\n<tr><td class=c><code>woman_playing_water_polo</code></td><td class=e>🤽‍♀️</td></tr>\n<tr><td class=c><code>handball_person</code></td><td class=e>🤾</td></tr>\n<tr><td class=c><code>man_playing_handball</code></td><td class=e>🤾‍♂️</td></tr>\n<tr><td class=c><code>woman_playing_handball</code></td><td class=e>🤾‍♀️</td></tr>\n<tr><td class=c><code>juggling_person</code></td><td class=e>🤹</td></tr>\n<tr><td class=c><code>man_juggling</code></td><td class=e>🤹‍♂️</td></tr>\n<tr><td class=c><code>woman_juggling</code></td><td class=e>🤹‍♀️</td></tr>\n<tr><td class=c><code>lotus_position</code></td><td class=e>🧘</td></tr>\n<tr><td class=c><code>lotus_position_man</code></td><td class=e>🧘‍♂️</td></tr>\n<tr><td class=c><code>lotus_position_woman</code></td><td class=e>🧘‍♀️</td></tr>\n<tr><td class=c><code>bath</code></td><td class=e>🛀</td></tr>\n<tr><td class=c><code>sleeping_bed</code></td><td class=e>🛌</td></tr>\n<tr><td class=c><code>people_holding_hands</code></td><td class=e>🧑‍🤝‍🧑</td></tr>\n<tr><td class=c><code>two_women_holding_hands</code></td><td class=e>👭</td></tr>\n<tr><td class=c><code>couple</code></td><td class=e>👫</td></tr>\n<tr><td class=c><code>two_men_holding_hands</code></td><td class=e>👬</td></tr>\n<tr><td class=c><code>couplekiss</code></td><td class=e>💏</td></tr>\n<tr><td class=c><code>couplekiss_man_woman</code></td><td class=e>👩‍❤️‍💋‍👨</td></tr>\n<tr><td class=c><code>couplekiss_man_man</code></td><td class=e>👨‍❤️‍💋‍👨</td></tr>\n<tr><td class=c><code>couplekiss_woman_woman</code></td><td class=e>👩‍❤️‍💋‍👩</td></tr>\n<tr><td class=c><code>couple_with_heart</code></td><td class=e>💑</td></tr>\n<tr><td class=c><code>couple_with_heart_woman_man</code></td><td class=e>👩‍❤️‍👨</td></tr>\n<tr><td class=c><code>couple_with_heart_man_man</code></td><td class=e>👨‍❤️‍👨</td></tr>\n<tr><td class=c><code>couple_with_heart_woman_woman</code></td><td class=e>👩‍❤️‍👩</td></tr>\n<tr><td class=c><code>family</code></td><td class=e>👪</td></tr>\n<tr><td class=c><code>family_man_woman_boy</code></td><td class=e>👨‍👩‍👦</td></tr>\n<tr><td class=c><code>family_man_woman_girl</code></td><td class=e>👨‍👩‍👧</td></tr>\n<tr><td class=c><code>family_man_woman_girl_boy</code></td><td class=e>👨‍👩‍👧‍👦</td></tr>\n<tr><td class=c><code>family_man_woman_boy_boy</code></td><td class=e>👨‍👩‍👦‍👦</td></tr>\n<tr><td class=c><code>family_man_woman_girl_girl</code></td><td class=e>👨‍👩‍👧‍👧</td></tr>\n<tr><td class=c><code>family_man_man_boy</code></td><td class=e>👨‍👨‍👦</td></tr>\n<tr><td class=c><code>family_man_man_girl</code></td><td class=e>👨‍👨‍👧</td></tr>\n<tr><td class=c><code>family_man_man_girl_boy</code></td><td class=e>👨‍👨‍👧‍👦</td></tr>\n<tr><td class=c><code>family_man_man_boy_boy</code></td><td class=e>👨‍👨‍👦‍👦</td></tr>\n<tr><td class=c><code>family_man_man_girl_girl</code></td><td class=e>👨‍👨‍👧‍👧</td></tr>\n<tr><td class=c><code>family_woman_woman_boy</code></td><td class=e>👩‍👩‍👦</td></tr>\n<tr><td class=c><code>family_woman_woman_girl</code></td><td class=e>👩‍👩‍👧</td></tr>\n<tr><td class=c><code>family_woman_woman_girl_boy</code></td><td class=e>👩‍👩‍👧‍👦</td></tr>\n<tr><td class=c><code>family_woman_woman_boy_boy</code></td><td class=e>👩‍👩‍👦‍👦</td></tr>\n<tr><td class=c><code>family_woman_woman_girl_girl</code></td><td class=e>👩‍👩‍👧‍👧</td></tr>\n<tr><td class=c><code>family_man_boy</code></td><td class=e>👨‍👦</td></tr>\n<tr><td class=c><code>family_man_boy_boy</code></td><td class=e>👨‍👦‍👦</td></tr>\n<tr><td class=c><code>family_man_girl</code></td><td class=e>👨‍👧</td></tr>\n<tr><td class=c><code>family_man_girl_boy</code></td><td class=e>👨‍👧‍👦</td></tr>\n<tr><td class=c><code>family_man_girl_girl</code></td><td class=e>👨‍👧‍👧</td></tr>\n<tr><td class=c><code>family_woman_boy</code></td><td class=e>👩‍👦</td></tr>\n<tr><td class=c><code>family_woman_boy_boy</code></td><td class=e>👩‍👦‍👦</td></tr>\n<tr><td class=c><code>family_woman_girl</code></td><td class=e>👩‍👧</td></tr>\n<tr><td class=c><code>family_woman_girl_boy</code></td><td class=e>👩‍👧‍👦</td></tr>\n<tr><td class=c><code>family_woman_girl_girl</code></td><td class=e>👩‍👧‍👧</td></tr>\n<tr><td class=c><code>speaking_head</code></td><td class=e>🗣️</td></tr>\n<tr><td class=c><code>bust_in_silhouette</code></td><td class=e>👤</td></tr>\n<tr><td class=c><code>busts_in_silhouette</code></td><td class=e>👥</td></tr>\n<tr><td class=c><code>people_hugging</code></td><td class=e>🫂</td></tr>\n<tr><td class=c><code>footprints</code></td><td class=e>👣</td></tr>\n<tr><td class=c><code>monkey_face</code></td><td class=e>🐵</td></tr>\n<tr><td class=c><code>monkey</code></td><td class=e>🐒</td></tr>\n<tr><td class=c><code>gorilla</code></td><td class=e>🦍</td></tr>\n<tr><td class=c><code>orangutan</code></td><td class=e>🦧</td></tr>\n<tr><td class=c><code>dog</code></td><td class=e>🐶</td></tr>\n<tr><td class=c><code>dog2</code></td><td class=e>🐕</td></tr>\n<tr><td class=c><code>guide_dog</code></td><td class=e>🦮</td></tr>\n<tr><td class=c><code>service_dog</code></td><td class=e>🐕‍🦺</td></tr>\n<tr><td class=c><code>poodle</code></td><td class=e>🐩</td></tr>\n<tr><td class=c><code>wolf</code></td><td class=e>🐺</td></tr>\n<tr><td class=c><code>fox_face</code></td><td class=e>🦊</td></tr>\n<tr><td class=c><code>raccoon</code></td><td class=e>🦝</td></tr>\n<tr><td class=c><code>cat</code></td><td class=e>🐱</td></tr>\n<tr><td class=c><code>cat2</code></td><td class=e>🐈</td></tr>\n<tr><td class=c><code>black_cat</code></td><td class=e>🐈‍⬛</td></tr>\n<tr><td class=c><code>lion</code></td><td class=e>🦁</td></tr>\n<tr><td class=c><code>tiger</code></td><td class=e>🐯</td></tr>\n<tr><td class=c><code>tiger2</code></td><td class=e>🐅</td></tr>\n<tr><td class=c><code>leopard</code></td><td class=e>🐆</td></tr>\n<tr><td class=c><code>horse</code></td><td class=e>🐴</td></tr>\n<tr><td class=c><code>racehorse</code></td><td class=e>🐎</td></tr>\n<tr><td class=c><code>unicorn</code></td><td class=e>🦄</td></tr>\n<tr><td class=c><code>zebra</code></td><td class=e>🦓</td></tr>\n<tr><td class=c><code>deer</code></td><td class=e>🦌</td></tr>\n<tr><td class=c><code>bison</code></td><td class=e>🦬</td></tr>\n<tr><td class=c><code>cow</code></td><td class=e>🐮</td></tr>\n<tr><td class=c><code>ox</code></td><td class=e>🐂</td></tr>\n<tr><td class=c><code>water_buffalo</code></td><td class=e>🐃</td></tr>\n<tr><td class=c><code>cow2</code></td><td class=e>🐄</td></tr>\n<tr><td class=c><code>pig</code></td><td class=e>🐷</td></tr>\n<tr><td class=c><code>pig2</code></td><td class=e>🐖</td></tr>\n<tr><td class=c><code>boar</code></td><td class=e>🐗</td></tr>\n<tr><td class=c><code>pig_nose</code></td><td class=e>🐽</td></tr>\n<tr><td class=c><code>ram</code></td><td class=e>🐏</td></tr>\n<tr><td class=c><code>sheep</code></td><td class=e>🐑</td></tr>\n<tr><td class=c><code>goat</code></td><td class=e>🐐</td></tr>\n<tr><td class=c><code>dromedary_camel</code></td><td class=e>🐪</td></tr>\n<tr><td class=c><code>camel</code></td><td class=e>🐫</td></tr>\n<tr><td class=c><code>llama</code></td><td class=e>🦙</td></tr>\n<tr><td class=c><code>giraffe</code></td><td class=e>🦒</td></tr>\n<tr><td class=c><code>elephant</code></td><td class=e>🐘</td></tr>\n<tr><td class=c><code>mammoth</code></td><td class=e>🦣</td></tr>\n<tr><td class=c><code>rhinoceros</code></td><td class=e>🦏</td></tr>\n<tr><td class=c><code>hippopotamus</code></td><td class=e>🦛</td></tr>\n<tr><td class=c><code>mouse</code></td><td class=e>🐭</td></tr>\n<tr><td class=c><code>mouse2</code></td><td class=e>🐁</td></tr>\n<tr><td class=c><code>rat</code></td><td class=e>🐀</td></tr>\n<tr><td class=c><code>hamster</code></td><td class=e>🐹</td></tr>\n<tr><td class=c><code>rabbit</code></td><td class=e>🐰</td></tr>\n<tr><td class=c><code>rabbit2</code></td><td class=e>🐇</td></tr>\n<tr><td class=c><code>chipmunk</code></td><td class=e>🐿️</td></tr>\n<tr><td class=c><code>beaver</code></td><td class=e>🦫</td></tr>\n<tr><td class=c><code>hedgehog</code></td><td class=e>🦔</td></tr>\n<tr><td class=c><code>bat</code></td><td class=e>🦇</td></tr>\n<tr><td class=c><code>bear</code></td><td class=e>🐻</td></tr>\n<tr><td class=c><code>polar_bear</code></td><td class=e>🐻‍❄️</td></tr>\n<tr><td class=c><code>koala</code></td><td class=e>🐨</td></tr>\n<tr><td class=c><code>panda_face</code></td><td class=e>🐼</td></tr>\n<tr><td class=c><code>sloth</code></td><td class=e>🦥</td></tr>\n<tr><td class=c><code>otter</code></td><td class=e>🦦</td></tr>\n<tr><td class=c><code>skunk</code></td><td class=e>🦨</td></tr>\n<tr><td class=c><code>kangaroo</code></td><td class=e>🦘</td></tr>\n<tr><td class=c><code>badger</code></td><td class=e>🦡</td></tr>\n<tr><td class=c><code>feet</code></td><td class=e>🐾</td></tr>\n<tr><td class=c><code>turkey</code></td><td class=e>🦃</td></tr>\n<tr><td class=c><code>chicken</code></td><td class=e>🐔</td></tr>\n<tr><td class=c><code>rooster</code></td><td class=e>🐓</td></tr>\n<tr><td class=c><code>hatching_chick</code></td><td class=e>🐣</td></tr>\n<tr><td class=c><code>baby_chick</code></td><td class=e>🐤</td></tr>\n<tr><td class=c><code>hatched_chick</code></td><td class=e>🐥</td></tr>\n<tr><td class=c><code>bird</code></td><td class=e>🐦</td></tr>\n<tr><td class=c><code>penguin</code></td><td class=e>🐧</td></tr>\n<tr><td class=c><code>dove</code></td><td class=e>🕊️</td></tr>\n<tr><td class=c><code>eagle</code></td><td class=e>🦅</td></tr>\n<tr><td class=c><code>duck</code></td><td class=e>🦆</td></tr>\n<tr><td class=c><code>swan</code></td><td class=e>🦢</td></tr>\n<tr><td class=c><code>owl</code></td><td class=e>🦉</td></tr>\n<tr><td class=c><code>dodo</code></td><td class=e>🦤</td></tr>\n<tr><td class=c><code>feather</code></td><td class=e>🪶</td></tr>\n<tr><td class=c><code>flamingo</code></td><td class=e>🦩</td></tr>\n<tr><td class=c><code>peacock</code></td><td class=e>🦚</td></tr>\n<tr><td class=c><code>parrot</code></td><td class=e>🦜</td></tr>\n<tr><td class=c><code>frog</code></td><td class=e>🐸</td></tr>\n<tr><td class=c><code>crocodile</code></td><td class=e>🐊</td></tr>\n<tr><td class=c><code>turtle</code></td><td class=e>🐢</td></tr>\n<tr><td class=c><code>lizard</code></td><td class=e>🦎</td></tr>\n<tr><td class=c><code>snake</code></td><td class=e>🐍</td></tr>\n<tr><td class=c><code>dragon_face</code></td><td class=e>🐲</td></tr>\n<tr><td class=c><code>dragon</code></td><td class=e>🐉</td></tr>\n<tr><td class=c><code>sauropod</code></td><td class=e>🦕</td></tr>\n<tr><td class=c><code>t-rex</code></td><td class=e>🦖</td></tr>\n<tr><td class=c><code>whale</code></td><td class=e>🐳</td></tr>\n<tr><td class=c><code>whale2</code></td><td class=e>🐋</td></tr>\n<tr><td class=c><code>dolphin</code></td><td class=e>🐬</td></tr>\n<tr><td class=c><code>seal</code></td><td class=e>🦭</td></tr>\n<tr><td class=c><code>fish</code></td><td class=e>🐟</td></tr>\n<tr><td class=c><code>tropical_fish</code></td><td class=e>🐠</td></tr>\n<tr><td class=c><code>blowfish</code></td><td class=e>🐡</td></tr>\n<tr><td class=c><code>shark</code></td><td class=e>🦈</td></tr>\n<tr><td class=c><code>octopus</code></td><td class=e>🐙</td></tr>\n</tbody></table></td>\n<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>\n<tr><td class=c><code>octopus</code></td><td class=e>🐙</td></tr>\n<tr><td class=c><code>shell</code></td><td class=e>🐚</td></tr>\n<tr><td class=c><code>snail</code></td><td class=e>🐌</td></tr>\n<tr><td class=c><code>butterfly</code></td><td class=e>🦋</td></tr>\n<tr><td class=c><code>bug</code></td><td class=e>🐛</td></tr>\n<tr><td class=c><code>ant</code></td><td class=e>🐜</td></tr>\n<tr><td class=c><code>bee</code></td><td class=e>🐝</td></tr>\n<tr><td class=c><code>beetle</code></td><td class=e>🪲</td></tr>\n<tr><td class=c><code>lady_beetle</code></td><td class=e>🐞</td></tr>\n<tr><td class=c><code>cricket</code></td><td class=e>🦗</td></tr>\n<tr><td class=c><code>cockroach</code></td><td class=e>🪳</td></tr>\n<tr><td class=c><code>spider</code></td><td class=e>🕷️</td></tr>\n<tr><td class=c><code>spider_web</code></td><td class=e>🕸️</td></tr>\n<tr><td class=c><code>scorpion</code></td><td class=e>🦂</td></tr>\n<tr><td class=c><code>mosquito</code></td><td class=e>🦟</td></tr>\n<tr><td class=c><code>fly</code></td><td class=e>🪰</td></tr>\n<tr><td class=c><code>worm</code></td><td class=e>🪱</td></tr>\n<tr><td class=c><code>microbe</code></td><td class=e>🦠</td></tr>\n<tr><td class=c><code>bouquet</code></td><td class=e>💐</td></tr>\n<tr><td class=c><code>cherry_blossom</code></td><td class=e>🌸</td></tr>\n<tr><td class=c><code>white_flower</code></td><td class=e>💮</td></tr>\n<tr><td class=c><code>rosette</code></td><td class=e>🏵️</td></tr>\n<tr><td class=c><code>rose</code></td><td class=e>🌹</td></tr>\n<tr><td class=c><code>wilted_flower</code></td><td class=e>🥀</td></tr>\n<tr><td class=c><code>hibiscus</code></td><td class=e>🌺</td></tr>\n<tr><td class=c><code>sunflower</code></td><td class=e>🌻</td></tr>\n<tr><td class=c><code>blossom</code></td><td class=e>🌼</td></tr>\n<tr><td class=c><code>tulip</code></td><td class=e>🌷</td></tr>\n<tr><td class=c><code>seedling</code></td><td class=e>🌱</td></tr>\n<tr><td class=c><code>potted_plant</code></td><td class=e>🪴</td></tr>\n<tr><td class=c><code>evergreen_tree</code></td><td class=e>🌲</td></tr>\n<tr><td class=c><code>deciduous_tree</code></td><td class=e>🌳</td></tr>\n<tr><td class=c><code>palm_tree</code></td><td class=e>🌴</td></tr>\n<tr><td class=c><code>cactus</code></td><td class=e>🌵</td></tr>\n<tr><td class=c><code>ear_of_rice</code></td><td class=e>🌾</td></tr>\n<tr><td class=c><code>herb</code></td><td class=e>🌿</td></tr>\n<tr><td class=c><code>shamrock</code></td><td class=e>☘️</td></tr>\n<tr><td class=c><code>four_leaf_clover</code></td><td class=e>🍀</td></tr>\n<tr><td class=c><code>maple_leaf</code></td><td class=e>🍁</td></tr>\n<tr><td class=c><code>fallen_leaf</code></td><td class=e>🍂</td></tr>\n<tr><td class=c><code>leaves</code></td><td class=e>🍃</td></tr>\n<tr><td class=c><code>grapes</code></td><td class=e>🍇</td></tr>\n<tr><td class=c><code>melon</code></td><td class=e>🍈</td></tr>\n<tr><td class=c><code>watermelon</code></td><td class=e>🍉</td></tr>\n<tr><td class=c><code>tangerine</code></td><td class=e>🍊</td></tr>\n<tr><td class=c><code>lemon</code></td><td class=e>🍋</td></tr>\n<tr><td class=c><code>banana</code></td><td class=e>🍌</td></tr>\n<tr><td class=c><code>pineapple</code></td><td class=e>🍍</td></tr>\n<tr><td class=c><code>mango</code></td><td class=e>🥭</td></tr>\n<tr><td class=c><code>apple</code></td><td class=e>🍎</td></tr>\n<tr><td class=c><code>green_apple</code></td><td class=e>🍏</td></tr>\n<tr><td class=c><code>pear</code></td><td class=e>🍐</td></tr>\n<tr><td class=c><code>peach</code></td><td class=e>🍑</td></tr>\n<tr><td class=c><code>cherries</code></td><td class=e>🍒</td></tr>\n<tr><td class=c><code>strawberry</code></td><td class=e>🍓</td></tr>\n<tr><td class=c><code>blueberries</code></td><td class=e>🫐</td></tr>\n<tr><td class=c><code>kiwi_fruit</code></td><td class=e>🥝</td></tr>\n<tr><td class=c><code>tomato</code></td><td class=e>🍅</td></tr>\n<tr><td class=c><code>olive</code></td><td class=e>🫒</td></tr>\n<tr><td class=c><code>coconut</code></td><td class=e>🥥</td></tr>\n<tr><td class=c><code>avocado</code></td><td class=e>🥑</td></tr>\n<tr><td class=c><code>eggplant</code></td><td class=e>🍆</td></tr>\n<tr><td class=c><code>potato</code></td><td class=e>🥔</td></tr>\n<tr><td class=c><code>carrot</code></td><td class=e>🥕</td></tr>\n<tr><td class=c><code>corn</code></td><td class=e>🌽</td></tr>\n<tr><td class=c><code>hot_pepper</code></td><td class=e>🌶️</td></tr>\n<tr><td class=c><code>bell_pepper</code></td><td class=e>🫑</td></tr>\n<tr><td class=c><code>cucumber</code></td><td class=e>🥒</td></tr>\n<tr><td class=c><code>leafy_green</code></td><td class=e>🥬</td></tr>\n<tr><td class=c><code>broccoli</code></td><td class=e>🥦</td></tr>\n<tr><td class=c><code>garlic</code></td><td class=e>🧄</td></tr>\n<tr><td class=c><code>onion</code></td><td class=e>🧅</td></tr>\n<tr><td class=c><code>mushroom</code></td><td class=e>🍄</td></tr>\n<tr><td class=c><code>peanuts</code></td><td class=e>🥜</td></tr>\n<tr><td class=c><code>chestnut</code></td><td class=e>🌰</td></tr>\n<tr><td class=c><code>bread</code></td><td class=e>🍞</td></tr>\n<tr><td class=c><code>croissant</code></td><td class=e>🥐</td></tr>\n<tr><td class=c><code>baguette_bread</code></td><td class=e>🥖</td></tr>\n<tr><td class=c><code>flatbread</code></td><td class=e>🫓</td></tr>\n<tr><td class=c><code>pretzel</code></td><td class=e>🥨</td></tr>\n<tr><td class=c><code>bagel</code></td><td class=e>🥯</td></tr>\n<tr><td class=c><code>pancakes</code></td><td class=e>🥞</td></tr>\n<tr><td class=c><code>waffle</code></td><td class=e>🧇</td></tr>\n<tr><td class=c><code>cheese</code></td><td class=e>🧀</td></tr>\n<tr><td class=c><code>meat_on_bone</code></td><td class=e>🍖</td></tr>\n<tr><td class=c><code>poultry_leg</code></td><td class=e>🍗</td></tr>\n<tr><td class=c><code>cut_of_meat</code></td><td class=e>🥩</td></tr>\n<tr><td class=c><code>bacon</code></td><td class=e>🥓</td></tr>\n<tr><td class=c><code>hamburger</code></td><td class=e>🍔</td></tr>\n<tr><td class=c><code>fries</code></td><td class=e>🍟</td></tr>\n<tr><td class=c><code>pizza</code></td><td class=e>🍕</td></tr>\n<tr><td class=c><code>hotdog</code></td><td class=e>🌭</td></tr>\n<tr><td class=c><code>sandwich</code></td><td class=e>🥪</td></tr>\n<tr><td class=c><code>taco</code></td><td class=e>🌮</td></tr>\n<tr><td class=c><code>burrito</code></td><td class=e>🌯</td></tr>\n<tr><td class=c><code>tamale</code></td><td class=e>🫔</td></tr>\n<tr><td class=c><code>stuffed_flatbread</code></td><td class=e>🥙</td></tr>\n<tr><td class=c><code>falafel</code></td><td class=e>🧆</td></tr>\n<tr><td class=c><code>egg</code></td><td class=e>🥚</td></tr>\n<tr><td class=c><code>fried_egg</code></td><td class=e>🍳</td></tr>\n<tr><td class=c><code>shallow_pan_of_food</code></td><td class=e>🥘</td></tr>\n<tr><td class=c><code>stew</code></td><td class=e>🍲</td></tr>\n<tr><td class=c><code>fondue</code></td><td class=e>🫕</td></tr>\n<tr><td class=c><code>bowl_with_spoon</code></td><td class=e>🥣</td></tr>\n<tr><td class=c><code>green_salad</code></td><td class=e>🥗</td></tr>\n<tr><td class=c><code>popcorn</code></td><td class=e>🍿</td></tr>\n<tr><td class=c><code>butter</code></td><td class=e>🧈</td></tr>\n<tr><td class=c><code>salt</code></td><td class=e>🧂</td></tr>\n<tr><td class=c><code>canned_food</code></td><td class=e>🥫</td></tr>\n<tr><td class=c><code>bento</code></td><td class=e>🍱</td></tr>\n<tr><td class=c><code>rice_cracker</code></td><td class=e>🍘</td></tr>\n<tr><td class=c><code>rice_ball</code></td><td class=e>🍙</td></tr>\n<tr><td class=c><code>rice</code></td><td class=e>🍚</td></tr>\n<tr><td class=c><code>curry</code></td><td class=e>🍛</td></tr>\n<tr><td class=c><code>ramen</code></td><td class=e>🍜</td></tr>\n<tr><td class=c><code>spaghetti</code></td><td class=e>🍝</td></tr>\n<tr><td class=c><code>sweet_potato</code></td><td class=e>🍠</td></tr>\n<tr><td class=c><code>oden</code></td><td class=e>🍢</td></tr>\n<tr><td class=c><code>sushi</code></td><td class=e>🍣</td></tr>\n<tr><td class=c><code>fried_shrimp</code></td><td class=e>🍤</td></tr>\n<tr><td class=c><code>fish_cake</code></td><td class=e>🍥</td></tr>\n<tr><td class=c><code>moon_cake</code></td><td class=e>🥮</td></tr>\n<tr><td class=c><code>dango</code></td><td class=e>🍡</td></tr>\n<tr><td class=c><code>dumpling</code></td><td class=e>🥟</td></tr>\n<tr><td class=c><code>fortune_cookie</code></td><td class=e>🥠</td></tr>\n<tr><td class=c><code>takeout_box</code></td><td class=e>🥡</td></tr>\n<tr><td class=c><code>crab</code></td><td class=e>🦀</td></tr>\n<tr><td class=c><code>lobster</code></td><td class=e>🦞</td></tr>\n<tr><td class=c><code>shrimp</code></td><td class=e>🦐</td></tr>\n<tr><td class=c><code>squid</code></td><td class=e>🦑</td></tr>\n<tr><td class=c><code>oyster</code></td><td class=e>🦪</td></tr>\n<tr><td class=c><code>icecream</code></td><td class=e>🍦</td></tr>\n<tr><td class=c><code>shaved_ice</code></td><td class=e>🍧</td></tr>\n<tr><td class=c><code>ice_cream</code></td><td class=e>🍨</td></tr>\n<tr><td class=c><code>doughnut</code></td><td class=e>🍩</td></tr>\n<tr><td class=c><code>cookie</code></td><td class=e>🍪</td></tr>\n<tr><td class=c><code>birthday</code></td><td class=e>🎂</td></tr>\n<tr><td class=c><code>cake</code></td><td class=e>🍰</td></tr>\n<tr><td class=c><code>cupcake</code></td><td class=e>🧁</td></tr>\n<tr><td class=c><code>pie</code></td><td class=e>🥧</td></tr>\n<tr><td class=c><code>chocolate_bar</code></td><td class=e>🍫</td></tr>\n<tr><td class=c><code>candy</code></td><td class=e>🍬</td></tr>\n<tr><td class=c><code>lollipop</code></td><td class=e>🍭</td></tr>\n<tr><td class=c><code>custard</code></td><td class=e>🍮</td></tr>\n<tr><td class=c><code>honey_pot</code></td><td class=e>🍯</td></tr>\n<tr><td class=c><code>baby_bottle</code></td><td class=e>🍼</td></tr>\n<tr><td class=c><code>milk_glass</code></td><td class=e>🥛</td></tr>\n<tr><td class=c><code>coffee</code></td><td class=e>☕</td></tr>\n<tr><td class=c><code>teapot</code></td><td class=e>🫖</td></tr>\n<tr><td class=c><code>tea</code></td><td class=e>🍵</td></tr>\n<tr><td class=c><code>sake</code></td><td class=e>🍶</td></tr>\n<tr><td class=c><code>champagne</code></td><td class=e>🍾</td></tr>\n<tr><td class=c><code>wine_glass</code></td><td class=e>🍷</td></tr>\n<tr><td class=c><code>cocktail</code></td><td class=e>🍸</td></tr>\n<tr><td class=c><code>tropical_drink</code></td><td class=e>🍹</td></tr>\n<tr><td class=c><code>beer</code></td><td class=e>🍺</td></tr>\n<tr><td class=c><code>beers</code></td><td class=e>🍻</td></tr>\n<tr><td class=c><code>clinking_glasses</code></td><td class=e>🥂</td></tr>\n<tr><td class=c><code>tumbler_glass</code></td><td class=e>🥃</td></tr>\n<tr><td class=c><code>cup_with_straw</code></td><td class=e>🥤</td></tr>\n<tr><td class=c><code>bubble_tea</code></td><td class=e>🧋</td></tr>\n<tr><td class=c><code>beverage_box</code></td><td class=e>🧃</td></tr>\n<tr><td class=c><code>mate</code></td><td class=e>🧉</td></tr>\n<tr><td class=c><code>ice_cube</code></td><td class=e>🧊</td></tr>\n<tr><td class=c><code>chopsticks</code></td><td class=e>🥢</td></tr>\n<tr><td class=c><code>plate_with_cutlery</code></td><td class=e>🍽️</td></tr>\n<tr><td class=c><code>fork_and_knife</code></td><td class=e>🍴</td></tr>\n<tr><td class=c><code>spoon</code></td><td class=e>🥄</td></tr>\n<tr><td class=c><code>hocho</code></td><td class=e>🔪</td></tr>\n<tr><td class=c><code>amphora</code></td><td class=e>🏺</td></tr>\n<tr><td class=c><code>earth_africa</code></td><td class=e>🌍</td></tr>\n<tr><td class=c><code>earth_americas</code></td><td class=e>🌎</td></tr>\n<tr><td class=c><code>earth_asia</code></td><td class=e>🌏</td></tr>\n<tr><td class=c><code>globe_with_meridians</code></td><td class=e>🌐</td></tr>\n<tr><td class=c><code>world_map</code></td><td class=e>🗺️</td></tr>\n<tr><td class=c><code>japan</code></td><td class=e>🗾</td></tr>\n<tr><td class=c><code>compass</code></td><td class=e>🧭</td></tr>\n<tr><td class=c><code>mountain_snow</code></td><td class=e>🏔️</td></tr>\n<tr><td class=c><code>mountain</code></td><td class=e>⛰️</td></tr>\n<tr><td class=c><code>volcano</code></td><td class=e>🌋</td></tr>\n<tr><td class=c><code>mount_fuji</code></td><td class=e>🗻</td></tr>\n<tr><td class=c><code>camping</code></td><td class=e>🏕️</td></tr>\n<tr><td class=c><code>beach_umbrella</code></td><td class=e>🏖️</td></tr>\n<tr><td class=c><code>desert</code></td><td class=e>🏜️</td></tr>\n<tr><td class=c><code>desert_island</code></td><td class=e>🏝️</td></tr>\n<tr><td class=c><code>national_park</code></td><td class=e>🏞️</td></tr>\n<tr><td class=c><code>stadium</code></td><td class=e>🏟️</td></tr>\n<tr><td class=c><code>classical_building</code></td><td class=e>🏛️</td></tr>\n<tr><td class=c><code>building_construction</code></td><td class=e>🏗️</td></tr>\n<tr><td class=c><code>bricks</code></td><td class=e>🧱</td></tr>\n<tr><td class=c><code>rock</code></td><td class=e>🪨</td></tr>\n<tr><td class=c><code>wood</code></td><td class=e>🪵</td></tr>\n<tr><td class=c><code>hut</code></td><td class=e>🛖</td></tr>\n<tr><td class=c><code>houses</code></td><td class=e>🏘️</td></tr>\n<tr><td class=c><code>derelict_house</code></td><td class=e>🏚️</td></tr>\n<tr><td class=c><code>house</code></td><td class=e>🏠</td></tr>\n<tr><td class=c><code>house_with_garden</code></td><td class=e>🏡</td></tr>\n<tr><td class=c><code>office</code></td><td class=e>🏢</td></tr>\n<tr><td class=c><code>post_office</code></td><td class=e>🏣</td></tr>\n<tr><td class=c><code>european_post_office</code></td><td class=e>🏤</td></tr>\n<tr><td class=c><code>hospital</code></td><td class=e>🏥</td></tr>\n<tr><td class=c><code>bank</code></td><td class=e>🏦</td></tr>\n<tr><td class=c><code>hotel</code></td><td class=e>🏨</td></tr>\n<tr><td class=c><code>love_hotel</code></td><td class=e>🏩</td></tr>\n<tr><td class=c><code>convenience_store</code></td><td class=e>🏪</td></tr>\n<tr><td class=c><code>school</code></td><td class=e>🏫</td></tr>\n<tr><td class=c><code>department_store</code></td><td class=e>🏬</td></tr>\n<tr><td class=c><code>factory</code></td><td class=e>🏭</td></tr>\n<tr><td class=c><code>japanese_castle</code></td><td class=e>🏯</td></tr>\n<tr><td class=c><code>european_castle</code></td><td class=e>🏰</td></tr>\n<tr><td class=c><code>wedding</code></td><td class=e>💒</td></tr>\n<tr><td class=c><code>tokyo_tower</code></td><td class=e>🗼</td></tr>\n<tr><td class=c><code>statue_of_liberty</code></td><td class=e>🗽</td></tr>\n<tr><td class=c><code>church</code></td><td class=e>⛪</td></tr>\n<tr><td class=c><code>mosque</code></td><td class=e>🕌</td></tr>\n<tr><td class=c><code>hindu_temple</code></td><td class=e>🛕</td></tr>\n<tr><td class=c><code>synagogue</code></td><td class=e>🕍</td></tr>\n<tr><td class=c><code>shinto_shrine</code></td><td class=e>⛩️</td></tr>\n<tr><td class=c><code>kaaba</code></td><td class=e>🕋</td></tr>\n<tr><td class=c><code>fountain</code></td><td class=e>⛲</td></tr>\n<tr><td class=c><code>tent</code></td><td class=e>⛺</td></tr>\n<tr><td class=c><code>foggy</code></td><td class=e>🌁</td></tr>\n<tr><td class=c><code>night_with_stars</code></td><td class=e>🌃</td></tr>\n<tr><td class=c><code>cityscape</code></td><td class=e>🏙️</td></tr>\n<tr><td class=c><code>sunrise_over_mountains</code></td><td class=e>🌄</td></tr>\n<tr><td class=c><code>sunrise</code></td><td class=e>🌅</td></tr>\n<tr><td class=c><code>city_sunset</code></td><td class=e>🌆</td></tr>\n<tr><td class=c><code>city_sunrise</code></td><td class=e>🌇</td></tr>\n<tr><td class=c><code>bridge_at_night</code></td><td class=e>🌉</td></tr>\n<tr><td class=c><code>hotsprings</code></td><td class=e>♨️</td></tr>\n<tr><td class=c><code>carousel_horse</code></td><td class=e>🎠</td></tr>\n<tr><td class=c><code>ferris_wheel</code></td><td class=e>🎡</td></tr>\n<tr><td class=c><code>roller_coaster</code></td><td class=e>🎢</td></tr>\n<tr><td class=c><code>barber</code></td><td class=e>💈</td></tr>\n<tr><td class=c><code>circus_tent</code></td><td class=e>🎪</td></tr>\n<tr><td class=c><code>steam_locomotive</code></td><td class=e>🚂</td></tr>\n<tr><td class=c><code>railway_car</code></td><td class=e>🚃</td></tr>\n<tr><td class=c><code>bullettrain_side</code></td><td class=e>🚄</td></tr>\n<tr><td class=c><code>bullettrain_front</code></td><td class=e>🚅</td></tr>\n<tr><td class=c><code>train2</code></td><td class=e>🚆</td></tr>\n<tr><td class=c><code>metro</code></td><td class=e>🚇</td></tr>\n<tr><td class=c><code>light_rail</code></td><td class=e>🚈</td></tr>\n<tr><td class=c><code>station</code></td><td class=e>🚉</td></tr>\n<tr><td class=c><code>tram</code></td><td class=e>🚊</td></tr>\n<tr><td class=c><code>monorail</code></td><td class=e>🚝</td></tr>\n<tr><td class=c><code>mountain_railway</code></td><td class=e>🚞</td></tr>\n<tr><td class=c><code>train</code></td><td class=e>🚋</td></tr>\n<tr><td class=c><code>bus</code></td><td class=e>🚌</td></tr>\n<tr><td class=c><code>oncoming_bus</code></td><td class=e>🚍</td></tr>\n<tr><td class=c><code>trolleybus</code></td><td class=e>🚎</td></tr>\n<tr><td class=c><code>minibus</code></td><td class=e>🚐</td></tr>\n<tr><td class=c><code>ambulance</code></td><td class=e>🚑</td></tr>\n<tr><td class=c><code>fire_engine</code></td><td class=e>🚒</td></tr>\n<tr><td class=c><code>police_car</code></td><td class=e>🚓</td></tr>\n<tr><td class=c><code>oncoming_police_car</code></td><td class=e>🚔</td></tr>\n<tr><td class=c><code>taxi</code></td><td class=e>🚕</td></tr>\n<tr><td class=c><code>oncoming_taxi</code></td><td class=e>🚖</td></tr>\n<tr><td class=c><code>car</code></td><td class=e>🚗</td></tr>\n<tr><td class=c><code>oncoming_automobile</code></td><td class=e>🚘</td></tr>\n<tr><td class=c><code>blue_car</code></td><td class=e>🚙</td></tr>\n<tr><td class=c><code>pickup_truck</code></td><td class=e>🛻</td></tr>\n<tr><td class=c><code>truck</code></td><td class=e>🚚</td></tr>\n<tr><td class=c><code>articulated_lorry</code></td><td class=e>🚛</td></tr>\n<tr><td class=c><code>tractor</code></td><td class=e>🚜</td></tr>\n<tr><td class=c><code>racing_car</code></td><td class=e>🏎️</td></tr>\n<tr><td class=c><code>motorcycle</code></td><td class=e>🏍️</td></tr>\n<tr><td class=c><code>motor_scooter</code></td><td class=e>🛵</td></tr>\n<tr><td class=c><code>manual_wheelchair</code></td><td class=e>🦽</td></tr>\n<tr><td class=c><code>motorized_wheelchair</code></td><td class=e>🦼</td></tr>\n<tr><td class=c><code>auto_rickshaw</code></td><td class=e>🛺</td></tr>\n<tr><td class=c><code>bike</code></td><td class=e>🚲</td></tr>\n<tr><td class=c><code>kick_scooter</code></td><td class=e>🛴</td></tr>\n<tr><td class=c><code>skateboard</code></td><td class=e>🛹</td></tr>\n<tr><td class=c><code>roller_skate</code></td><td class=e>🛼</td></tr>\n<tr><td class=c><code>busstop</code></td><td class=e>🚏</td></tr>\n<tr><td class=c><code>motorway</code></td><td class=e>🛣️</td></tr>\n<tr><td class=c><code>railway_track</code></td><td class=e>🛤️</td></tr>\n<tr><td class=c><code>oil_drum</code></td><td class=e>🛢️</td></tr>\n<tr><td class=c><code>fuelpump</code></td><td class=e>⛽</td></tr>\n<tr><td class=c><code>rotating_light</code></td><td class=e>🚨</td></tr>\n<tr><td class=c><code>traffic_light</code></td><td class=e>🚥</td></tr>\n<tr><td class=c><code>vertical_traffic_light</code></td><td class=e>🚦</td></tr>\n<tr><td class=c><code>stop_sign</code></td><td class=e>🛑</td></tr>\n<tr><td class=c><code>construction</code></td><td class=e>🚧</td></tr>\n<tr><td class=c><code>anchor</code></td><td class=e>⚓</td></tr>\n<tr><td class=c><code>boat</code></td><td class=e>⛵</td></tr>\n<tr><td class=c><code>canoe</code></td><td class=e>🛶</td></tr>\n<tr><td class=c><code>speedboat</code></td><td class=e>🚤</td></tr>\n<tr><td class=c><code>passenger_ship</code></td><td class=e>🛳️</td></tr>\n<tr><td class=c><code>ferry</code></td><td class=e>⛴️</td></tr>\n<tr><td class=c><code>motor_boat</code></td><td class=e>🛥️</td></tr>\n<tr><td class=c><code>ship</code></td><td class=e>🚢</td></tr>\n<tr><td class=c><code>airplane</code></td><td class=e>✈️</td></tr>\n<tr><td class=c><code>small_airplane</code></td><td class=e>🛩️</td></tr>\n<tr><td class=c><code>flight_departure</code></td><td class=e>🛫</td></tr>\n<tr><td class=c><code>flight_arrival</code></td><td class=e>🛬</td></tr>\n<tr><td class=c><code>parachute</code></td><td class=e>🪂</td></tr>\n<tr><td class=c><code>seat</code></td><td class=e>💺</td></tr>\n<tr><td class=c><code>helicopter</code></td><td class=e>🚁</td></tr>\n<tr><td class=c><code>suspension_railway</code></td><td class=e>🚟</td></tr>\n<tr><td class=c><code>mountain_cableway</code></td><td class=e>🚠</td></tr>\n<tr><td class=c><code>aerial_tramway</code></td><td class=e>🚡</td></tr>\n<tr><td class=c><code>artificial_satellite</code></td><td class=e>🛰️</td></tr>\n<tr><td class=c><code>rocket</code></td><td class=e>🚀</td></tr>\n<tr><td class=c><code>flying_saucer</code></td><td class=e>🛸</td></tr>\n<tr><td class=c><code>bellhop_bell</code></td><td class=e>🛎️</td></tr>\n<tr><td class=c><code>luggage</code></td><td class=e>🧳</td></tr>\n<tr><td class=c><code>hourglass</code></td><td class=e>⌛</td></tr>\n<tr><td class=c><code>hourglass_flowing_sand</code></td><td class=e>⏳</td></tr>\n<tr><td class=c><code>watch</code></td><td class=e>⌚</td></tr>\n<tr><td class=c><code>alarm_clock</code></td><td class=e>⏰</td></tr>\n<tr><td class=c><code>stopwatch</code></td><td class=e>⏱️</td></tr>\n<tr><td class=c><code>timer_clock</code></td><td class=e>⏲️</td></tr>\n<tr><td class=c><code>mantelpiece_clock</code></td><td class=e>🕰️</td></tr>\n<tr><td class=c><code>clock12</code></td><td class=e>🕛</td></tr>\n<tr><td class=c><code>clock1230</code></td><td class=e>🕧</td></tr>\n<tr><td class=c><code>clock1</code></td><td class=e>🕐</td></tr>\n<tr><td class=c><code>clock130</code></td><td class=e>🕜</td></tr>\n<tr><td class=c><code>clock2</code></td><td class=e>🕑</td></tr>\n<tr><td class=c><code>clock230</code></td><td class=e>🕝</td></tr>\n<tr><td class=c><code>clock3</code></td><td class=e>🕒</td></tr>\n<tr><td class=c><code>clock330</code></td><td class=e>🕞</td></tr>\n<tr><td class=c><code>clock4</code></td><td class=e>🕓</td></tr>\n<tr><td class=c><code>clock430</code></td><td class=e>🕟</td></tr>\n<tr><td class=c><code>clock5</code></td><td class=e>🕔</td></tr>\n<tr><td class=c><code>clock530</code></td><td class=e>🕠</td></tr>\n<tr><td class=c><code>clock6</code></td><td class=e>🕕</td></tr>\n<tr><td class=c><code>clock630</code></td><td class=e>🕡</td></tr>\n<tr><td class=c><code>clock7</code></td><td class=e>🕖</td></tr>\n<tr><td class=c><code>clock730</code></td><td class=e>🕢</td></tr>\n<tr><td class=c><code>clock8</code></td><td class=e>🕗</td></tr>\n<tr><td class=c><code>clock830</code></td><td class=e>🕣</td></tr>\n<tr><td class=c><code>clock9</code></td><td class=e>🕘</td></tr>\n<tr><td class=c><code>clock930</code></td><td class=e>🕤</td></tr>\n<tr><td class=c><code>clock10</code></td><td class=e>🕙</td></tr>\n<tr><td class=c><code>clock1030</code></td><td class=e>🕥</td></tr>\n<tr><td class=c><code>clock11</code></td><td class=e>🕚</td></tr>\n<tr><td class=c><code>clock1130</code></td><td class=e>🕦</td></tr>\n<tr><td class=c><code>new_moon</code></td><td class=e>🌑</td></tr>\n<tr><td class=c><code>waxing_crescent_moon</code></td><td class=e>🌒</td></tr>\n<tr><td class=c><code>first_quarter_moon</code></td><td class=e>🌓</td></tr>\n<tr><td class=c><code>moon</code></td><td class=e>🌔</td></tr>\n<tr><td class=c><code>full_moon</code></td><td class=e>🌕</td></tr>\n<tr><td class=c><code>waning_gibbous_moon</code></td><td class=e>🌖</td></tr>\n<tr><td class=c><code>last_quarter_moon</code></td><td class=e>🌗</td></tr>\n<tr><td class=c><code>waning_crescent_moon</code></td><td class=e>🌘</td></tr>\n<tr><td class=c><code>crescent_moon</code></td><td class=e>🌙</td></tr>\n<tr><td class=c><code>new_moon_with_face</code></td><td class=e>🌚</td></tr>\n<tr><td class=c><code>first_quarter_moon_with_face</code></td><td class=e>🌛</td></tr>\n<tr><td class=c><code>last_quarter_moon_with_face</code></td><td class=e>🌜</td></tr>\n<tr><td class=c><code>thermometer</code></td><td class=e>🌡️</td></tr>\n<tr><td class=c><code>sunny</code></td><td class=e>☀️</td></tr>\n<tr><td class=c><code>full_moon_with_face</code></td><td class=e>🌝</td></tr>\n<tr><td class=c><code>sun_with_face</code></td><td class=e>🌞</td></tr>\n<tr><td class=c><code>ringed_planet</code></td><td class=e>🪐</td></tr>\n<tr><td class=c><code>star</code></td><td class=e>⭐</td></tr>\n<tr><td class=c><code>star2</code></td><td class=e>🌟</td></tr>\n<tr><td class=c><code>stars</code></td><td class=e>🌠</td></tr>\n<tr><td class=c><code>milky_way</code></td><td class=e>🌌</td></tr>\n<tr><td class=c><code>cloud</code></td><td class=e>☁️</td></tr>\n<tr><td class=c><code>partly_sunny</code></td><td class=e>⛅</td></tr>\n<tr><td class=c><code>cloud_with_lightning_and_rain</code></td><td class=e>⛈️</td></tr>\n<tr><td class=c><code>sun_behind_small_cloud</code></td><td class=e>🌤️</td></tr>\n<tr><td class=c><code>sun_behind_large_cloud</code></td><td class=e>🌥️</td></tr>\n<tr><td class=c><code>sun_behind_rain_cloud</code></td><td class=e>🌦️</td></tr>\n<tr><td class=c><code>cloud_with_rain</code></td><td class=e>🌧️</td></tr>\n<tr><td class=c><code>cloud_with_snow</code></td><td class=e>🌨️</td></tr>\n<tr><td class=c><code>cloud_with_lightning</code></td><td class=e>🌩️</td></tr>\n<tr><td class=c><code>tornado</code></td><td class=e>🌪️</td></tr>\n<tr><td class=c><code>fog</code></td><td class=e>🌫️</td></tr>\n<tr><td class=c><code>wind_face</code></td><td class=e>🌬️</td></tr>\n<tr><td class=c><code>cyclone</code></td><td class=e>🌀</td></tr>\n<tr><td class=c><code>rainbow</code></td><td class=e>🌈</td></tr>\n<tr><td class=c><code>closed_umbrella</code></td><td class=e>🌂</td></tr>\n<tr><td class=c><code>open_umbrella</code></td><td class=e>☂️</td></tr>\n<tr><td class=c><code>umbrella</code></td><td class=e>☔</td></tr>\n<tr><td class=c><code>parasol_on_ground</code></td><td class=e>⛱️</td></tr>\n<tr><td class=c><code>zap</code></td><td class=e>⚡</td></tr>\n<tr><td class=c><code>snowflake</code></td><td class=e>❄️</td></tr>\n<tr><td class=c><code>snowman_with_snow</code></td><td class=e>☃️</td></tr>\n<tr><td class=c><code>snowman</code></td><td class=e>⛄</td></tr>\n<tr><td class=c><code>comet</code></td><td class=e>☄️</td></tr>\n<tr><td class=c><code>fire</code></td><td class=e>🔥</td></tr>\n<tr><td class=c><code>droplet</code></td><td class=e>💧</td></tr>\n<tr><td class=c><code>ocean</code></td><td class=e>🌊</td></tr>\n<tr><td class=c><code>jack_o_lantern</code></td><td class=e>🎃</td></tr>\n<tr><td class=c><code>christmas_tree</code></td><td class=e>🎄</td></tr>\n<tr><td class=c><code>fireworks</code></td><td class=e>🎆</td></tr>\n<tr><td class=c><code>sparkler</code></td><td class=e>🎇</td></tr>\n<tr><td class=c><code>firecracker</code></td><td class=e>🧨</td></tr>\n<tr><td class=c><code>sparkles</code></td><td class=e>✨</td></tr>\n<tr><td class=c><code>balloon</code></td><td class=e>🎈</td></tr>\n<tr><td class=c><code>tada</code></td><td class=e>🎉</td></tr>\n<tr><td class=c><code>confetti_ball</code></td><td class=e>🎊</td></tr>\n<tr><td class=c><code>tanabata_tree</code></td><td class=e>🎋</td></tr>\n<tr><td class=c><code>bamboo</code></td><td class=e>🎍</td></tr>\n<tr><td class=c><code>dolls</code></td><td class=e>🎎</td></tr>\n<tr><td class=c><code>flags</code></td><td class=e>🎏</td></tr>\n<tr><td class=c><code>wind_chime</code></td><td class=e>🎐</td></tr>\n<tr><td class=c><code>rice_scene</code></td><td class=e>🎑</td></tr>\n<tr><td class=c><code>red_envelope</code></td><td class=e>🧧</td></tr>\n<tr><td class=c><code>ribbon</code></td><td class=e>🎀</td></tr>\n<tr><td class=c><code>gift</code></td><td class=e>🎁</td></tr>\n<tr><td class=c><code>reminder_ribbon</code></td><td class=e>🎗️</td></tr>\n<tr><td class=c><code>tickets</code></td><td class=e>🎟️</td></tr>\n<tr><td class=c><code>ticket</code></td><td class=e>🎫</td></tr>\n<tr><td class=c><code>medal_military</code></td><td class=e>🎖️</td></tr>\n<tr><td class=c><code>trophy</code></td><td class=e>🏆</td></tr>\n<tr><td class=c><code>medal_sports</code></td><td class=e>🏅</td></tr>\n<tr><td class=c><code>1st_place_medal</code></td><td class=e>🥇</td></tr>\n<tr><td class=c><code>2nd_place_medal</code></td><td class=e>🥈</td></tr>\n<tr><td class=c><code>3rd_place_medal</code></td><td class=e>🥉</td></tr>\n<tr><td class=c><code>soccer</code></td><td class=e>⚽</td></tr>\n<tr><td class=c><code>baseball</code></td><td class=e>⚾</td></tr>\n<tr><td class=c><code>softball</code></td><td class=e>🥎</td></tr>\n<tr><td class=c><code>basketball</code></td><td class=e>🏀</td></tr>\n<tr><td class=c><code>volleyball</code></td><td class=e>🏐</td></tr>\n<tr><td class=c><code>football</code></td><td class=e>🏈</td></tr>\n<tr><td class=c><code>rugby_football</code></td><td class=e>🏉</td></tr>\n<tr><td class=c><code>tennis</code></td><td class=e>🎾</td></tr>\n<tr><td class=c><code>flying_disc</code></td><td class=e>🥏</td></tr>\n<tr><td class=c><code>bowling</code></td><td class=e>🎳</td></tr>\n<tr><td class=c><code>cricket_game</code></td><td class=e>🏏</td></tr>\n<tr><td class=c><code>field_hockey</code></td><td class=e>🏑</td></tr>\n<tr><td class=c><code>ice_hockey</code></td><td class=e>🏒</td></tr>\n<tr><td class=c><code>lacrosse</code></td><td class=e>🥍</td></tr>\n<tr><td class=c><code>ping_pong</code></td><td class=e>🏓</td></tr>\n<tr><td class=c><code>badminton</code></td><td class=e>🏸</td></tr>\n<tr><td class=c><code>boxing_glove</code></td><td class=e>🥊</td></tr>\n<tr><td class=c><code>martial_arts_uniform</code></td><td class=e>🥋</td></tr>\n<tr><td class=c><code>goal_net</code></td><td class=e>🥅</td></tr>\n<tr><td class=c><code>golf</code></td><td class=e>⛳</td></tr>\n<tr><td class=c><code>ice_skate</code></td><td class=e>⛸️</td></tr>\n<tr><td class=c><code>fishing_pole_and_fish</code></td><td class=e>🎣</td></tr>\n<tr><td class=c><code>diving_mask</code></td><td class=e>🤿</td></tr>\n<tr><td class=c><code>running_shirt_with_sash</code></td><td class=e>🎽</td></tr>\n<tr><td class=c><code>ski</code></td><td class=e>🎿</td></tr>\n<tr><td class=c><code>sled</code></td><td class=e>🛷</td></tr>\n<tr><td class=c><code>curling_stone</code></td><td class=e>🥌</td></tr>\n<tr><td class=c><code>dart</code></td><td class=e>🎯</td></tr>\n<tr><td class=c><code>yo_yo</code></td><td class=e>🪀</td></tr>\n<tr><td class=c><code>kite</code></td><td class=e>🪁</td></tr>\n<tr><td class=c><code>8ball</code></td><td class=e>🎱</td></tr>\n<tr><td class=c><code>crystal_ball</code></td><td class=e>🔮</td></tr>\n<tr><td class=c><code>magic_wand</code></td><td class=e>🪄</td></tr>\n<tr><td class=c><code>nazar_amulet</code></td><td class=e>🧿</td></tr>\n<tr><td class=c><code>video_game</code></td><td class=e>🎮</td></tr>\n<tr><td class=c><code>joystick</code></td><td class=e>🕹️</td></tr>\n<tr><td class=c><code>slot_machine</code></td><td class=e>🎰</td></tr>\n<tr><td class=c><code>game_die</code></td><td class=e>🎲</td></tr>\n<tr><td class=c><code>jigsaw</code></td><td class=e>🧩</td></tr>\n<tr><td class=c><code>teddy_bear</code></td><td class=e>🧸</td></tr>\n<tr><td class=c><code>pinata</code></td><td class=e>🪅</td></tr>\n<tr><td class=c><code>nesting_dolls</code></td><td class=e>🪆</td></tr>\n<tr><td class=c><code>spades</code></td><td class=e>♠️</td></tr>\n<tr><td class=c><code>hearts</code></td><td class=e>♥️</td></tr>\n<tr><td class=c><code>diamonds</code></td><td class=e>♦️</td></tr>\n<tr><td class=c><code>clubs</code></td><td class=e>♣️</td></tr>\n<tr><td class=c><code>chess_pawn</code></td><td class=e>♟️</td></tr>\n<tr><td class=c><code>black_joker</code></td><td class=e>🃏</td></tr>\n<tr><td class=c><code>mahjong</code></td><td class=e>🀄</td></tr>\n<tr><td class=c><code>flower_playing_cards</code></td><td class=e>🎴</td></tr>\n<tr><td class=c><code>performing_arts</code></td><td class=e>🎭</td></tr>\n<tr><td class=c><code>framed_picture</code></td><td class=e>🖼️</td></tr>\n<tr><td class=c><code>art</code></td><td class=e>🎨</td></tr>\n<tr><td class=c><code>thread</code></td><td class=e>🧵</td></tr>\n<tr><td class=c><code>sewing_needle</code></td><td class=e>🪡</td></tr>\n<tr><td class=c><code>yarn</code></td><td class=e>🧶</td></tr>\n<tr><td class=c><code>knot</code></td><td class=e>🪢</td></tr>\n<tr><td class=c><code>eyeglasses</code></td><td class=e>👓</td></tr>\n<tr><td class=c><code>dark_sunglasses</code></td><td class=e>🕶️</td></tr>\n<tr><td class=c><code>goggles</code></td><td class=e>🥽</td></tr>\n<tr><td class=c><code>lab_coat</code></td><td class=e>🥼</td></tr>\n<tr><td class=c><code>safety_vest</code></td><td class=e>🦺</td></tr>\n<tr><td class=c><code>necktie</code></td><td class=e>👔</td></tr>\n<tr><td class=c><code>shirt</code></td><td class=e>👕</td></tr>\n<tr><td class=c><code>jeans</code></td><td class=e>👖</td></tr>\n<tr><td class=c><code>scarf</code></td><td class=e>🧣</td></tr>\n<tr><td class=c><code>gloves</code></td><td class=e>🧤</td></tr>\n<tr><td class=c><code>coat</code></td><td class=e>🧥</td></tr>\n<tr><td class=c><code>socks</code></td><td class=e>🧦</td></tr>\n<tr><td class=c><code>dress</code></td><td class=e>👗</td></tr>\n<tr><td class=c><code>kimono</code></td><td class=e>👘</td></tr>\n<tr><td class=c><code>sari</code></td><td class=e>🥻</td></tr>\n<tr><td class=c><code>one_piece_swimsuit</code></td><td class=e>🩱</td></tr>\n<tr><td class=c><code>swim_brief</code></td><td class=e>🩲</td></tr>\n<tr><td class=c><code>shorts</code></td><td class=e>🩳</td></tr>\n<tr><td class=c><code>bikini</code></td><td class=e>👙</td></tr>\n<tr><td class=c><code>womans_clothes</code></td><td class=e>👚</td></tr>\n<tr><td class=c><code>purse</code></td><td class=e>👛</td></tr>\n<tr><td class=c><code>handbag</code></td><td class=e>👜</td></tr>\n<tr><td class=c><code>pouch</code></td><td class=e>👝</td></tr>\n<tr><td class=c><code>shopping</code></td><td class=e>🛍️</td></tr>\n<tr><td class=c><code>school_satchel</code></td><td class=e>🎒</td></tr>\n<tr><td class=c><code>thong_sandal</code></td><td class=e>🩴</td></tr>\n<tr><td class=c><code>mans_shoe</code></td><td class=e>👞</td></tr>\n<tr><td class=c><code>athletic_shoe</code></td><td class=e>👟</td></tr>\n<tr><td class=c><code>hiking_boot</code></td><td class=e>🥾</td></tr>\n<tr><td class=c><code>flat_shoe</code></td><td class=e>🥿</td></tr>\n<tr><td class=c><code>high_heel</code></td><td class=e>👠</td></tr>\n<tr><td class=c><code>sandal</code></td><td class=e>👡</td></tr>\n<tr><td class=c><code>ballet_shoes</code></td><td class=e>🩰</td></tr>\n<tr><td class=c><code>boot</code></td><td class=e>👢</td></tr>\n<tr><td class=c><code>crown</code></td><td class=e>👑</td></tr>\n<tr><td class=c><code>womans_hat</code></td><td class=e>👒</td></tr>\n<tr><td class=c><code>tophat</code></td><td class=e>🎩</td></tr>\n<tr><td class=c><code>mortar_board</code></td><td class=e>🎓</td></tr>\n<tr><td class=c><code>billed_cap</code></td><td class=e>🧢</td></tr>\n<tr><td class=c><code>military_helmet</code></td><td class=e>🪖</td></tr>\n<tr><td class=c><code>rescue_worker_helmet</code></td><td class=e>⛑️</td></tr>\n<tr><td class=c><code>prayer_beads</code></td><td class=e>📿</td></tr>\n<tr><td class=c><code>lipstick</code></td><td class=e>💄</td></tr>\n<tr><td class=c><code>ring</code></td><td class=e>💍</td></tr>\n<tr><td class=c><code>gem</code></td><td class=e>💎</td></tr>\n<tr><td class=c><code>mute</code></td><td class=e>🔇</td></tr>\n<tr><td class=c><code>speaker</code></td><td class=e>🔈</td></tr>\n<tr><td class=c><code>sound</code></td><td class=e>🔉</td></tr>\n<tr><td class=c><code>loud_sound</code></td><td class=e>🔊</td></tr>\n<tr><td class=c><code>loudspeaker</code></td><td class=e>📢</td></tr>\n<tr><td class=c><code>mega</code></td><td class=e>📣</td></tr>\n<tr><td class=c><code>postal_horn</code></td><td class=e>📯</td></tr>\n<tr><td class=c><code>bell</code></td><td class=e>🔔</td></tr>\n<tr><td class=c><code>no_bell</code></td><td class=e>🔕</td></tr>\n<tr><td class=c><code>musical_score</code></td><td class=e>🎼</td></tr>\n<tr><td class=c><code>musical_note</code></td><td class=e>🎵</td></tr>\n<tr><td class=c><code>notes</code></td><td class=e>🎶</td></tr>\n<tr><td class=c><code>studio_microphone</code></td><td class=e>🎙️</td></tr>\n<tr><td class=c><code>level_slider</code></td><td class=e>🎚️</td></tr>\n<tr><td class=c><code>control_knobs</code></td><td class=e>🎛️</td></tr>\n<tr><td class=c><code>microphone</code></td><td class=e>🎤</td></tr>\n<tr><td class=c><code>headphones</code></td><td class=e>🎧</td></tr>\n<tr><td class=c><code>radio</code></td><td class=e>📻</td></tr>\n<tr><td class=c><code>saxophone</code></td><td class=e>🎷</td></tr>\n<tr><td class=c><code>accordion</code></td><td class=e>🪗</td></tr>\n<tr><td class=c><code>guitar</code></td><td class=e>🎸</td></tr>\n<tr><td class=c><code>musical_keyboard</code></td><td class=e>🎹</td></tr>\n<tr><td class=c><code>trumpet</code></td><td class=e>🎺</td></tr>\n<tr><td class=c><code>violin</code></td><td class=e>🎻</td></tr>\n<tr><td class=c><code>banjo</code></td><td class=e>🪕</td></tr>\n<tr><td class=c><code>drum</code></td><td class=e>🥁</td></tr>\n<tr><td class=c><code>long_drum</code></td><td class=e>🪘</td></tr>\n<tr><td class=c><code>iphone</code></td><td class=e>📱</td></tr>\n<tr><td class=c><code>calling</code></td><td class=e>📲</td></tr>\n<tr><td class=c><code>phone</code></td><td class=e>☎️</td></tr>\n<tr><td class=c><code>telephone_receiver</code></td><td class=e>📞</td></tr>\n<tr><td class=c><code>pager</code></td><td class=e>📟</td></tr>\n<tr><td class=c><code>fax</code></td><td class=e>📠</td></tr>\n<tr><td class=c><code>battery</code></td><td class=e>🔋</td></tr>\n<tr><td class=c><code>electric_plug</code></td><td class=e>🔌</td></tr>\n<tr><td class=c><code>computer</code></td><td class=e>💻</td></tr>\n<tr><td class=c><code>desktop_computer</code></td><td class=e>🖥️</td></tr>\n<tr><td class=c><code>printer</code></td><td class=e>🖨️</td></tr>\n<tr><td class=c><code>keyboard</code></td><td class=e>⌨️</td></tr>\n<tr><td class=c><code>computer_mouse</code></td><td class=e>🖱️</td></tr>\n<tr><td class=c><code>trackball</code></td><td class=e>🖲️</td></tr>\n<tr><td class=c><code>minidisc</code></td><td class=e>💽</td></tr>\n<tr><td class=c><code>floppy_disk</code></td><td class=e>💾</td></tr>\n<tr><td class=c><code>cd</code></td><td class=e>💿</td></tr>\n<tr><td class=c><code>dvd</code></td><td class=e>📀</td></tr>\n<tr><td class=c><code>abacus</code></td><td class=e>🧮</td></tr>\n<tr><td class=c><code>movie_camera</code></td><td class=e>🎥</td></tr>\n<tr><td class=c><code>film_strip</code></td><td class=e>🎞️</td></tr>\n<tr><td class=c><code>film_projector</code></td><td class=e>📽️</td></tr>\n<tr><td class=c><code>clapper</code></td><td class=e>🎬</td></tr>\n<tr><td class=c><code>tv</code></td><td class=e>📺</td></tr>\n<tr><td class=c><code>camera</code></td><td class=e>📷</td></tr>\n<tr><td class=c><code>camera_flash</code></td><td class=e>📸</td></tr>\n<tr><td class=c><code>video_camera</code></td><td class=e>📹</td></tr>\n<tr><td class=c><code>vhs</code></td><td class=e>📼</td></tr>\n<tr><td class=c><code>mag</code></td><td class=e>🔍</td></tr>\n<tr><td class=c><code>mag_right</code></td><td class=e>🔎</td></tr>\n<tr><td class=c><code>candle</code></td><td class=e>🕯️</td></tr>\n<tr><td class=c><code>bulb</code></td><td class=e>💡</td></tr>\n<tr><td class=c><code>flashlight</code></td><td class=e>🔦</td></tr>\n<tr><td class=c><code>izakaya_lantern</code></td><td class=e>🏮</td></tr>\n<tr><td class=c><code>diya_lamp</code></td><td class=e>🪔</td></tr>\n<tr><td class=c><code>notebook_with_decorative_cover</code></td><td class=e>📔</td></tr>\n<tr><td class=c><code>closed_book</code></td><td class=e>📕</td></tr>\n<tr><td class=c><code>book</code></td><td class=e>📖</td></tr>\n<tr><td class=c><code>green_book</code></td><td class=e>📗</td></tr>\n<tr><td class=c><code>blue_book</code></td><td class=e>📘</td></tr>\n<tr><td class=c><code>orange_book</code></td><td class=e>📙</td></tr>\n<tr><td class=c><code>books</code></td><td class=e>📚</td></tr>\n<tr><td class=c><code>notebook</code></td><td class=e>📓</td></tr>\n<tr><td class=c><code>ledger</code></td><td class=e>📒</td></tr>\n<tr><td class=c><code>page_with_curl</code></td><td class=e>📃</td></tr>\n<tr><td class=c><code>scroll</code></td><td class=e>📜</td></tr>\n<tr><td class=c><code>page_facing_up</code></td><td class=e>📄</td></tr>\n<tr><td class=c><code>newspaper</code></td><td class=e>📰</td></tr>\n<tr><td class=c><code>newspaper_roll</code></td><td class=e>🗞️</td></tr>\n<tr><td class=c><code>bookmark_tabs</code></td><td class=e>📑</td></tr>\n<tr><td class=c><code>bookmark</code></td><td class=e>🔖</td></tr>\n<tr><td class=c><code>label</code></td><td class=e>🏷️</td></tr>\n<tr><td class=c><code>moneybag</code></td><td class=e>💰</td></tr>\n<tr><td class=c><code>coin</code></td><td class=e>🪙</td></tr>\n<tr><td class=c><code>yen</code></td><td class=e>💴</td></tr>\n<tr><td class=c><code>dollar</code></td><td class=e>💵</td></tr>\n<tr><td class=c><code>euro</code></td><td class=e>💶</td></tr>\n<tr><td class=c><code>pound</code></td><td class=e>💷</td></tr>\n<tr><td class=c><code>money_with_wings</code></td><td class=e>💸</td></tr>\n<tr><td class=c><code>credit_card</code></td><td class=e>💳</td></tr>\n<tr><td class=c><code>receipt</code></td><td class=e>🧾</td></tr>\n<tr><td class=c><code>chart</code></td><td class=e>💹</td></tr>\n<tr><td class=c><code>envelope</code></td><td class=e>✉️</td></tr>\n<tr><td class=c><code>email</code></td><td class=e>📧</td></tr>\n</tbody></table></td>\n<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>\n<tr><td class=c><code>email</code></td><td class=e>📧</td></tr>\n<tr><td class=c><code>incoming_envelope</code></td><td class=e>📨</td></tr>\n<tr><td class=c><code>envelope_with_arrow</code></td><td class=e>📩</td></tr>\n<tr><td class=c><code>outbox_tray</code></td><td class=e>📤</td></tr>\n<tr><td class=c><code>inbox_tray</code></td><td class=e>📥</td></tr>\n<tr><td class=c><code>package</code></td><td class=e>📦</td></tr>\n<tr><td class=c><code>mailbox</code></td><td class=e>📫</td></tr>\n<tr><td class=c><code>mailbox_closed</code></td><td class=e>📪</td></tr>\n<tr><td class=c><code>mailbox_with_mail</code></td><td class=e>📬</td></tr>\n<tr><td class=c><code>mailbox_with_no_mail</code></td><td class=e>📭</td></tr>\n<tr><td class=c><code>postbox</code></td><td class=e>📮</td></tr>\n<tr><td class=c><code>ballot_box</code></td><td class=e>🗳️</td></tr>\n<tr><td class=c><code>pencil2</code></td><td class=e>✏️</td></tr>\n<tr><td class=c><code>black_nib</code></td><td class=e>✒️</td></tr>\n<tr><td class=c><code>fountain_pen</code></td><td class=e>🖋️</td></tr>\n<tr><td class=c><code>pen</code></td><td class=e>🖊️</td></tr>\n<tr><td class=c><code>paintbrush</code></td><td class=e>🖌️</td></tr>\n<tr><td class=c><code>crayon</code></td><td class=e>🖍️</td></tr>\n<tr><td class=c><code>memo</code></td><td class=e>📝</td></tr>\n<tr><td class=c><code>briefcase</code></td><td class=e>💼</td></tr>\n<tr><td class=c><code>file_folder</code></td><td class=e>📁</td></tr>\n<tr><td class=c><code>open_file_folder</code></td><td class=e>📂</td></tr>\n<tr><td class=c><code>card_index_dividers</code></td><td class=e>🗂️</td></tr>\n<tr><td class=c><code>date</code></td><td class=e>📅</td></tr>\n<tr><td class=c><code>calendar</code></td><td class=e>📆</td></tr>\n<tr><td class=c><code>spiral_notepad</code></td><td class=e>🗒️</td></tr>\n<tr><td class=c><code>spiral_calendar</code></td><td class=e>🗓️</td></tr>\n<tr><td class=c><code>card_index</code></td><td class=e>📇</td></tr>\n<tr><td class=c><code>chart_with_upwards_trend</code></td><td class=e>📈</td></tr>\n<tr><td class=c><code>chart_with_downwards_trend</code></td><td class=e>📉</td></tr>\n<tr><td class=c><code>bar_chart</code></td><td class=e>📊</td></tr>\n<tr><td class=c><code>clipboard</code></td><td class=e>📋</td></tr>\n<tr><td class=c><code>pushpin</code></td><td class=e>📌</td></tr>\n<tr><td class=c><code>round_pushpin</code></td><td class=e>📍</td></tr>\n<tr><td class=c><code>paperclip</code></td><td class=e>📎</td></tr>\n<tr><td class=c><code>paperclips</code></td><td class=e>🖇️</td></tr>\n<tr><td class=c><code>straight_ruler</code></td><td class=e>📏</td></tr>\n<tr><td class=c><code>triangular_ruler</code></td><td class=e>📐</td></tr>\n<tr><td class=c><code>scissors</code></td><td class=e>✂️</td></tr>\n<tr><td class=c><code>card_file_box</code></td><td class=e>🗃️</td></tr>\n<tr><td class=c><code>file_cabinet</code></td><td class=e>🗄️</td></tr>\n<tr><td class=c><code>wastebasket</code></td><td class=e>🗑️</td></tr>\n<tr><td class=c><code>lock</code></td><td class=e>🔒</td></tr>\n<tr><td class=c><code>unlock</code></td><td class=e>🔓</td></tr>\n<tr><td class=c><code>lock_with_ink_pen</code></td><td class=e>🔏</td></tr>\n<tr><td class=c><code>closed_lock_with_key</code></td><td class=e>🔐</td></tr>\n<tr><td class=c><code>key</code></td><td class=e>🔑</td></tr>\n<tr><td class=c><code>old_key</code></td><td class=e>🗝️</td></tr>\n<tr><td class=c><code>hammer</code></td><td class=e>🔨</td></tr>\n<tr><td class=c><code>axe</code></td><td class=e>🪓</td></tr>\n<tr><td class=c><code>pick</code></td><td class=e>⛏️</td></tr>\n<tr><td class=c><code>hammer_and_pick</code></td><td class=e>⚒️</td></tr>\n<tr><td class=c><code>hammer_and_wrench</code></td><td class=e>🛠️</td></tr>\n<tr><td class=c><code>dagger</code></td><td class=e>🗡️</td></tr>\n<tr><td class=c><code>crossed_swords</code></td><td class=e>⚔️</td></tr>\n<tr><td class=c><code>gun</code></td><td class=e>🔫</td></tr>\n<tr><td class=c><code>boomerang</code></td><td class=e>🪃</td></tr>\n<tr><td class=c><code>bow_and_arrow</code></td><td class=e>🏹</td></tr>\n<tr><td class=c><code>shield</code></td><td class=e>🛡️</td></tr>\n<tr><td class=c><code>carpentry_saw</code></td><td class=e>🪚</td></tr>\n<tr><td class=c><code>wrench</code></td><td class=e>🔧</td></tr>\n<tr><td class=c><code>screwdriver</code></td><td class=e>🪛</td></tr>\n<tr><td class=c><code>nut_and_bolt</code></td><td class=e>🔩</td></tr>\n<tr><td class=c><code>gear</code></td><td class=e>⚙️</td></tr>\n<tr><td class=c><code>clamp</code></td><td class=e>🗜️</td></tr>\n<tr><td class=c><code>balance_scale</code></td><td class=e>⚖️</td></tr>\n<tr><td class=c><code>probing_cane</code></td><td class=e>🦯</td></tr>\n<tr><td class=c><code>link</code></td><td class=e>🔗</td></tr>\n<tr><td class=c><code>chains</code></td><td class=e>⛓️</td></tr>\n<tr><td class=c><code>hook</code></td><td class=e>🪝</td></tr>\n<tr><td class=c><code>toolbox</code></td><td class=e>🧰</td></tr>\n<tr><td class=c><code>magnet</code></td><td class=e>🧲</td></tr>\n<tr><td class=c><code>ladder</code></td><td class=e>🪜</td></tr>\n<tr><td class=c><code>alembic</code></td><td class=e>⚗️</td></tr>\n<tr><td class=c><code>test_tube</code></td><td class=e>🧪</td></tr>\n<tr><td class=c><code>petri_dish</code></td><td class=e>🧫</td></tr>\n<tr><td class=c><code>dna</code></td><td class=e>🧬</td></tr>\n<tr><td class=c><code>microscope</code></td><td class=e>🔬</td></tr>\n<tr><td class=c><code>telescope</code></td><td class=e>🔭</td></tr>\n<tr><td class=c><code>satellite</code></td><td class=e>📡</td></tr>\n<tr><td class=c><code>syringe</code></td><td class=e>💉</td></tr>\n<tr><td class=c><code>drop_of_blood</code></td><td class=e>🩸</td></tr>\n<tr><td class=c><code>pill</code></td><td class=e>💊</td></tr>\n<tr><td class=c><code>adhesive_bandage</code></td><td class=e>🩹</td></tr>\n<tr><td class=c><code>stethoscope</code></td><td class=e>🩺</td></tr>\n<tr><td class=c><code>door</code></td><td class=e>🚪</td></tr>\n<tr><td class=c><code>elevator</code></td><td class=e>🛗</td></tr>\n<tr><td class=c><code>mirror</code></td><td class=e>🪞</td></tr>\n<tr><td class=c><code>window</code></td><td class=e>🪟</td></tr>\n<tr><td class=c><code>bed</code></td><td class=e>🛏️</td></tr>\n<tr><td class=c><code>couch_and_lamp</code></td><td class=e>🛋️</td></tr>\n<tr><td class=c><code>chair</code></td><td class=e>🪑</td></tr>\n<tr><td class=c><code>toilet</code></td><td class=e>🚽</td></tr>\n<tr><td class=c><code>plunger</code></td><td class=e>🪠</td></tr>\n<tr><td class=c><code>shower</code></td><td class=e>🚿</td></tr>\n<tr><td class=c><code>bathtub</code></td><td class=e>🛁</td></tr>\n<tr><td class=c><code>mouse_trap</code></td><td class=e>🪤</td></tr>\n<tr><td class=c><code>razor</code></td><td class=e>🪒</td></tr>\n<tr><td class=c><code>lotion_bottle</code></td><td class=e>🧴</td></tr>\n<tr><td class=c><code>safety_pin</code></td><td class=e>🧷</td></tr>\n<tr><td class=c><code>broom</code></td><td class=e>🧹</td></tr>\n<tr><td class=c><code>basket</code></td><td class=e>🧺</td></tr>\n<tr><td class=c><code>roll_of_paper</code></td><td class=e>🧻</td></tr>\n<tr><td class=c><code>bucket</code></td><td class=e>🪣</td></tr>\n<tr><td class=c><code>soap</code></td><td class=e>🧼</td></tr>\n<tr><td class=c><code>toothbrush</code></td><td class=e>🪥</td></tr>\n<tr><td class=c><code>sponge</code></td><td class=e>🧽</td></tr>\n<tr><td class=c><code>fire_extinguisher</code></td><td class=e>🧯</td></tr>\n<tr><td class=c><code>shopping_cart</code></td><td class=e>🛒</td></tr>\n<tr><td class=c><code>smoking</code></td><td class=e>🚬</td></tr>\n<tr><td class=c><code>coffin</code></td><td class=e>⚰️</td></tr>\n<tr><td class=c><code>headstone</code></td><td class=e>🪦</td></tr>\n<tr><td class=c><code>funeral_urn</code></td><td class=e>⚱️</td></tr>\n<tr><td class=c><code>moyai</code></td><td class=e>🗿</td></tr>\n<tr><td class=c><code>placard</code></td><td class=e>🪧</td></tr>\n<tr><td class=c><code>atm</code></td><td class=e>🏧</td></tr>\n<tr><td class=c><code>put_litter_in_its_place</code></td><td class=e>🚮</td></tr>\n<tr><td class=c><code>potable_water</code></td><td class=e>🚰</td></tr>\n<tr><td class=c><code>wheelchair</code></td><td class=e>♿</td></tr>\n<tr><td class=c><code>mens</code></td><td class=e>🚹</td></tr>\n<tr><td class=c><code>womens</code></td><td class=e>🚺</td></tr>\n<tr><td class=c><code>restroom</code></td><td class=e>🚻</td></tr>\n<tr><td class=c><code>baby_symbol</code></td><td class=e>🚼</td></tr>\n<tr><td class=c><code>wc</code></td><td class=e>🚾</td></tr>\n<tr><td class=c><code>passport_control</code></td><td class=e>🛂</td></tr>\n<tr><td class=c><code>customs</code></td><td class=e>🛃</td></tr>\n<tr><td class=c><code>baggage_claim</code></td><td class=e>🛄</td></tr>\n<tr><td class=c><code>left_luggage</code></td><td class=e>🛅</td></tr>\n<tr><td class=c><code>warning</code></td><td class=e>⚠️</td></tr>\n<tr><td class=c><code>children_crossing</code></td><td class=e>🚸</td></tr>\n<tr><td class=c><code>no_entry</code></td><td class=e>⛔</td></tr>\n<tr><td class=c><code>no_entry_sign</code></td><td class=e>🚫</td></tr>\n<tr><td class=c><code>no_bicycles</code></td><td class=e>🚳</td></tr>\n<tr><td class=c><code>no_smoking</code></td><td class=e>🚭</td></tr>\n<tr><td class=c><code>do_not_litter</code></td><td class=e>🚯</td></tr>\n<tr><td class=c><code>non-potable_water</code></td><td class=e>🚱</td></tr>\n<tr><td class=c><code>no_pedestrians</code></td><td class=e>🚷</td></tr>\n<tr><td class=c><code>no_mobile_phones</code></td><td class=e>📵</td></tr>\n<tr><td class=c><code>underage</code></td><td class=e>🔞</td></tr>\n<tr><td class=c><code>radioactive</code></td><td class=e>☢️</td></tr>\n<tr><td class=c><code>biohazard</code></td><td class=e>☣️</td></tr>\n<tr><td class=c><code>arrow_up</code></td><td class=e>⬆️</td></tr>\n<tr><td class=c><code>arrow_upper_right</code></td><td class=e>↗️</td></tr>\n<tr><td class=c><code>arrow_right</code></td><td class=e>➡️</td></tr>\n<tr><td class=c><code>arrow_lower_right</code></td><td class=e>↘️</td></tr>\n<tr><td class=c><code>arrow_down</code></td><td class=e>⬇️</td></tr>\n<tr><td class=c><code>arrow_lower_left</code></td><td class=e>↙️</td></tr>\n<tr><td class=c><code>arrow_left</code></td><td class=e>⬅️</td></tr>\n<tr><td class=c><code>arrow_upper_left</code></td><td class=e>↖️</td></tr>\n<tr><td class=c><code>arrow_up_down</code></td><td class=e>↕️</td></tr>\n<tr><td class=c><code>left_right_arrow</code></td><td class=e>↔️</td></tr>\n<tr><td class=c><code>leftwards_arrow_with_hook</code></td><td class=e>↩️</td></tr>\n<tr><td class=c><code>arrow_right_hook</code></td><td class=e>↪️</td></tr>\n<tr><td class=c><code>arrow_heading_up</code></td><td class=e>⤴️</td></tr>\n<tr><td class=c><code>arrow_heading_down</code></td><td class=e>⤵️</td></tr>\n<tr><td class=c><code>arrows_clockwise</code></td><td class=e>🔃</td></tr>\n<tr><td class=c><code>arrows_counterclockwise</code></td><td class=e>🔄</td></tr>\n<tr><td class=c><code>back</code></td><td class=e>🔙</td></tr>\n<tr><td class=c><code>end</code></td><td class=e>🔚</td></tr>\n<tr><td class=c><code>on</code></td><td class=e>🔛</td></tr>\n<tr><td class=c><code>soon</code></td><td class=e>🔜</td></tr>\n<tr><td class=c><code>top</code></td><td class=e>🔝</td></tr>\n<tr><td class=c><code>place_of_worship</code></td><td class=e>🛐</td></tr>\n<tr><td class=c><code>atom_symbol</code></td><td class=e>⚛️</td></tr>\n<tr><td class=c><code>om</code></td><td class=e>🕉️</td></tr>\n<tr><td class=c><code>star_of_david</code></td><td class=e>✡️</td></tr>\n<tr><td class=c><code>wheel_of_dharma</code></td><td class=e>☸️</td></tr>\n<tr><td class=c><code>yin_yang</code></td><td class=e>☯️</td></tr>\n<tr><td class=c><code>latin_cross</code></td><td class=e>✝️</td></tr>\n<tr><td class=c><code>orthodox_cross</code></td><td class=e>☦️</td></tr>\n<tr><td class=c><code>star_and_crescent</code></td><td class=e>☪️</td></tr>\n<tr><td class=c><code>peace_symbol</code></td><td class=e>☮️</td></tr>\n<tr><td class=c><code>menorah</code></td><td class=e>🕎</td></tr>\n<tr><td class=c><code>six_pointed_star</code></td><td class=e>🔯</td></tr>\n<tr><td class=c><code>aries</code></td><td class=e>♈</td></tr>\n<tr><td class=c><code>taurus</code></td><td class=e>♉</td></tr>\n<tr><td class=c><code>gemini</code></td><td class=e>♊</td></tr>\n<tr><td class=c><code>cancer</code></td><td class=e>♋</td></tr>\n<tr><td class=c><code>leo</code></td><td class=e>♌</td></tr>\n<tr><td class=c><code>virgo</code></td><td class=e>♍</td></tr>\n<tr><td class=c><code>libra</code></td><td class=e>♎</td></tr>\n<tr><td class=c><code>scorpius</code></td><td class=e>♏</td></tr>\n<tr><td class=c><code>sagittarius</code></td><td class=e>♐</td></tr>\n<tr><td class=c><code>capricorn</code></td><td class=e>♑</td></tr>\n<tr><td class=c><code>aquarius</code></td><td class=e>♒</td></tr>\n<tr><td class=c><code>pisces</code></td><td class=e>♓</td></tr>\n<tr><td class=c><code>ophiuchus</code></td><td class=e>⛎</td></tr>\n<tr><td class=c><code>twisted_rightwards_arrows</code></td><td class=e>🔀</td></tr>\n<tr><td class=c><code>repeat</code></td><td class=e>🔁</td></tr>\n<tr><td class=c><code>repeat_one</code></td><td class=e>🔂</td></tr>\n<tr><td class=c><code>arrow_forward</code></td><td class=e>▶️</td></tr>\n<tr><td class=c><code>fast_forward</code></td><td class=e>⏩</td></tr>\n<tr><td class=c><code>next_track_button</code></td><td class=e>⏭️</td></tr>\n<tr><td class=c><code>play_or_pause_button</code></td><td class=e>⏯️</td></tr>\n<tr><td class=c><code>arrow_backward</code></td><td class=e>◀️</td></tr>\n<tr><td class=c><code>rewind</code></td><td class=e>⏪</td></tr>\n<tr><td class=c><code>previous_track_button</code></td><td class=e>⏮️</td></tr>\n<tr><td class=c><code>arrow_up_small</code></td><td class=e>🔼</td></tr>\n<tr><td class=c><code>arrow_double_up</code></td><td class=e>⏫</td></tr>\n<tr><td class=c><code>arrow_down_small</code></td><td class=e>🔽</td></tr>\n<tr><td class=c><code>arrow_double_down</code></td><td class=e>⏬</td></tr>\n<tr><td class=c><code>pause_button</code></td><td class=e>⏸️</td></tr>\n<tr><td class=c><code>stop_button</code></td><td class=e>⏹️</td></tr>\n<tr><td class=c><code>record_button</code></td><td class=e>⏺️</td></tr>\n<tr><td class=c><code>eject_button</code></td><td class=e>⏏️</td></tr>\n<tr><td class=c><code>cinema</code></td><td class=e>🎦</td></tr>\n<tr><td class=c><code>low_brightness</code></td><td class=e>🔅</td></tr>\n<tr><td class=c><code>high_brightness</code></td><td class=e>🔆</td></tr>\n<tr><td class=c><code>signal_strength</code></td><td class=e>📶</td></tr>\n<tr><td class=c><code>vibration_mode</code></td><td class=e>📳</td></tr>\n<tr><td class=c><code>mobile_phone_off</code></td><td class=e>📴</td></tr>\n<tr><td class=c><code>female_sign</code></td><td class=e>♀️</td></tr>\n<tr><td class=c><code>male_sign</code></td><td class=e>♂️</td></tr>\n<tr><td class=c><code>transgender_symbol</code></td><td class=e>⚧️</td></tr>\n<tr><td class=c><code>heavy_multiplication_x</code></td><td class=e>✖️</td></tr>\n<tr><td class=c><code>heavy_plus_sign</code></td><td class=e>➕</td></tr>\n<tr><td class=c><code>heavy_minus_sign</code></td><td class=e>➖</td></tr>\n<tr><td class=c><code>heavy_division_sign</code></td><td class=e>➗</td></tr>\n<tr><td class=c><code>infinity</code></td><td class=e>♾️</td></tr>\n<tr><td class=c><code>bangbang</code></td><td class=e>‼️</td></tr>\n<tr><td class=c><code>interrobang</code></td><td class=e>⁉️</td></tr>\n<tr><td class=c><code>question</code></td><td class=e>❓</td></tr>\n<tr><td class=c><code>grey_question</code></td><td class=e>❔</td></tr>\n<tr><td class=c><code>grey_exclamation</code></td><td class=e>❕</td></tr>\n<tr><td class=c><code>exclamation</code></td><td class=e>❗</td></tr>\n<tr><td class=c><code>wavy_dash</code></td><td class=e>〰️</td></tr>\n<tr><td class=c><code>currency_exchange</code></td><td class=e>💱</td></tr>\n<tr><td class=c><code>heavy_dollar_sign</code></td><td class=e>💲</td></tr>\n<tr><td class=c><code>medical_symbol</code></td><td class=e>⚕️</td></tr>\n<tr><td class=c><code>recycle</code></td><td class=e>♻️</td></tr>\n<tr><td class=c><code>fleur_de_lis</code></td><td class=e>⚜️</td></tr>\n<tr><td class=c><code>trident</code></td><td class=e>🔱</td></tr>\n<tr><td class=c><code>name_badge</code></td><td class=e>📛</td></tr>\n<tr><td class=c><code>beginner</code></td><td class=e>🔰</td></tr>\n<tr><td class=c><code>o</code></td><td class=e>⭕</td></tr>\n<tr><td class=c><code>white_check_mark</code></td><td class=e>✅</td></tr>\n<tr><td class=c><code>ballot_box_with_check</code></td><td class=e>☑️</td></tr>\n<tr><td class=c><code>heavy_check_mark</code></td><td class=e>✔️</td></tr>\n<tr><td class=c><code>x</code></td><td class=e>❌</td></tr>\n<tr><td class=c><code>negative_squared_cross_mark</code></td><td class=e>❎</td></tr>\n<tr><td class=c><code>curly_loop</code></td><td class=e>➰</td></tr>\n<tr><td class=c><code>loop</code></td><td class=e>➿</td></tr>\n<tr><td class=c><code>part_alternation_mark</code></td><td class=e>〽️</td></tr>\n<tr><td class=c><code>eight_spoked_asterisk</code></td><td class=e>✳️</td></tr>\n<tr><td class=c><code>eight_pointed_black_star</code></td><td class=e>✴️</td></tr>\n<tr><td class=c><code>sparkle</code></td><td class=e>❇️</td></tr>\n<tr><td class=c><code>copyright</code></td><td class=e>©️</td></tr>\n<tr><td class=c><code>registered</code></td><td class=e>®️</td></tr>\n<tr><td class=c><code>tm</code></td><td class=e>™️</td></tr>\n<tr><td class=c><code>hash</code></td><td class=e>#️⃣</td></tr>\n<tr><td class=c><code>asterisk</code></td><td class=e>*️⃣</td></tr>\n<tr><td class=c><code>zero</code></td><td class=e>0️⃣</td></tr>\n<tr><td class=c><code>one</code></td><td class=e>1️⃣</td></tr>\n<tr><td class=c><code>two</code></td><td class=e>2️⃣</td></tr>\n<tr><td class=c><code>three</code></td><td class=e>3️⃣</td></tr>\n<tr><td class=c><code>four</code></td><td class=e>4️⃣</td></tr>\n<tr><td class=c><code>five</code></td><td class=e>5️⃣</td></tr>\n<tr><td class=c><code>six</code></td><td class=e>6️⃣</td></tr>\n<tr><td class=c><code>seven</code></td><td class=e>7️⃣</td></tr>\n<tr><td class=c><code>eight</code></td><td class=e>8️⃣</td></tr>\n<tr><td class=c><code>nine</code></td><td class=e>9️⃣</td></tr>\n<tr><td class=c><code>keycap_ten</code></td><td class=e>🔟</td></tr>\n<tr><td class=c><code>capital_abcd</code></td><td class=e>🔠</td></tr>\n<tr><td class=c><code>abcd</code></td><td class=e>🔡</td></tr>\n<tr><td class=c><code>1234</code></td><td class=e>🔢</td></tr>\n<tr><td class=c><code>symbols</code></td><td class=e>🔣</td></tr>\n<tr><td class=c><code>abc</code></td><td class=e>🔤</td></tr>\n<tr><td class=c><code>a</code></td><td class=e>🅰️</td></tr>\n<tr><td class=c><code>ab</code></td><td class=e>🆎</td></tr>\n<tr><td class=c><code>b</code></td><td class=e>🅱️</td></tr>\n<tr><td class=c><code>cl</code></td><td class=e>🆑</td></tr>\n<tr><td class=c><code>cool</code></td><td class=e>🆒</td></tr>\n<tr><td class=c><code>free</code></td><td class=e>🆓</td></tr>\n<tr><td class=c><code>information_source</code></td><td class=e>ℹ️</td></tr>\n<tr><td class=c><code>id</code></td><td class=e>🆔</td></tr>\n<tr><td class=c><code>m</code></td><td class=e>Ⓜ️</td></tr>\n<tr><td class=c><code>new</code></td><td class=e>🆕</td></tr>\n<tr><td class=c><code>ng</code></td><td class=e>🆖</td></tr>\n<tr><td class=c><code>o2</code></td><td class=e>🅾️</td></tr>\n<tr><td class=c><code>ok</code></td><td class=e>🆗</td></tr>\n<tr><td class=c><code>parking</code></td><td class=e>🅿️</td></tr>\n<tr><td class=c><code>sos</code></td><td class=e>🆘</td></tr>\n<tr><td class=c><code>up</code></td><td class=e>🆙</td></tr>\n<tr><td class=c><code>vs</code></td><td class=e>🆚</td></tr>\n<tr><td class=c><code>koko</code></td><td class=e>🈁</td></tr>\n<tr><td class=c><code>sa</code></td><td class=e>🈂️</td></tr>\n<tr><td class=c><code>u6708</code></td><td class=e>🈷️</td></tr>\n<tr><td class=c><code>u6709</code></td><td class=e>🈶</td></tr>\n<tr><td class=c><code>u6307</code></td><td class=e>🈯</td></tr>\n<tr><td class=c><code>ideograph_advantage</code></td><td class=e>🉐</td></tr>\n<tr><td class=c><code>u5272</code></td><td class=e>🈹</td></tr>\n<tr><td class=c><code>u7121</code></td><td class=e>🈚</td></tr>\n<tr><td class=c><code>u7981</code></td><td class=e>🈲</td></tr>\n<tr><td class=c><code>accept</code></td><td class=e>🉑</td></tr>\n<tr><td class=c><code>u7533</code></td><td class=e>🈸</td></tr>\n<tr><td class=c><code>u5408</code></td><td class=e>🈴</td></tr>\n<tr><td class=c><code>u7a7a</code></td><td class=e>🈳</td></tr>\n<tr><td class=c><code>congratulations</code></td><td class=e>㊗️</td></tr>\n<tr><td class=c><code>secret</code></td><td class=e>㊙️</td></tr>\n<tr><td class=c><code>u55b6</code></td><td class=e>🈺</td></tr>\n<tr><td class=c><code>u6e80</code></td><td class=e>🈵</td></tr>\n<tr><td class=c><code>red_circle</code></td><td class=e>🔴</td></tr>\n<tr><td class=c><code>orange_circle</code></td><td class=e>🟠</td></tr>\n<tr><td class=c><code>yellow_circle</code></td><td class=e>🟡</td></tr>\n<tr><td class=c><code>green_circle</code></td><td class=e>🟢</td></tr>\n<tr><td class=c><code>large_blue_circle</code></td><td class=e>🔵</td></tr>\n<tr><td class=c><code>purple_circle</code></td><td class=e>🟣</td></tr>\n<tr><td class=c><code>brown_circle</code></td><td class=e>🟤</td></tr>\n<tr><td class=c><code>black_circle</code></td><td class=e>⚫</td></tr>\n<tr><td class=c><code>white_circle</code></td><td class=e>⚪</td></tr>\n<tr><td class=c><code>red_square</code></td><td class=e>🟥</td></tr>\n<tr><td class=c><code>orange_square</code></td><td class=e>🟧</td></tr>\n<tr><td class=c><code>yellow_square</code></td><td class=e>🟨</td></tr>\n<tr><td class=c><code>green_square</code></td><td class=e>🟩</td></tr>\n<tr><td class=c><code>blue_square</code></td><td class=e>🟦</td></tr>\n<tr><td class=c><code>purple_square</code></td><td class=e>🟪</td></tr>\n<tr><td class=c><code>brown_square</code></td><td class=e>🟫</td></tr>\n<tr><td class=c><code>black_large_square</code></td><td class=e>⬛</td></tr>\n<tr><td class=c><code>white_large_square</code></td><td class=e>⬜</td></tr>\n<tr><td class=c><code>black_medium_square</code></td><td class=e>◼️</td></tr>\n<tr><td class=c><code>white_medium_square</code></td><td class=e>◻️</td></tr>\n<tr><td class=c><code>black_medium_small_square</code></td><td class=e>◾</td></tr>\n<tr><td class=c><code>white_medium_small_square</code></td><td class=e>◽</td></tr>\n<tr><td class=c><code>black_small_square</code></td><td class=e>▪️</td></tr>\n<tr><td class=c><code>white_small_square</code></td><td class=e>▫️</td></tr>\n<tr><td class=c><code>large_orange_diamond</code></td><td class=e>🔶</td></tr>\n<tr><td class=c><code>large_blue_diamond</code></td><td class=e>🔷</td></tr>\n<tr><td class=c><code>small_orange_diamond</code></td><td class=e>🔸</td></tr>\n<tr><td class=c><code>small_blue_diamond</code></td><td class=e>🔹</td></tr>\n<tr><td class=c><code>small_red_triangle</code></td><td class=e>🔺</td></tr>\n<tr><td class=c><code>small_red_triangle_down</code></td><td class=e>🔻</td></tr>\n<tr><td class=c><code>diamond_shape_with_a_dot_inside</code></td><td class=e>💠</td></tr>\n<tr><td class=c><code>radio_button</code></td><td class=e>🔘</td></tr>\n<tr><td class=c><code>white_square_button</code></td><td class=e>🔳</td></tr>\n<tr><td class=c><code>black_square_button</code></td><td class=e>🔲</td></tr>\n<tr><td class=c><code>checkered_flag</code></td><td class=e>🏁</td></tr>\n<tr><td class=c><code>triangular_flag_on_post</code></td><td class=e>🚩</td></tr>\n<tr><td class=c><code>crossed_flags</code></td><td class=e>🎌</td></tr>\n<tr><td class=c><code>black_flag</code></td><td class=e>🏴</td></tr>\n<tr><td class=c><code>white_flag</code></td><td class=e>🏳️</td></tr>\n<tr><td class=c><code>rainbow_flag</code></td><td class=e>🏳️‍🌈</td></tr>\n<tr><td class=c><code>transgender_flag</code></td><td class=e>🏳️‍⚧️</td></tr>\n<tr><td class=c><code>pirate_flag</code></td><td class=e>🏴‍☠️</td></tr>\n<tr><td class=c><code>ascension_island</code></td><td class=e>🇦🇨</td></tr>\n<tr><td class=c><code>andorra</code></td><td class=e>🇦🇩</td></tr>\n<tr><td class=c><code>united_arab_emirates</code></td><td class=e>🇦🇪</td></tr>\n<tr><td class=c><code>afghanistan</code></td><td class=e>🇦🇫</td></tr>\n<tr><td class=c><code>antigua_barbuda</code></td><td class=e>🇦🇬</td></tr>\n<tr><td class=c><code>anguilla</code></td><td class=e>🇦🇮</td></tr>\n<tr><td class=c><code>albania</code></td><td class=e>🇦🇱</td></tr>\n<tr><td class=c><code>armenia</code></td><td class=e>🇦🇲</td></tr>\n<tr><td class=c><code>angola</code></td><td class=e>🇦🇴</td></tr>\n<tr><td class=c><code>antarctica</code></td><td class=e>🇦🇶</td></tr>\n<tr><td class=c><code>argentina</code></td><td class=e>🇦🇷</td></tr>\n<tr><td class=c><code>american_samoa</code></td><td class=e>🇦🇸</td></tr>\n<tr><td class=c><code>austria</code></td><td class=e>🇦🇹</td></tr>\n<tr><td class=c><code>australia</code></td><td class=e>🇦🇺</td></tr>\n<tr><td class=c><code>aruba</code></td><td class=e>🇦🇼</td></tr>\n<tr><td class=c><code>aland_islands</code></td><td class=e>🇦🇽</td></tr>\n<tr><td class=c><code>azerbaijan</code></td><td class=e>🇦🇿</td></tr>\n<tr><td class=c><code>bosnia_herzegovina</code></td><td class=e>🇧🇦</td></tr>\n<tr><td class=c><code>barbados</code></td><td class=e>🇧🇧</td></tr>\n<tr><td class=c><code>bangladesh</code></td><td class=e>🇧🇩</td></tr>\n<tr><td class=c><code>belgium</code></td><td class=e>🇧🇪</td></tr>\n<tr><td class=c><code>burkina_faso</code></td><td class=e>🇧🇫</td></tr>\n<tr><td class=c><code>bulgaria</code></td><td class=e>🇧🇬</td></tr>\n<tr><td class=c><code>bahrain</code></td><td class=e>🇧🇭</td></tr>\n<tr><td class=c><code>burundi</code></td><td class=e>🇧🇮</td></tr>\n<tr><td class=c><code>benin</code></td><td class=e>🇧🇯</td></tr>\n<tr><td class=c><code>st_barthelemy</code></td><td class=e>🇧🇱</td></tr>\n<tr><td class=c><code>bermuda</code></td><td class=e>🇧🇲</td></tr>\n<tr><td class=c><code>brunei</code></td><td class=e>🇧🇳</td></tr>\n<tr><td class=c><code>bolivia</code></td><td class=e>🇧🇴</td></tr>\n<tr><td class=c><code>caribbean_netherlands</code></td><td class=e>🇧🇶</td></tr>\n<tr><td class=c><code>brazil</code></td><td class=e>🇧🇷</td></tr>\n<tr><td class=c><code>bahamas</code></td><td class=e>🇧🇸</td></tr>\n<tr><td class=c><code>bhutan</code></td><td class=e>🇧🇹</td></tr>\n<tr><td class=c><code>bouvet_island</code></td><td class=e>🇧🇻</td></tr>\n<tr><td class=c><code>botswana</code></td><td class=e>🇧🇼</td></tr>\n<tr><td class=c><code>belarus</code></td><td class=e>🇧🇾</td></tr>\n<tr><td class=c><code>belize</code></td><td class=e>🇧🇿</td></tr>\n<tr><td class=c><code>canada</code></td><td class=e>🇨🇦</td></tr>\n<tr><td class=c><code>cocos_islands</code></td><td class=e>🇨🇨</td></tr>\n<tr><td class=c><code>congo_kinshasa</code></td><td class=e>🇨🇩</td></tr>\n<tr><td class=c><code>central_african_republic</code></td><td class=e>🇨🇫</td></tr>\n<tr><td class=c><code>congo_brazzaville</code></td><td class=e>🇨🇬</td></tr>\n<tr><td class=c><code>switzerland</code></td><td class=e>🇨🇭</td></tr>\n<tr><td class=c><code>cote_divoire</code></td><td class=e>🇨🇮</td></tr>\n<tr><td class=c><code>cook_islands</code></td><td class=e>🇨🇰</td></tr>\n<tr><td class=c><code>chile</code></td><td class=e>🇨🇱</td></tr>\n<tr><td class=c><code>cameroon</code></td><td class=e>🇨🇲</td></tr>\n<tr><td class=c><code>cn</code></td><td class=e>🇨🇳</td></tr>\n<tr><td class=c><code>colombia</code></td><td class=e>🇨🇴</td></tr>\n<tr><td class=c><code>clipperton_island</code></td><td class=e>🇨🇵</td></tr>\n<tr><td class=c><code>costa_rica</code></td><td class=e>🇨🇷</td></tr>\n<tr><td class=c><code>cuba</code></td><td class=e>🇨🇺</td></tr>\n<tr><td class=c><code>cape_verde</code></td><td class=e>🇨🇻</td></tr>\n<tr><td class=c><code>curacao</code></td><td class=e>🇨🇼</td></tr>\n<tr><td class=c><code>christmas_island</code></td><td class=e>🇨🇽</td></tr>\n<tr><td class=c><code>cyprus</code></td><td class=e>🇨🇾</td></tr>\n<tr><td class=c><code>czech_republic</code></td><td class=e>🇨🇿</td></tr>\n<tr><td class=c><code>de</code></td><td class=e>🇩🇪</td></tr>\n<tr><td class=c><code>diego_garcia</code></td><td class=e>🇩🇬</td></tr>\n<tr><td class=c><code>djibouti</code></td><td class=e>🇩🇯</td></tr>\n<tr><td class=c><code>denmark</code></td><td class=e>🇩🇰</td></tr>\n<tr><td class=c><code>dominica</code></td><td class=e>🇩🇲</td></tr>\n<tr><td class=c><code>dominican_republic</code></td><td class=e>🇩🇴</td></tr>\n<tr><td class=c><code>algeria</code></td><td class=e>🇩🇿</td></tr>\n<tr><td class=c><code>ceuta_melilla</code></td><td class=e>🇪🇦</td></tr>\n<tr><td class=c><code>ecuador</code></td><td class=e>🇪🇨</td></tr>\n<tr><td class=c><code>estonia</code></td><td class=e>🇪🇪</td></tr>\n<tr><td class=c><code>egypt</code></td><td class=e>🇪🇬</td></tr>\n<tr><td class=c><code>western_sahara</code></td><td class=e>🇪🇭</td></tr>\n<tr><td class=c><code>eritrea</code></td><td class=e>🇪🇷</td></tr>\n<tr><td class=c><code>es</code></td><td class=e>🇪🇸</td></tr>\n<tr><td class=c><code>ethiopia</code></td><td class=e>🇪🇹</td></tr>\n<tr><td class=c><code>eu</code></td><td class=e>🇪🇺</td></tr>\n<tr><td class=c><code>finland</code></td><td class=e>🇫🇮</td></tr>\n<tr><td class=c><code>fiji</code></td><td class=e>🇫🇯</td></tr>\n<tr><td class=c><code>falkland_islands</code></td><td class=e>🇫🇰</td></tr>\n<tr><td class=c><code>micronesia</code></td><td class=e>🇫🇲</td></tr>\n<tr><td class=c><code>faroe_islands</code></td><td class=e>🇫🇴</td></tr>\n<tr><td class=c><code>fr</code></td><td class=e>🇫🇷</td></tr>\n<tr><td class=c><code>gabon</code></td><td class=e>🇬🇦</td></tr>\n<tr><td class=c><code>gb</code></td><td class=e>🇬🇧</td></tr>\n<tr><td class=c><code>grenada</code></td><td class=e>🇬🇩</td></tr>\n<tr><td class=c><code>georgia</code></td><td class=e>🇬🇪</td></tr>\n<tr><td class=c><code>french_guiana</code></td><td class=e>🇬🇫</td></tr>\n<tr><td class=c><code>guernsey</code></td><td class=e>🇬🇬</td></tr>\n<tr><td class=c><code>ghana</code></td><td class=e>🇬🇭</td></tr>\n<tr><td class=c><code>gibraltar</code></td><td class=e>🇬🇮</td></tr>\n<tr><td class=c><code>greenland</code></td><td class=e>🇬🇱</td></tr>\n<tr><td class=c><code>gambia</code></td><td class=e>🇬🇲</td></tr>\n<tr><td class=c><code>guinea</code></td><td class=e>🇬🇳</td></tr>\n<tr><td class=c><code>guadeloupe</code></td><td class=e>🇬🇵</td></tr>\n<tr><td class=c><code>equatorial_guinea</code></td><td class=e>🇬🇶</td></tr>\n<tr><td class=c><code>greece</code></td><td class=e>🇬🇷</td></tr>\n<tr><td class=c><code>south_georgia_south_sandwich_islands</code></td><td class=e>🇬🇸</td></tr>\n<tr><td class=c><code>guatemala</code></td><td class=e>🇬🇹</td></tr>\n<tr><td class=c><code>guam</code></td><td class=e>🇬🇺</td></tr>\n<tr><td class=c><code>guinea_bissau</code></td><td class=e>🇬🇼</td></tr>\n<tr><td class=c><code>guyana</code></td><td class=e>🇬🇾</td></tr>\n<tr><td class=c><code>hong_kong</code></td><td class=e>🇭🇰</td></tr>\n<tr><td class=c><code>heard_mcdonald_islands</code></td><td class=e>🇭🇲</td></tr>\n<tr><td class=c><code>honduras</code></td><td class=e>🇭🇳</td></tr>\n<tr><td class=c><code>croatia</code></td><td class=e>🇭🇷</td></tr>\n<tr><td class=c><code>haiti</code></td><td class=e>🇭🇹</td></tr>\n<tr><td class=c><code>hungary</code></td><td class=e>🇭🇺</td></tr>\n<tr><td class=c><code>canary_islands</code></td><td class=e>🇮🇨</td></tr>\n<tr><td class=c><code>indonesia</code></td><td class=e>🇮🇩</td></tr>\n<tr><td class=c><code>ireland</code></td><td class=e>🇮🇪</td></tr>\n<tr><td class=c><code>israel</code></td><td class=e>🇮🇱</td></tr>\n<tr><td class=c><code>isle_of_man</code></td><td class=e>🇮🇲</td></tr>\n<tr><td class=c><code>india</code></td><td class=e>🇮🇳</td></tr>\n<tr><td class=c><code>british_indian_ocean_territory</code></td><td class=e>🇮🇴</td></tr>\n<tr><td class=c><code>iraq</code></td><td class=e>🇮🇶</td></tr>\n<tr><td class=c><code>iran</code></td><td class=e>🇮🇷</td></tr>\n<tr><td class=c><code>iceland</code></td><td class=e>🇮🇸</td></tr>\n<tr><td class=c><code>it</code></td><td class=e>🇮🇹</td></tr>\n<tr><td class=c><code>jersey</code></td><td class=e>🇯🇪</td></tr>\n<tr><td class=c><code>jamaica</code></td><td class=e>🇯🇲</td></tr>\n<tr><td class=c><code>jordan</code></td><td class=e>🇯🇴</td></tr>\n<tr><td class=c><code>jp</code></td><td class=e>🇯🇵</td></tr>\n<tr><td class=c><code>kenya</code></td><td class=e>🇰🇪</td></tr>\n<tr><td class=c><code>kyrgyzstan</code></td><td class=e>🇰🇬</td></tr>\n<tr><td class=c><code>cambodia</code></td><td class=e>🇰🇭</td></tr>\n<tr><td class=c><code>kiribati</code></td><td class=e>🇰🇮</td></tr>\n<tr><td class=c><code>comoros</code></td><td class=e>🇰🇲</td></tr>\n<tr><td class=c><code>st_kitts_nevis</code></td><td class=e>🇰🇳</td></tr>\n<tr><td class=c><code>north_korea</code></td><td class=e>🇰🇵</td></tr>\n<tr><td class=c><code>kr</code></td><td class=e>🇰🇷</td></tr>\n<tr><td class=c><code>kuwait</code></td><td class=e>🇰🇼</td></tr>\n<tr><td class=c><code>cayman_islands</code></td><td class=e>🇰🇾</td></tr>\n<tr><td class=c><code>kazakhstan</code></td><td class=e>🇰🇿</td></tr>\n<tr><td class=c><code>laos</code></td><td class=e>🇱🇦</td></tr>\n<tr><td class=c><code>lebanon</code></td><td class=e>🇱🇧</td></tr>\n<tr><td class=c><code>st_lucia</code></td><td class=e>🇱🇨</td></tr>\n<tr><td class=c><code>liechtenstein</code></td><td class=e>🇱🇮</td></tr>\n<tr><td class=c><code>sri_lanka</code></td><td class=e>🇱🇰</td></tr>\n<tr><td class=c><code>liberia</code></td><td class=e>🇱🇷</td></tr>\n<tr><td class=c><code>lesotho</code></td><td class=e>🇱🇸</td></tr>\n<tr><td class=c><code>lithuania</code></td><td class=e>🇱🇹</td></tr>\n<tr><td class=c><code>luxembourg</code></td><td class=e>🇱🇺</td></tr>\n<tr><td class=c><code>latvia</code></td><td class=e>🇱🇻</td></tr>\n<tr><td class=c><code>libya</code></td><td class=e>🇱🇾</td></tr>\n<tr><td class=c><code>morocco</code></td><td class=e>🇲🇦</td></tr>\n<tr><td class=c><code>monaco</code></td><td class=e>🇲🇨</td></tr>\n<tr><td class=c><code>moldova</code></td><td class=e>🇲🇩</td></tr>\n<tr><td class=c><code>montenegro</code></td><td class=e>🇲🇪</td></tr>\n<tr><td class=c><code>st_martin</code></td><td class=e>🇲🇫</td></tr>\n<tr><td class=c><code>madagascar</code></td><td class=e>🇲🇬</td></tr>\n<tr><td class=c><code>marshall_islands</code></td><td class=e>🇲🇭</td></tr>\n<tr><td class=c><code>macedonia</code></td><td class=e>🇲🇰</td></tr>\n<tr><td class=c><code>mali</code></td><td class=e>🇲🇱</td></tr>\n<tr><td class=c><code>myanmar</code></td><td class=e>🇲🇲</td></tr>\n<tr><td class=c><code>mongolia</code></td><td class=e>🇲🇳</td></tr>\n<tr><td class=c><code>macau</code></td><td class=e>🇲🇴</td></tr>\n<tr><td class=c><code>northern_mariana_islands</code></td><td class=e>🇲🇵</td></tr>\n<tr><td class=c><code>martinique</code></td><td class=e>🇲🇶</td></tr>\n<tr><td class=c><code>mauritania</code></td><td class=e>🇲🇷</td></tr>\n<tr><td class=c><code>montserrat</code></td><td class=e>🇲🇸</td></tr>\n<tr><td class=c><code>malta</code></td><td class=e>🇲🇹</td></tr>\n<tr><td class=c><code>mauritius</code></td><td class=e>🇲🇺</td></tr>\n<tr><td class=c><code>maldives</code></td><td class=e>🇲🇻</td></tr>\n<tr><td class=c><code>malawi</code></td><td class=e>🇲🇼</td></tr>\n<tr><td class=c><code>mexico</code></td><td class=e>🇲🇽</td></tr>\n<tr><td class=c><code>malaysia</code></td><td class=e>🇲🇾</td></tr>\n<tr><td class=c><code>mozambique</code></td><td class=e>🇲🇿</td></tr>\n<tr><td class=c><code>namibia</code></td><td class=e>🇳🇦</td></tr>\n<tr><td class=c><code>new_caledonia</code></td><td class=e>🇳🇨</td></tr>\n<tr><td class=c><code>niger</code></td><td class=e>🇳🇪</td></tr>\n<tr><td class=c><code>norfolk_island</code></td><td class=e>🇳🇫</td></tr>\n<tr><td class=c><code>nigeria</code></td><td class=e>🇳🇬</td></tr>\n<tr><td class=c><code>nicaragua</code></td><td class=e>🇳🇮</td></tr>\n<tr><td class=c><code>netherlands</code></td><td class=e>🇳🇱</td></tr>\n<tr><td class=c><code>norway</code></td><td class=e>🇳🇴</td></tr>\n<tr><td class=c><code>nepal</code></td><td class=e>🇳🇵</td></tr>\n<tr><td class=c><code>nauru</code></td><td class=e>🇳🇷</td></tr>\n<tr><td class=c><code>niue</code></td><td class=e>🇳🇺</td></tr>\n<tr><td class=c><code>new_zealand</code></td><td class=e>🇳🇿</td></tr>\n<tr><td class=c><code>oman</code></td><td class=e>🇴🇲</td></tr>\n<tr><td class=c><code>panama</code></td><td class=e>🇵🇦</td></tr>\n<tr><td class=c><code>peru</code></td><td class=e>🇵🇪</td></tr>\n<tr><td class=c><code>french_polynesia</code></td><td class=e>🇵🇫</td></tr>\n<tr><td class=c><code>papua_new_guinea</code></td><td class=e>🇵🇬</td></tr>\n<tr><td class=c><code>philippines</code></td><td class=e>🇵🇭</td></tr>\n<tr><td class=c><code>pakistan</code></td><td class=e>🇵🇰</td></tr>\n<tr><td class=c><code>poland</code></td><td class=e>🇵🇱</td></tr>\n<tr><td class=c><code>st_pierre_miquelon</code></td><td class=e>🇵🇲</td></tr>\n<tr><td class=c><code>pitcairn_islands</code></td><td class=e>🇵🇳</td></tr>\n<tr><td class=c><code>puerto_rico</code></td><td class=e>🇵🇷</td></tr>\n<tr><td class=c><code>palestinian_territories</code></td><td class=e>🇵🇸</td></tr>\n<tr><td class=c><code>portugal</code></td><td class=e>🇵🇹</td></tr>\n<tr><td class=c><code>palau</code></td><td class=e>🇵🇼</td></tr>\n<tr><td class=c><code>paraguay</code></td><td class=e>🇵🇾</td></tr>\n<tr><td class=c><code>qatar</code></td><td class=e>🇶🇦</td></tr>\n<tr><td class=c><code>reunion</code></td><td class=e>🇷🇪</td></tr>\n<tr><td class=c><code>romania</code></td><td class=e>🇷🇴</td></tr>\n<tr><td class=c><code>serbia</code></td><td class=e>🇷🇸</td></tr>\n<tr><td class=c><code>ru</code></td><td class=e>🇷🇺</td></tr>\n<tr><td class=c><code>rwanda</code></td><td class=e>🇷🇼</td></tr>\n<tr><td class=c><code>saudi_arabia</code></td><td class=e>🇸🇦</td></tr>\n<tr><td class=c><code>solomon_islands</code></td><td class=e>🇸🇧</td></tr>\n<tr><td class=c><code>seychelles</code></td><td class=e>🇸🇨</td></tr>\n<tr><td class=c><code>sudan</code></td><td class=e>🇸🇩</td></tr>\n<tr><td class=c><code>sweden</code></td><td class=e>🇸🇪</td></tr>\n<tr><td class=c><code>singapore</code></td><td class=e>🇸🇬</td></tr>\n<tr><td class=c><code>st_helena</code></td><td class=e>🇸🇭</td></tr>\n<tr><td class=c><code>slovenia</code></td><td class=e>🇸🇮</td></tr>\n<tr><td class=c><code>svalbard_jan_mayen</code></td><td class=e>🇸🇯</td></tr>\n<tr><td class=c><code>slovakia</code></td><td class=e>🇸🇰</td></tr>\n<tr><td class=c><code>sierra_leone</code></td><td class=e>🇸🇱</td></tr>\n<tr><td class=c><code>san_marino</code></td><td class=e>🇸🇲</td></tr>\n<tr><td class=c><code>senegal</code></td><td class=e>🇸🇳</td></tr>\n<tr><td class=c><code>somalia</code></td><td class=e>🇸🇴</td></tr>\n<tr><td class=c><code>suriname</code></td><td class=e>🇸🇷</td></tr>\n<tr><td class=c><code>south_sudan</code></td><td class=e>🇸🇸</td></tr>\n<tr><td class=c><code>sao_tome_principe</code></td><td class=e>🇸🇹</td></tr>\n<tr><td class=c><code>el_salvador</code></td><td class=e>🇸🇻</td></tr>\n<tr><td class=c><code>sint_maarten</code></td><td class=e>🇸🇽</td></tr>\n<tr><td class=c><code>syria</code></td><td class=e>🇸🇾</td></tr>\n<tr><td class=c><code>swaziland</code></td><td class=e>🇸🇿</td></tr>\n<tr><td class=c><code>tristan_da_cunha</code></td><td class=e>🇹🇦</td></tr>\n<tr><td class=c><code>turks_caicos_islands</code></td><td class=e>🇹🇨</td></tr>\n<tr><td class=c><code>chad</code></td><td class=e>🇹🇩</td></tr>\n<tr><td class=c><code>french_southern_territories</code></td><td class=e>🇹🇫</td></tr>\n<tr><td class=c><code>togo</code></td><td class=e>🇹🇬</td></tr>\n<tr><td class=c><code>thailand</code></td><td class=e>🇹🇭</td></tr>\n<tr><td class=c><code>tajikistan</code></td><td class=e>🇹🇯</td></tr>\n<tr><td class=c><code>tokelau</code></td><td class=e>🇹🇰</td></tr>\n<tr><td class=c><code>timor_leste</code></td><td class=e>🇹🇱</td></tr>\n<tr><td class=c><code>turkmenistan</code></td><td class=e>🇹🇲</td></tr>\n<tr><td class=c><code>tunisia</code></td><td class=e>🇹🇳</td></tr>\n<tr><td class=c><code>tonga</code></td><td class=e>🇹🇴</td></tr>\n<tr><td class=c><code>tr</code></td><td class=e>🇹🇷</td></tr>\n<tr><td class=c><code>trinidad_tobago</code></td><td class=e>🇹🇹</td></tr>\n<tr><td class=c><code>tuvalu</code></td><td class=e>🇹🇻</td></tr>\n<tr><td class=c><code>taiwan</code></td><td class=e>🇹🇼</td></tr>\n<tr><td class=c><code>tanzania</code></td><td class=e>🇹🇿</td></tr>\n<tr><td class=c><code>ukraine</code></td><td class=e>🇺🇦</td></tr>\n<tr><td class=c><code>uganda</code></td><td class=e>🇺🇬</td></tr>\n<tr><td class=c><code>us_outlying_islands</code></td><td class=e>🇺🇲</td></tr>\n<tr><td class=c><code>united_nations</code></td><td class=e>🇺🇳</td></tr>\n<tr><td class=c><code>us</code></td><td class=e>🇺🇸</td></tr>\n<tr><td class=c><code>uruguay</code></td><td class=e>🇺🇾</td></tr>\n<tr><td class=c><code>uzbekistan</code></td><td class=e>🇺🇿</td></tr>\n<tr><td class=c><code>vatican_city</code></td><td class=e>🇻🇦</td></tr>\n<tr><td class=c><code>st_vincent_grenadines</code></td><td class=e>🇻🇨</td></tr>\n<tr><td class=c><code>venezuela</code></td><td class=e>🇻🇪</td></tr>\n<tr><td class=c><code>british_virgin_islands</code></td><td class=e>🇻🇬</td></tr>\n<tr><td class=c><code>us_virgin_islands</code></td><td class=e>🇻🇮</td></tr>\n<tr><td class=c><code>vietnam</code></td><td class=e>🇻🇳</td></tr>\n<tr><td class=c><code>vanuatu</code></td><td class=e>🇻🇺</td></tr>\n<tr><td class=c><code>wallis_futuna</code></td><td class=e>🇼🇫</td></tr>\n<tr><td class=c><code>samoa</code></td><td class=e>🇼🇸</td></tr>\n<tr><td class=c><code>kosovo</code></td><td class=e>🇽🇰</td></tr>\n<tr><td class=c><code>yemen</code></td><td class=e>🇾🇪</td></tr>\n<tr><td class=c><code>mayotte</code></td><td class=e>🇾🇹</td></tr>\n<tr><td class=c><code>south_africa</code></td><td class=e>🇿🇦</td></tr>\n<tr><td class=c><code>zambia</code></td><td class=e>🇿🇲</td></tr>\n<tr><td class=c><code>zimbabwe</code></td><td class=e>🇿🇼</td></tr>\n<tr><td class=c><code>england</code></td><td class=e>🏴󠁧󠁢󠁥󠁮󠁧󠁿</td></tr>\n<tr><td class=c><code>scotland</code></td><td class=e>🏴󠁧󠁢󠁳󠁣󠁴󠁿</td></tr>\n<tr><td class=c><code>wales</code></td><td class=e>🏴󠁧󠁢󠁷󠁬󠁳󠁿</td></tr>\n</tbody></table></td>\n</tr></table>\n"
  },
  {
    "path": "docs/examples.md",
    "content": "# Examples\n\nThere are a million ways to use ntfy, but here are some inspirations. I try to collect\n<a href=\"https://github.com/binwiederhier/ntfy/tree/main/examples\">examples on GitHub</a>, so be sure to check\nthose out, too.\n\n!!! info\n    Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please\n    [create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that\n    I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.\n\n## Cronjobs\nntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...).\n\nI started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call\ndirectly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>\nor ⚠️ <i>Laptop backup failed</i> directly to my phone:\n\n``` bash\nrsync -a root@laptop /backups/laptop \\\n  && zfs snapshot ... \\\n  && curl -H prio:low -d \"Laptop backup succeeded\" ntfy.sh/backups \\\n  || curl -H tags:warning -H prio:high -d \"Laptop backup failed\" ntfy.sh/backups\n```\n\nHere's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with\nGitHub have been hopeless. In case it ever becomes available, I want to know immediately.\n\n```\n# Check github/ntfy user\n*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep \"Not Found\"; then curl -d \"github.com/ntfy is available\" -H \"Tags: tada\" -H \"Prio: high\" ntfy.sh/my-alerts; fi\n```\n\nYou can also use [`ntfy-run`](https://github.com/quantum5/ntfy-run) to send the output of your cronjob in the\nnotification, so that you know exactly why it failed:\n\n```\n0 0 * * * ntfy-run -n https://ntfy.sh/backups --success-priority low --failure-tags warning ~/backup-computer\n```\n\n## Low disk space alerts\nHere's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but \neffective. \n\n``` bash \n#!/bin/bash\n\nmingigs=10\navail=$(df | awk '$6 == \"/\" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }')\ntopicurl=https://ntfy.sh/mytopic\n\nif [ -n \"$avail\" ]; then\n  curl \\\n    -d \"Only $avail GB available on the root disk. Better clean that up.\" \\\n    -H \"Title: Low disk space alert on $(hostname)\" \\\n    -H \"Priority: high\" \\\n    -H \"Tags: warning,cd\" \\\n    $topicurl\nfi\n```\n\n## SSH login alerts\nYears ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I\nown, I now message myself. Here's an example of how to use <a href=\"https://en.wikipedia.org/wiki/Linux_PAM\">PAM</a>\nto notify yourself on SSH login.\n\n=== \"/etc/pam.d/sshd\"\n    ```\n    # at the end of the file\n    session optional pam_exec.so /usr/bin/ntfy-ssh-login.sh\n    ```\n\n=== \"/usr/bin/ntfy-ssh-login.sh\"\n    ```bash\n    #!/bin/bash\n    if [ \"${PAM_TYPE}\" = \"open_session\" ]; then\n      curl \\\n        -H prio:high \\\n        -H tags:warning \\\n        -d \"SSH login: ${PAM_USER} from ${PAM_RHOST}\" \\\n        ntfy.sh/alerts\n    fi\n    ```\n\n## Collect data from multiple machines\nThe other day I was running tasks on 20 servers, and I wanted to collect the interim results\nas a CSV in one place. Each of the servers was publishing to a topic as the results completed (`publish-result.sh`), \nand I had one central collector to grab the results as they came in (`collect-results.sh`).\n\nIt looked something like this:\n\n=== \"collect-results.sh\"\n    ```bash\n    while read result; do\n      [ -n \"$result\" ] && echo \"$result\" >> results.csv\n    done < <(stdbuf -i0 -o0 curl -s ntfy.sh/results/raw)\n    ```\n=== \"publish-result.sh\" \n    ```bash\n    // This script was run on each of the 20 servers. It was doing heavy processing ...\n    \n    // Publish script results\n    curl -d \"$(hostname),$count,$time\" ntfy.sh/results\n    ```\n\n## Ansible, Salt and Puppet\nYou can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated.\nOne of my co-workers uses the following Ansible task to let him know when things are done:\n\n``` yaml\n- name: Send ntfy.sh update\n  uri:\n    url: \"https://ntfy.sh/{{ ntfy_channel }}\"\n    method: POST\n    body: \"{{ inventory_hostname }} reseeding complete\"\n```\n\nThere's also a dedicated Ansible action plugin (one which runs on the Ansible controller) called\n[ansible-ntfy](https://github.com/jpmens/ansible-ntfy). The following task posts a message\nto ntfy at its default URL (`attrs` and other attributes are optional):\n\n``` yaml\n- name: \"Notify ntfy that we're done\"\n  ntfy:\n       msg: \"deployment on {{ inventory_hostname }} is complete. 🐄\"\n       attrs:\n          tags: [ heavy_check_mark ]\n          priority: 1\n```\n\n## GitHub Actions\nYou can send a message during a workflow run with curl. Here is an example sending info about the repo, commit and job status.\n``` yaml\n- name: Actions Ntfy\n  run: |\n    curl \\\n      -u ${{ secrets.NTFY_CRED }} \\\n      -H \"Title: Title here\" \\\n      -H \"Content-Type: text/plain\" \\\n      -d $'Repo: ${{ github.repository }}\\nCommit: ${{ github.sha }}\\nRef: ${{ github.ref }}\\nStatus: ${{ job.status}}' \\\n      ${{ secrets.NTFY_URL }}\n```\n\n## Changedetection.io\nntfy is an excellent choice for getting notifications when a website has a change sent to your mobile (or desktop), \n[changedetection.io](https://changedetection.io) or on GitHub ([dgtlmoon/changedetection.io](https://github.com/dgtlmoon/changedetection.io)) \nuses [apprise](https://github.com/caronc/apprise) library for notification integrations.\n\nTo add any ntfy(s) notification to a website change simply add the [ntfy style URL](https://github.com/caronc/apprise/wiki/Notify_ntfy) \nto the notification list.\n\nFor example `ntfy://{topic}` or `ntfy://{user}:{password}@{host}:{port}/{topics}`\n\nIn your changedetection.io installation, click `Edit` > `Notifications` on a single website watch (or group) then add \nthe special ntfy Apprise Notification URL to the Notification List.\n\n![ntfy alerts on website change](static/img/cdio-setup.jpg)\n\n## Watchtower (shoutrrr)\nYou can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send \n[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.\n\nExample docker-compose.yml:\n\n``` yaml\nservices:\n  watchtower:\n    image: containrrr/watchtower\n    environment:\n      - WATCHTOWER_NOTIFICATION_SKIP_TITLE=True\n      - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates\n```\n\nThe environment variable `WATCHTOWER_NOTIFICATION_SKIP_TITLE` is required to prevent Watchtower from [replacing the `title` query parameter](https://containrrr.dev/watchtower/notifications/#settings). If omitted, the provided notification title will not be used.\n\nOr, if you only want to send notifications using shoutrrr:\n```\nshoutrrr send -u \"ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates\" -m \"testMessage\"\n```\n\nAuthentication tokens are also supported:\n\n- (Recommended) Ntfy url format (replace the domain, topic and token with your own):\n```\nntfy://:TOKEN@DOMAIN/TOPIC\n```\n\n- Generic webhook and authorization header using this url format (replace the domain, topic and token with your own):\n\n```\ngeneric+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`\n```\n\n## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd\n\n<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->\n\nRadarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect.\n\nSonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc.\nSome simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts).\n\n## Node-RED\nYou can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:\n\n<details>\n<summary>Example: Send a message (click to expand)</summary>\n\n``` json\n[\n    {\n        \"id\": \"c956e688cc74ad8e\",\n        \"type\": \"http request\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"ntfy.sh\",\n        \"method\": \"POST\",\n        \"ret\": \"txt\",\n        \"paytoqs\": \"ignore\",\n        \"url\": \"https://ntfy.sh/mytopic\",\n        \"tls\": \"\",\n        \"persist\": false,\n        \"proxy\": \"\",\n        \"authType\": \"\",\n        \"senderr\": false,\n        \"credentials\":\n        {\n            \"user\": \"\",\n            \"password\": \"\"\n        },\n        \"x\": 590,\n        \"y\": 3160,\n        \"wires\":\n        [\n            []\n        ]\n    },\n    {\n        \"id\": \"32ee1eade51fae50\",\n        \"type\": \"function\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"data\",\n        \"func\": \"msg.payload = \\\"Something happened\\\";\\nmsg.headers = {};\\nmsg.headers['tags'] = 'house';\\nmsg.headers['X-Title'] = 'Home Assistant';\\n\\nreturn msg;\",\n        \"outputs\": 1,\n        \"noerr\": 0,\n        \"initialize\": \"\",\n        \"finalize\": \"\",\n        \"libs\": [],\n        \"x\": 470,\n        \"y\": 3160,\n        \"wires\":\n        [\n            [\n                \"c956e688cc74ad8e\"\n            ]\n        ]\n    },\n    {\n        \"id\": \"b287e59cd2311815\",\n        \"type\": \"inject\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"Manual start\",\n        \"props\":\n        [\n            {\n                \"p\": \"payload\"\n            },\n            {\n                \"p\": \"topic\",\n                \"vt\": \"str\"\n            }\n        ],\n        \"repeat\": \"\",\n        \"crontab\": \"\",\n        \"once\": false,\n        \"onceDelay\": \"20\",\n        \"topic\": \"\",\n        \"payload\": \"\",\n        \"payloadType\": \"date\",\n        \"x\": 330,\n        \"y\": 3160,\n        \"wires\":\n        [\n            [\n                \"32ee1eade51fae50\"\n            ]\n        ]\n    }\n]\n```\n\n</details>\n\n![Node red message flow](static/img/nodered-message.png)\n\n<details>\n<summary>Example: Send a picture (click to expand)</summary>\n\n``` json\n[\n    {\n        \"id\": \"d135a13eadeb9d6d\",\n        \"type\": \"http request\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"Download image\",\n        \"method\": \"GET\",\n        \"ret\": \"bin\",\n        \"paytoqs\": \"ignore\",\n        \"url\": \"https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png\",\n        \"tls\": \"\",\n        \"persist\": false,\n        \"proxy\": \"\",\n        \"authType\": \"\",\n        \"senderr\": false,\n        \"credentials\":\n        {\n            \"user\": \"\",\n            \"password\": \"\"\n        },\n        \"x\": 490,\n        \"y\": 3320,\n        \"wires\":\n        [\n            [\n                \"6e75bc41d2ec4a03\"\n            ]\n        ]\n    },\n    {\n        \"id\": \"6e75bc41d2ec4a03\",\n        \"type\": \"function\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"data\",\n        \"func\": \"msg.payload = msg.payload;\\nmsg.headers = {};\\nmsg.headers['tags'] = 'house';\\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\\n\\nreturn msg;\",\n        \"outputs\": 1,\n        \"noerr\": 0,\n        \"initialize\": \"\",\n        \"finalize\": \"\",\n        \"libs\": [],\n        \"x\": 650,\n        \"y\": 3320,\n        \"wires\":\n        [\n            [\n                \"eb160615b6ceda98\"\n            ]\n        ]\n    },\n    {\n        \"id\": \"eb160615b6ceda98\",\n        \"type\": \"http request\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"ntfy.sh\",\n        \"method\": \"PUT\",\n        \"ret\": \"bin\",\n        \"paytoqs\": \"ignore\",\n        \"url\": \"https://ntfy.sh/mytopic\",\n        \"tls\": \"\",\n        \"persist\": false,\n        \"proxy\": \"\",\n        \"authType\": \"\",\n        \"senderr\": false,\n        \"credentials\":\n        {\n            \"user\": \"\",\n            \"password\": \"\"\n        },\n        \"x\": 770,\n        \"y\": 3320,\n        \"wires\":\n        [\n            []\n        ]\n    },\n    {\n        \"id\": \"5b8dbf15c8a7a3a5\",\n        \"type\": \"inject\",\n        \"z\": \"fabdd7a3.4045a\",\n        \"name\": \"Manual start\",\n        \"props\":\n        [\n            {\n                \"p\": \"payload\"\n            },\n            {\n                \"p\": \"topic\",\n                \"vt\": \"str\"\n            }\n        ],\n        \"repeat\": \"\",\n        \"crontab\": \"\",\n        \"once\": false,\n        \"onceDelay\": \"20\",\n        \"topic\": \"\",\n        \"payload\": \"\",\n        \"payloadType\": \"date\",\n        \"x\": 310,\n        \"y\": 3320,\n        \"wires\":\n        [\n            [\n                \"d135a13eadeb9d6d\"\n            ]\n        ]\n    }\n]\n```\n\n</details>\n\n![Node red picture flow](static/img/nodered-picture.png)\n\n## Gatus\nTo use ntfy with [Gatus](https://github.com/TwiN/gatus), you can use the `ntfy` alerting provider like so:\n\n```yaml\nalerting:\n  ntfy:\n    url: \"https://ntfy.sh\"\n    topic: \"YOUR_NTFY_TOPIC\"\n    priority: 3\n```\n\nFor more information on using ntfy with Gatus, refer to [Configuring ntfy alerts](https://github.com/TwiN/gatus#configuring-ntfy-alerts).\n\n<details>\n  <summary>Alternative: Using the custom alerting provider</summary>\n\n```yaml\nalerting:\n  custom:\n    url: \"https://ntfy.sh\"\n    method: \"POST\"\n    body: |\n      {\n        \"topic\": \"mytopic\",\n        \"message\": \"[ENDPOINT_NAME] - [ALERT_DESCRIPTION]\",\n        \"title\": \"Gatus\",\n        \"tags\": [\"[ALERT_TRIGGERED_OR_RESOLVED]\"],\n        \"priority\": 3\n      }\n    default-alert:\n      enabled: true\n      description: \"health check failed\"\n      send-on-resolved: true\n      failure-threshold: 3\n      success-threshold: 3\n    placeholders:\n      ALERT_TRIGGERED_OR_RESOLVED:\n        TRIGGERED: \"warning\"\n        RESOLVED: \"white_check_mark\"\n```\n\n</details>\n\n\n## Jellyseerr/Overseerr webhook\nHere is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook\nJSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click. \nAnd if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.\n\n``` json\n{\n    \"topic\": \"requests\",\n    \"title\": \"{{event}}\",\n    \"message\": \"{{subject}}\\n{{message}}\\n\\nRequested by: {{requestedBy_username}}\\n\\nStatus: {{media_status}}\\nRequest Id: {{request_id}}\",\n    \"priority\": 4,\n    \"attach\": \"{{image}}\",\n    \"click\": \"https://requests.example.com/{{media_type}}/{{media_tmdbid}}\"\n}\n```\n\n## Home Assistant\nHere is an example for the configuration.yml file to setup a REST notify component.\nSince Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource.\n\n```yaml\nnotify:\n  - name: ntfy\n    platform: rest\n    method: POST_JSON\n    data:\n      topic: YOUR_NTFY_TOPIC\n    title_param_name: title\n    message_param_name: message\n    resource: https://ntfy.sh\n```\n\nIf you need to authenticate to your ntfy resource, define the authentication, username and password as below:\n\n```yaml\nnotify:\n  - name: ntfy\n    platform: rest\n    method: POST_JSON\n    authentication: basic\n    username: YOUR_USERNAME\n    password: YOUR_PASSWORD\n    data:\n      topic: YOUR_NTFY_TOPIC\n    title_param_name: title\n    message_param_name: message\n    resource: https://ntfy.sh\n```\n\nIf you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example:\n\n```yaml\nnotify:\n  - name: ntfy\n    platform: rest\n    method: POST_JSON\n    data:\n      topic: YOUR_NTFY_TOPIC\n      priority: 4\n    title_param_name: title\n    message_param_name: message\n    resource: https://ntfy.sh\n```\n\n## Uptime Kuma\nGo to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**.\nThen set your desired **title** (e.g. \"Uptime Kuma\"), **ntfy topic**, **Server URL** and **priority (1-5)**:\n\n<div id=\"uptimekuma-screenshots\" class=\"screenshots\">\n    <a href=\"../static/img/uptimekuma-settings.png\"><img src=\"../static/img/uptimekuma-settings.png\"/></a>\n    <a href=\"../static/img/uptimekuma-setup.png\"><img src=\"../static/img/uptimekuma-setup.png\"/></a>\n</div>\n\nYou can now test the notifications and apply them to monitors:\n\n<div id=\"uptimekuma-monitor-screenshots\" class=\"screenshots\">\n    <a href=\"../static/img/uptimekuma-ios-test.jpg\"><img src=\"../static/img/uptimekuma-ios-test.jpg\"/></a>\n    <a href=\"../static/img/uptimekuma-ios-down.jpg\"><img src=\"../static/img/uptimekuma-ios-down.jpg\"/></a>\n    <a href=\"../static/img/uptimekuma-ios-up.jpg\"><img src=\"../static/img/uptimekuma-ios-up.jpg\"/></a>\n</div>\n\n## UptimeRobot\nGo to your [UptimeRobot](https://github.com/uptimerobot) My Settings > Alert Contacts > Add Alert Contact\nSelect **Alert Contact Type** = Webhook. Then set your desired **Friendly Name** (e.g. \"ntfy-sh-UP\"), **URL to Notify**, **POST value** and select checkbox **Send as JSON (application/json)**. Make sure to send the JSON POST request to ntfy.domain.com without the topic name in the url and include the \"topic\" name in the JSON body.\n\n<div id=\"uptimerobot-monitor-setup\" class=\"screenshots\">\n    <a href=\"../static/img/uptimerobot-setup.jpg\"><img src=\"../static/img/uptimerobot-setup.jpg\"/></a>\n</div>\n\n``` json\n{\n    \"topic\":\"myTopic\",\n    \"title\": \"*monitorFriendlyName* *alertTypeFriendlyName*\",\n    \"message\": \"*alertDetails*\", \n    \"tags\": [\"green_circle\"],\n    \"priority\": 3,\n    \"click\": https://uptimerobot.com/dashboard#*monitorID*\n}\n```\nYou can create two Alert Contacts each with a different icon and priority, for example:\n\n``` json\n{\n    \"topic\":\"myTopic\",\n    \"title\": \"*monitorFriendlyName* *alertTypeFriendlyName*\",\n    \"message\": \"*alertDetails*\", \n    \"tags\": [\"red_circle\"],\n    \"priority\": 3,\n    \"click\": https://uptimerobot.com/dashboard#*monitorID*\n}\n```\nYou can now add the created Alerts Contact(s) to the monitor(s) and test the notifications:\n\n<div id=\"uptimerobot-monitor-screenshots\" class=\"screenshots\">\n    <a href=\"../static/img/uptimerobot-test.jpg\"><img src=\"../static/img/uptimerobot-test.jpg\"/></a>\n</div>\n\n\n## Apprise\nntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the \n[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)).\n\nYou can use it like this:\n\n```\napprise -vv -t \"Test Message Title\" -b \"Test Message Body\" \\\n   ntfy://mytopic\n```\n\nOr with your own server like this:\n\n```\napprise -vv -t \"Test Message Title\" -b \"Test Message Body\" \\\n   ntfy://ntfy.example.com/mytopic\n```\n\n\n## Rundeck\nRundeck by default sends only HTML email which is not processed by ntfy SMTP server. Append following configurations to \n[rundeck-config.properties](https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html) :\n\n```\n# Template\nrundeck.mail.template.file=/path/to/template.html\nrundeck.mail.template.log.formatted=false\n```\n\nExample `template.html`:\n```html\n<div>Execution ${execution.id} was <b>${execution.status}</b></div>\n<ul>\n    <li><a href=\"${execution.href}\">Execution result</a></li>\n    <li><a href=\"${job.href}\">Job</a></li>\n    <li><a href=\"${execution.projectHref}\">Project: ${execution.project}</a></li>\n    <li><a href=\"${rundeck.href}\">Rundeck</a></li>\n</ul>\n```\n\nAdd notification on Rundeck (attachment type must be: `Attached as file to email`):\n![Rundeck](static/img/rundeck.png)\n\n## Traccar\nThis will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes\n\nThe easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.\n\n**Info:** Add a phone number to your traccar account not in device, as otherwise it will not try to send SMS.\n\n**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))\n```xml\n        <entry key='sms.http.url'>https://ntfy.sh</entry>\n        <entry key='sms.http.template'>\n            {\n                \"topic\": \"{phone}\",\n                \"message\": \"{message}\"\n            }\n        </entry>\n```\nIf [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token\n```xml\n        <entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>\n```\nor by simply providing traccar with a valid username/password combination.\n```xml\n        <entry key='sms.http.user'>phil</entry>\n        <entry key='sms.http.password'>mypass</entry>\n```\n\n## Terminal Notifications for Long-Running Commands\n\nThis example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.\n\nStore your ntfy.sh bearer token securely if access control is enabled:\n\n   ```sh\n   echo \"your_bearer_token_here\" > ~/.ntfy_token\n   chmod 600 ~/.ntfy_token\n   ```\n\nAdd the following function and alias to your `.bashrc` or `.bash_profile`:\n\n   ```sh\n   # Function for alert notifications using ntfy.sh\n   notify_via_ntfy() {\n       local exit_status=$?  # Capture the exit status before doing anything else\n       local token=$(< ~/.ntfy_token)  # Securely read the token\n       local status_icon=\"$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)\"\n       local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\\{1,\\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')\n       # for zsh users, use the same sed pattern but get the history differently.\n       # local last_command=$(history \"$HISTCMD\" | sed -e 's/^[[:space:]]*[0-9]\\{1,\\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')\n\n       curl -s -X POST \"https://n.example.dev/alerts\" \\\n           -H \"Authorization: Bearer $token\" \\\n           -H \"Title: Terminal\" \\\n           -H \"X-Priority: 3\" \\\n           -H \"Tags: $status_icon\" \\\n           -d \"Command: $last_command (Exit: $exit_status)\"\n\n       echo \"Tags: $status_icon\"\n       echo \"$last_command (Exit: $exit_status)\"\n   }\n\n   # Add an \"alert\" alias for long running commands using ntfy.sh\n   alias alert='notify_via_ntfy'\n   ```\n\nNow you can run any long-running command and append `alert` to notify when it completes:\n\n```sh\nsleep 10; alert\n```\n![ntfy notifications on mobile device](static/img/mobile-screenshot-notification.png)\n\n**Notification Sent** with a success 🪄 (`magic_wand`) or failure ⚠️ (`warning`) tag.\n\nTo test failure notifications:\n\n```sh\nfalse; alert  # Always fails (exit 1)\nls --invalid; alert  # Invalid option\ncat nonexistent_file; alert  # File not found\n```\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# Frequently asked questions (FAQ)\n\n## Isn't this like ...?\nWho knows. I didn't do a lot of research before making this. It was fun making it.\n\n## Can I use this in my app? Will it stay free?\nYes. As long as you don't abuse it, it'll be available and free of charge. While I will always allow usage of the ntfy.sh\nserver without signup and free of charge, I may also offer paid plans in the future.\n\n## What are the uptime guarantees?\nBest effort. \n\nntfy currently runs on a single DigitalOcean droplet, without any scale out strategy or redundancies. When the time comes,\nI'll add scale out features, but for now it is what it is.\n\nIn the first year of its life, and to this day (Dec'22), ntfy had **no outages** that I can remember. Other than short \nblips and some HTTP 500 spikes, it has been rock solid.   \n\nThere is a [status page](https://ntfy.statuspage.io/) which is updated based on some automated checks via the amazingly \nawesome [healthchecks.io](https://healthchecks.io/) (_no affiliation, just a fan_).\n\n## What happens if there are multiple subscribers to the same topic?\nAs per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic.\n\n## Will you know what topics exist, can you spy on me?\nIf you don't trust me or your messages are sensitive, run your own server. It's open source.\nThat said, the logs do contain topic names and IP addresses, but I don't use them for anything other than\ntroubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default) \nto facilitate service restarts, message polling and to overcome client network disruptions.\n\n## Can I self-host it?\nYes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from\nyour own server as well. Check out the [install instructions](install.md).\n\n## Is Firebase used?\nIn addition to caching messages locally and delivering them to long-polling subscribers, all messages are also\npublished to Firebase Cloud Messaging (FCM) (if `FirebaseKeyFile` is set, which it is on ntfy.sh). This\nis to facilitate notifications on Android. \n\nIf you do not care for Firebase, I suggest you install the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/)\nof the app and [self-host your own ntfy server](install.md).\n\n## How much battery does the Android app use?\nIf you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature, \nthe Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, \nor you use *instant delivery* (Android only),  or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)),\nthe app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone). \nThere has been a ton of testing and improvement around this. I think it's pretty decent now.\n\n## Paid plans? I thought it was open source?\nAll of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you\ncan (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher\nlimits.\n\n## What is instant delivery?\n[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the\nserver and listens for incoming notifications. This consumes additional battery (see above),\nbut delivers notifications instantly.\n\n## Can you implement feature X?\nYes, maybe. Check out [existing GitHub issues](https://github.com/binwiederhier/ntfy/issues) to see if somebody else had\nthe same idea before you, or file a new issue. I'll likely get back to you within a few days.\n\n## I'm having issues with iOS, can you help? The iOS app is behind compared to the Android app, can you fix that?\nThe iOS is very bare bones and quite frankly a little buggy. I wanted to get something out the door to make the iOS users\nhappy, but halfway through I got frustrated with iOS development and paused development. I will eventually get back to\nit, or hopefully, somebody else will come along and help out. Please review the [known issues](known-issues.md) for details.\n\n## Can I disable the web app? Can I protect it with a login screen?\nThe web app is a static website without a backend (other than the ntfy API). All data is stored locally in the browser\ncache and local storage. That means it does not need to be protected with a login screen, and it poses no additional \nsecurity risk. So technically, it does not need to be disabled.\n\nHowever, if you still want to disable it, you can do so with the `web-root: disable` option in the `server.yml` file. \n\nThink of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without\na proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless.\n\n## If topic names are public, could I not just brute force them?\nIf you don't have [ACLs set up](config.md#access-control), the topic name is your password, it says so everywhere. If you\nchoose a easy-to-guess/dumb topic name, people will be able to guess it. If you choose a randomly generated topic name,\nthe topic is as good as a good password.\n\nAs for brute forcing: It's not possible to brute force a ntfy server for very long, as you'll get quickly rate limited.\nIn the default configuration, you'll be able to do 60 requests as a burst, and then 1 request per 10 seconds. Assuming you\nchoose a random 10 digit topic name using only A-Z, a-z, 0-9, _ and -, there are 64^10 possible topic names. Even if you\ncould do hundreds of requests per seconds (which you cannot), it would take many years to brute force a topic name.\n\nFor ntfy.sh, there's even a fail2ban in place which will ban your IP pretty quickly.\n\n## Where can I donate?\nI have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).\nI would be humbled if you helped me carry the server and developer account costs. Even small donations are very much\nappreciated.\n\n## Can I email you? Can I DM you on Discord/Matrix?\nFor community support, please use the public channels listed on the [contact page](contact.md). I generally \n**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) \nplan (see [paid support](contact.md#paid-support)), or you are inquiring about business \nopportunities (see [other inquiries](contact.md#other-inquiries)).\n\nI am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions \nin public forums benefits others, since I can link to the discussion at a later point in time, or other users\nmay be able to help out. I hope you understand.\n"
  },
  {
    "path": "docs/hooks.py",
    "content": "import os\nimport shutil\n\n\ndef on_post_build(config, **kwargs):\n    site_dir = config[\"site_dir\"]\n    shutil.copytree(\"docs/static/fonts\", os.path.join(site_dir, \"get\"))\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Getting started\nntfy lets you **send push notifications to your phone or desktop via scripts from any computer**, using simple HTTP PUT\nor POST requests. I use it to notify myself when scripts fail, or long-running commands complete.\n\n## Step 1: Get the app\n<a href=\"https://play.google.com/store/apps/details?id=io.heckel.ntfy\"><img width=\"170\" src=\"static/img/badge-googleplay.png\"></a>\n<a href=\"https://f-droid.org/en/packages/io.heckel.ntfy/\"><img width=\"170\" src=\"static/img/badge-fdroid.svg\"></a>\n<a href=\"https://apps.apple.com/us/app/ntfy/id1625396347\"><img width=\"150\" src=\"static/img/badge-appstore.png\"></a>\n\nTo [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.\nOnce installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just\npick a name and use it later when you [publish a message](publish.md). Note that **topic names are public, so it's wise \nto choose something that cannot be guessed easily.** \n\nFor this guide, we'll just use `mytopic` as our topic name:\n\n<figure markdown>\n  ![adding a topic](static/img/getting-started-add.png){ width=500 }\n  <figcaption>Creating/adding your first topic</figcaption>\n</figure>\n\nThat's it. After you tap \"Subscribe\", the app is listening for new messages on that topic.\n\n## Step 2: Send a message\nNow let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT/POST,\nor with the [ntfy CLI](install.md). The message is in the request body. Here's an example showing how to publish a \nsimple message using a POST request:\n\n=== \"Command line (curl)\"\n    ```\n    curl -d \"Backup successful 😀\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish mytopic \"Backup successful 😀\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    \n    Backup successful 😀\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic', {\n        method: 'POST', // PUT works too\n        body: 'Backup successful 😀'\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    http.Post(\"https://ntfy.sh/mytopic\", \"text/plain\",\n       strings.NewReader(\"Backup successful 😀\"))\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mytopic\",\n        data=\"Backup successful 😀\".encode(encoding='utf-8'))\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' => 'Content-Type: text/plain',\n            'content' => 'Backup successful 😀'\n        ]\n    ]));\n    ```\n\nThis will create a notification that looks like this:\n\n<figure markdown>\n  ![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }\n  <figcaption>Android notification</figcaption>\n</figure>\n\nThat's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on\n[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md).\n\nHere's another video showing the entire process:\n\n<figure>\n  <video controls muted autoplay loop width=\"650\" src=\"static/img/android-video-overview.mp4\"></video>\n  <figcaption>Sending push notifications to your Android phone</figcaption>\n</figure>\n\n\n"
  },
  {
    "path": "docs/install.md",
    "content": "# Installing ntfy\nThe `ntfy` CLI allows you to [publish messages](publish.md), [subscribe to topics](subscribe/cli.md) as well as to\nself-host your own ntfy server. It's all pretty straight forward. Just install the binary, package or Docker image, \nconfigure it and run it. Just like any other software. No fuzz. \n\n!!! info\n    The following steps are only required if you want to **self-host your own ntfy server or you want to use the ntfy CLI**.\n    If you just want to [send messages using ntfy.sh](publish.md), you don't need to install anything. You can just use\n    `curl`.\n\n## General steps\nThe ntfy server comes as a statically linked binary and is shipped as tarball, deb/rpm packages and as a Docker image.\nWe support amd64, armv7 and arm64.\n\n1. Install ntfy using one of the methods described below\n2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))\n3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user), see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))\n\nTo run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).\nTo send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md)\nfor details). \n\nIf you like tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU) on YouTube, or\n[Alex's Docker-based setup guide](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/). Both are great\nresources to get started. _I am not affiliated with Kris or Alex, I just liked their video/post._\n\n## Linux binaries\nPlease check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and\ndeb/rpm packages.\n\n=== \"x86_64/amd64\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz\n    tar zxvf ntfy_2.19.2_linux_amd64.tar.gz\n    sudo cp -a ntfy_2.19.2_linux_amd64/ntfy /usr/local/bin/ntfy\n    sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy\n    sudo ntfy serve\n    ```\n\n=== \"armv6\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz\n    tar zxvf ntfy_2.19.2_linux_armv6.tar.gz\n    sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy\n    sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy\n    sudo ntfy serve\n    ```\n\n=== \"armv7/armhf\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz\n    tar zxvf ntfy_2.19.2_linux_armv7.tar.gz\n    sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy\n    sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy\n    sudo ntfy serve\n    ```\n\n=== \"arm64\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz\n    tar zxvf ntfy_2.19.2_linux_arm64.tar.gz\n    sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy\n    sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy\n    sudo ntfy serve\n    ```\n\n## Debian/Ubuntu repository\n\n!!! info\n    As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.\n    The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely\n    go away soon. I suspect I will phase it out some time in early 2026.\n\nInstallation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4  B6B7 CFDB 962D 4F1E C4AF`):\n\n=== \"x86_64/amd64\"\n    ```bash\n    sudo mkdir -p /etc/apt/keyrings\n    sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg\n    sudo apt install apt-transport-https\n    echo \"deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main\" \\\n        | sudo tee /etc/apt/sources.list.d/ntfy.list\n    sudo apt update\n    sudo apt install ntfy\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"armv7/armhf\"\n    ```bash\n    sudo mkdir -p /etc/apt/keyrings\n    sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg\n    sudo apt install apt-transport-https\n    echo \"deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main\" \\\n        | sudo tee /etc/apt/sources.list.d/ntfy.list\n    sudo apt update\n    sudo apt install ntfy\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"arm64\"\n    ```bash\n    sudo mkdir -p /etc/apt/keyrings\n    sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg\n    sudo apt install apt-transport-https\n    echo \"deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main\" \\\n        | sudo tee /etc/apt/sources.list.d/ntfy.list\n    sudo apt update\n    sudo apt install ntfy\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\nManually installing the .deb file:\n\n=== \"x86_64/amd64\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.deb\n    sudo dpkg -i ntfy_*.deb\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"armv6\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.deb\n    sudo dpkg -i ntfy_*.deb\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"armv7/armhf\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.deb\n    sudo dpkg -i ntfy_*.deb\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"arm64\"\n    ```bash\n    wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.deb\n    sudo dpkg -i ntfy_*.deb\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n## Fedora/RHEL/CentOS\n\n=== \"x86_64/amd64\"\n    ```bash\n    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.rpm\n    sudo systemctl enable ntfy \n    sudo systemctl start ntfy\n    ```\n\n=== \"armv6\"\n    ```bash\n    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.rpm\n    sudo systemctl enable ntfy\n    sudo systemctl start ntfy\n    ```\n\n=== \"armv7/armhf\"\n    ```bash\n    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.rpm\n    sudo systemctl enable ntfy \n    sudo systemctl start ntfy\n    ```\n\n=== \"arm64\"\n    ```bash\n    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.rpm\n    sudo systemctl enable ntfy \n    sudo systemctl start ntfy\n    ```\n\n## Arch Linux\n<span class=\"community-badge\" title=\"This package is maintained by the community, not the ntfy developers\"><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z\"/></svg> Community maintained</span>\n\nntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). \nYou can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, \nbuild and install ntfy and keep it up to date.\n```\nparu -S ntfysh-bin\n```\n\nAlternatively, run the following commands to install ntfy manually:\n```\ncurl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv\ncd ntfysh-bin\nmakepkg -si\n```\n\n## NixOS / Nix \n<span class=\"community-badge\" title=\"This package is maintained by the community, not the ntfy developers\"><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z\"/></svg> Community maintained</span>\n\nntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:\n```\nnix-env -iA ntfy-sh\n```\n\nNixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy). \n\n## FreeBSD\n<span class=\"community-badge\" title=\"This package is maintained by the community, not the ntfy developers\"><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z\"/></svg> Community maintained</span>\n\nntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:\n```\npkg install go-ntfy\n```\n\n## macOS\nThe [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. \nTo install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz), \nextract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). \n\nIf run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at \n`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).\n\n```bash\ncurl -L https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz > ntfy_2.19.2_darwin_all.tar.gz\ntar zxvf ntfy_2.19.2_darwin_all.tar.gz\nsudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy\nmkdir ~/Library/Application\\ Support/ntfy \ncp ntfy_2.19.2_darwin_all/client/client.yml ~/Library/Application\\ Support/ntfy/client.yml\nntfy --help\n```\n\n!!! info\n    Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for \n    development as well. Check out the [build instructions](develop.md) for details.\n\n## Homebrew\n<span class=\"community-badge\" title=\"This package is maintained by the community, not the ntfy developers\"><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z\"/></svg> Community maintained</span>\n\nTo install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),\nsimply run:\n```\nbrew install ntfy\n```\n\n## Windows\nThe ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.\nTo install, you can either\n\n* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_windows_amd64.zip),\nextract it and place the `ntfy.exe` binary somewhere in your `%Path%`. \n* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`\n\nOnce installed, you can run the ntfy CLI commands like so:\n\n```\nntfy.exe -h\n```\n\nThe default configuration file location on Windows is `%ProgramData%\\ntfy\\server.yml` (e.g., `C:\\ProgramData\\ntfy\\server.yml`)\nfor the server, and `%AppData%\\ntfy\\client.yml` for the client. You may need to create the directory and config file manually.\n\nTo install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an\nelevated command prompt (adjust the path to `ntfy.exe` accordingly):\n\n```\nsc create ntfy binPath=\"C:\\path\\to\\ntfy.exe serve\" start=auto\nsc start ntfy\n```\n\n## Docker\nThe [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should \nbe pretty straight forward to use.\n\nThe server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent \n[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings, \nyou should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`.\n\n!!! info\n    Note that the Docker image **does not contain a `/etc/ntfy/server.yml` file**. If you'd like to use a config file, \n    please manually create one outside the image and map it as a volume, e.g. via `-v /etc/ntfy:/etc/ntfy`. You may\n    use the [`server.yml` file on GitHub](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml) as a template.\n\nBasic usage (no cache or additional config):\n```\ndocker run -p 80:80 -it binwiederhier/ntfy serve\n```\n\nWith persistent cache (configured as command line arguments):\n```bash\ndocker run \\\n  -v /var/cache/ntfy:/var/cache/ntfy \\\n  -p 80:80 \\\n  -it \\\n  binwiederhier/ntfy \\\n    serve \\\n    --cache-file /var/cache/ntfy/cache.db\n```\n\nWith other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):\n```bash\ndocker run \\\n  -v /etc/ntfy:/etc/ntfy \\\n  -e TZ=UTC \\\n  -p 80:80 \\\n  -u UID:GID \\\n  -it \\\n  binwiederhier/ntfy \\\n  serve\n```\n\nUsing docker-compose with non-root user and healthchecks enabled:\n```yaml\nservices:\n  ntfy:\n    image: binwiederhier/ntfy\n    container_name: ntfy\n    command:\n      - serve\n    environment:\n      - TZ=UTC    # optional: set desired timezone\n    user: UID:GID # optional: replace with your own user/group or uid/gid\n    volumes:\n      - /var/cache/ntfy:/var/cache/ntfy\n      - /etc/ntfy:/etc/ntfy\n    ports:\n      - 80:80\n    healthcheck: # optional: remember to adapt the host:port to your environment\n        test: [\"CMD-SHELL\", \"wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\\\"healthy\\\"\\\\s*:\\\\s*true' || exit 1\"]\n        interval: 60s\n        timeout: 10s\n        retries: 3\n        start_period: 40s\n    restart: unless-stopped\n    init: true # needed, if healthcheck is used. Prevents zombie processes\n```\n\nIf using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.\n\nAlternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.\n```\nFROM binwiederhier/ntfy\nCOPY server.yml /etc/ntfy/server.yml\nENTRYPOINT [\"ntfy\", \"serve\"]\n```\nThis image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.\n\n## Kubernetes\n\nThe setup for Kubernetes is very similar to that for Docker, and requires a fairly minimal deployment or pod definition to function. There\nare a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone\nunmanned pod.\n\n=== \"deployment\"\n    ```yaml\n    apiVersion: apps/v1\n    kind: Deployment\n    metadata:\n      name: ntfy\n    spec:\n      selector:\n        matchLabels:\n          app: ntfy\n      template:\n        metadata:\n          labels:\n            app: ntfy\n        spec:\n          containers:\n          - name: ntfy\n            image: binwiederhier/ntfy\n            args: [\"serve\"]\n            resources:\n              limits:\n                memory: \"128Mi\"\n                cpu: \"500m\"\n            ports:\n            - containerPort: 80\n              name: http\n            volumeMounts:\n            - name: config\n              mountPath: \"/etc/ntfy\"\n              readOnly: true\n          volumes:\n            - name: config\n              configMap:\n                name: ntfy\n    ---\n    # Basic service for port 80\n    apiVersion: v1\n    kind: Service\n    metadata:\n      name: ntfy\n    spec:\n      selector:\n        app: ntfy\n      ports:\n      - port: 80\n        targetPort: 80\n    ```\n\n=== \"stateful set\"\n    ```yaml\n    apiVersion: apps/v1\n    kind: StatefulSet\n    metadata:\n      name: ntfy\n    spec:\n      selector:\n        matchLabels:\n          app: ntfy\n      serviceName: ntfy\n      template:\n        metadata:\n          labels:\n            app: ntfy\n        spec:\n          containers:\n          - name: ntfy\n            image: binwiederhier/ntfy\n            args: [\"serve\", \"--cache-file\", \"/var/cache/ntfy/cache.db\"]\n            ports:\n            - containerPort: 80\n              name: http\n            volumeMounts:\n            - name: config\n              mountPath: \"/etc/ntfy\"\n              readOnly: true\n            - name: cache\n              mountPath: \"/var/cache/ntfy\"\n          volumes:\n            - name: config\n              configMap:\n                name: ntfy\n      volumeClaimTemplates:\n      - metadata:\n          name: cache\n        spec:\n          accessModes: [ \"ReadWriteOnce\" ]\n          resources:\n            requests:\n              storage: 1Gi\n    ```\n\n=== \"pod\"\n    ```yaml\n    apiVersion: v1\n    kind: Pod\n    metadata:\n      labels:\n        app: ntfy\n    spec:\n      containers:\n      - name: ntfy\n        image: binwiederhier/ntfy\n        args: [\"serve\"]\n        resources:\n          limits:\n            memory: \"128Mi\"\n            cpu: \"500m\"\n        ports:\n        - containerPort: 80\n          name: http\n        volumeMounts:\n        - name: config\n          mountPath: \"/etc/ntfy\"\n          readOnly: true\n      volumes:\n        - name: config\n          configMap:\n            name: ntfy\n    ```\n\nConfiguration is relatively straightforward. As an example, a minimal configuration is provided.\n\n=== \"resource definition\"\n    ```yaml\n    apiVersion: v1\n    kind: ConfigMap\n    metadata:\n      name: ntfy\n    data:\n      server.yml: |\n        # Template: https://github.com/binwiederhier/ntfy/blob/main/server/server.yml\n        base-url: https://ntfy.sh\n    ```\n\n=== \"from-file\"\n    ```bash\n    kubectl create configmap ntfy --from-file=server.yml \n    ```\n\n## Kustomize\n\nntfy can be deployed in a Kubernetes cluster with [Kustomize](https://github.com/kubernetes-sigs/kustomize), a tool used\nto customize Kubernetes objects using a `kustomization.yaml` file.\n\n1. Create new folder - `ntfy`\n2. Add all files listed below \n    1. `kustomization.yaml` - stores all configmaps and resources used in a deployment\n    2. `ntfy-deployment.yaml` - define deployment type and its parameters\n    3. `ntfy-pvc.yaml` - describes how [persistent volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) will be created \n    4. `ntfy-svc.yaml` - expose application to the internal kubernetes network\n    5. `ntfy-ingress.yaml` - expose service to outside the network using [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/)\n    6. `server.yaml` - simple server configuration\n3. Replace **TESTNAMESPACE** within `kustomization.yaml` with designated namespace \n4. Replace **ntfy.test** within `ntfy-ingress.yaml` with desired DNS name\n5. Apply configuration to cluster set in current context: \n\n```bash\nkubectl apply -k /ntfy\n```\n\n=== \"kustomization.yaml\"\n    ```yaml\n    apiVersion: kustomize.config.k8s.io/v1beta1\n    kind: Kustomization\n    resources:\n      - ntfy-deployment.yaml # deployment definition\n      - ntfy-svc.yaml # service connecting pods to cluster network\n      - ntfy-pvc.yaml # pvc used to store cache and attachment\n      - ntfy-ingress.yaml # ingress definition\n    configMapGenerator: # will parse config from raw config to configmap,it allows for dynamic reload of application if additional app is deployed ie https://github.com/stakater/Reloader\n        - name: server-config\n          files: \n            - server.yml\n    namespace: TESTNAMESPACE # select namespace for whole application \n    ```\n=== \"ntfy-deployment.yaml\"\n    ```yaml\n    apiVersion: apps/v1\n    kind: Deployment\n    metadata:\n      name: ntfy-deployment\n      labels:\n        app: ntfy-deployment\n    spec:\n      revisionHistoryLimit: 1\n      replicas: 1\n      selector:\n        matchLabels:\n          app: ntfy-pod\n      template:\n        metadata:\n          labels:\n            app: ntfy-pod\n        spec:\n          containers:\n            - name: ntfy \n              image: binwiederhier/ntfy:v1.28.0 # set deployed version\n              args: [\"serve\"]\n              env:  #example of adjustments made in environmental variables\n                - name: TZ # set timezone\n                  value: XXXXXXX\n                - name: NTFY_DEBUG # enable/disable debug\n                  value: \"false\"\n                - name: NTFY_LOG_LEVEL # adjust log level\n                  value: INFO\n                - name: NTFY_BASE_URL # add base url\n                  value: XXXXXXXXXX \n              ports: \n                - containerPort: 80\n                  name: http-ntfy\n              resources:\n                limits:\n                  memory: 300Mi\n                  cpu:  200m\n                requests:\n                      cpu: 150m\n                      memory: 150Mi\n              volumeMounts:\n                - mountPath: /etc/ntfy/server.yml\n                  subPath: server.yml\n                  name: config-volume # generated via configMapGenerator from kustomization file\n                - mountPath: /var/cache/ntfy\n                  name: cache-volume # cache volume mounted to persistent volume\n          volumes:\n            - name: config-volume\n              configMap: # uses configmap generator to parse server.yml to configmap\n                name: server-config\n            - name: cache-volume\n              persistentVolumeClaim: # stores /cache/ntfy in defined pv\n                claimName: ntfy-pvc\n    ```\n  \n=== \"ntfy-pvc.yaml\"\n    ```yaml\n    apiVersion: v1\n    kind: PersistentVolumeClaim\n    metadata:\n      name: ntfy-pvc\n    spec:\n      accessModes:\n        - ReadWriteOnce\n      storageClassName: local-path # adjust storage if needed\n      resources:\n        requests:\n          storage: 1Gi\n    ```\n\n=== \"ntfy-svc.yaml\"\n    ```yaml\n    apiVersion: v1\n    kind: Service\n    metadata:\n      name: ntfy-svc  \n    spec:\n      type: ClusterIP\n      selector:\n        app: ntfy-pod\n      ports:\n        - name: http-ntfy-out\n          protocol: TCP\n          port: 80\n          targetPort:  http-ntfy\n    ```\n\n=== \"ntfy-ingress.yaml\"\n    ```yaml\n    apiVersion: networking.k8s.io/v1\n    kind: Ingress\n    metadata:\n      name: ntfy-ingress\n    spec:\n      rules:\n        - host: ntfy.test #select own\n          http:\n            paths:\n              - path: /\n                pathType: Prefix\n                backend:\n                  service:\n                    name:  ntfy-svc\n                    port:\n                      number: 80\n    ```\n\n=== \"server.yml\"\n    ```yaml\n    cache-file: \"/var/cache/ntfy/cache.db\"\n    attachment-cache-dir: \"/var/cache/ntfy/attachments\"\n    ```\n"
  },
  {
    "path": "docs/integrations.md",
    "content": "# Integrations + community projects\n\nThere are quite a few projects that work with ntfy, integrate ntfy, or have been built around ntfy. It's super exciting to see what you guys have come up with. Feel free to [create a pull request on GitHub](https://github.com/binwiederhier/ntfy/issues) to add your own project here.\n\nI've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.\n\n## Table of Contents\n\n- [Official integrations](#official-integrations)\n- [Integration via HTTP/SMTP/etc.](#integration-via-httpsmtpetc)\n- [UnifiedPush integrations](#unifiedpush-integrations)\n- [Libraries](#libraries)\n- [CLIs + GUIs](#clis-guis)\n- [Projects + scripts](#projects-scripts)\n- [Blog + forum posts](#blog-forum-posts)\n- [Alternative ntfy servers](#alternative-ntfy-servers)\n\n## Official integrations\n\n- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification\n- [Home Assistant](https://www.home-assistant.io/integrations/ntfy) ⭐ - Home Assistant is an open-source platform for automating and controlling smart home devices.\n- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs\n- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform\n- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool\n- [Robusta](https://docs.robusta.dev/master/catalog/sinks/webhook.html) ⭐ - open source platform for Kubernetes troubleshooting\n- [borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#third-party-monitoring-services) ⭐ - configuration-driven backup software for servers and workstations\n- [Radarr](https://radarr.video/) ⭐ - Movie collection manager for Usenet and BitTorrent users\n- [Sonarr](https://sonarr.tv/) ⭐ - PVR for Usenet and BitTorrent users\n- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard\n- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool\n- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media\n- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.8/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.\n- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring\n- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool\n- [Scrt.link](https://scrt.link/) - Share a secret\n- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python\n- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier\n- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server\n- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring\n- [HetrixTools](https://docs.hetrixtools.com/ntfy-sh-notifications/) - Uptime monitoring\n- [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage) - Visual data transformation and automation tool\n- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.\n- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader\n- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform\n- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform\n\n## Integration via HTTP/SMTP/etc.\n\n- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr))\n- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#))\n- [Overseerr](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))\n- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook))\n- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy))\n- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.\n- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))\n- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)\n\n## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations\n\n- [Element](https://f-droid.org/packages/im.vector.app/) ⭐ - Matrix client\n- [SchildiChat](https://schildi.chat/android/) ⭐ - Matrix client\n- [Tusky](https://tusky.app/) ⭐ - Fediverse client\n- [Fedilab](https://fedilab.app/) - Fediverse client\n- [FindMyDevice](https://gitlab.com/Nulide/findmydevice/) - Find your Device with an SMS or online with the help of FMDServer\n- [Tox Push Message App](https://github.com/zoff99/tox_push_msg_app) - Tox Push Message App\n\n## Libraries \n\n- [ntfy-php-library](https://github.com/VerifiedJoseph/ntfy-php-library) - PHP library for sending messages using a ntfy server (PHP)\n- [ntfy-notifier](https://github.com/DAcodedBEAT/ntfy-notifier) - Symfony Notifier integration for ntfy (PHP)\n- [ntfpy](https://github.com/Nevalicjus/ntfpy) - API Wrapper for ntfy.sh (Python)\n- [pyntfy](https://github.com/DP44/pyntfy) - A module for interacting with ntfy notifications (Python)\n- [vntfy](https://github.com/lmangani/vntfy) - Barebone V client for ntfy (V)\n- [ntfy-middleman](https://github.com/nachotp/ntfy-middleman) - Wraps APIs and send notifications using ntfy.sh on schedule (Python)\n- [ntfy-dotnet](https://github.com/nwithan8/ntfy-dotnet) - .NET client library to interact with a ntfy server (C# / .NET)\n- [node-ntfy-publish](https://github.com/cityssm/node-ntfy-publish) - A Node package to publish notifications to an ntfy server (Node)\n- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R)\n- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)\n- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS)\n- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart)\n- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)\n- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)\n- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)\n- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)\n\n## CLIs + GUIs\n\n- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events\n- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy\n- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications\n- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte\n- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic\n- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop\n- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy\n- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications\n- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11\n- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.\n- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.\n- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails\n- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.\n\n## Projects + scripts \n\n- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)\n- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)\n- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)\n- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)\n- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)\n- [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go)\n- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)\n- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)\n- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)\n- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP)\n- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) -  Checking if server is online and reporting through ntfy (C)\n- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell)\n- [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python)\n- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python)\n- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python)\n- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript)\n- [ntfy Discord bot](https://github.com/binwiederhier/ntfy-bot) - ntfy Discord bot (Go)\n- [ntfy Discord bot](https://github.com/jr1221/ntfy_discord_bot) - An advanced modal-based bot for interacting with the ntfy.sh API (Dart)\n- [Bettarr Notifications](https://github.com/NiNiyas/Bettarr-Notifications) - Better Notifications for Sonarr and Radarr (Python)\n- [Notify me the intruders](https://github.com/nothingbutlucas/notify_me_the_intruders) - Notify you if they are intruders or new connections on your network (Shell)\n- [Send GitHub Action to ntfy](https://github.com/NiNiyas/ntfy-action) - Send GitHub Action workflow notifications to ntfy (JS)\n- [aTable/ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS)\n- [~xenrox/ntfy-alertmanager](https://hub.xenrox.net/~xenrox/ntfy-alertmanager) - A bridge between ntfy and Alertmanager (Go)\n- [pinpox/alertmanager-ntfy](https://github.com/pinpox/alertmanager-ntfy) - Relay prometheus alertmanager alerts to ntfy (Go)\n- [alexbakker/alertmanager-ntfy](https://github.com/alexbakker/alertmanager-ntfy) - Service that forwards Prometheus Alertmanager notifications to ntfy (Go)\n- [restreamchat2ntfy](https://github.com/kurohuku7/restreamchat2ntfy) - Send restream.io chat to ntfy to check on the Meta Quest (JS)\n- [k8s-ntfy-deployment-service](https://github.com/Christian42/k8s-ntfy-deployment-service) - Automatic Kubernetes (k8s) ntfy deployment\n- [huginn-global-entry-notif](https://github.com/kylezoa/huginn-global-entry-notif) - Checks CBP API for available appointments with Huginn (JSON)\n- [ntfyer](https://github.com/KikyTokamuro/ntfyer) - Sending various information to your ntfy topic by time (TypeScript)\n- [git-simple-notifier](https://github.com/plamenjm/git-simple-notifier) - Script running git-log, checking for new repositories (Shell)\n- [ntfy-to-slack](https://github.com/ozskywalker/ntfy-to-slack) - Tool to subscribe to a ntfy topic and send the messages to a Slack webhook (Go)\n- [ansible-ntfy](https://github.com/jpmens/ansible-ntfy) - Ansible action plugin to post JSON messages to ntfy (Python)\n- [ntfy-notification-channel](https://github.com/wijourdil/ntfy-notification-channel) - Laravel Notification channel for ntfy (PHP)\n- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy\n- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)\n- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost \n- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go)\n- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)\n- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)\n- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)\n- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)\n- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums\n- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows\n- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)\n- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy\n- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy\n- [easy2ntfy](https://github.com/chromoxdor/easy2ntfy) - Gateway for ESPeasy to receive commands through ntfy and using easyfetch (HTML/JS)\n- [ntfy_lite](https://github.com/MPI-IS/ntfy_lite) - Minimalist python API for pushing ntfy notifications (Python)\n- [notify](https://github.com/guanguans/notify) - 推送通知 (PHP)\n- [zpool-events](https://github.com/maglar0/zpool-events) - Notify on ZFS pool events (Python)\n- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)\n- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)\n- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)\n- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup, shutdown and service failure\n- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)\n- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)\n- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager\n- [NtfyMe-Blender](https://github.com/NotNanook/NtfyMe-Blender) - Blender addon to send notifications to NtfyMe (Python)\n- [ntfy-ios-url-share](https://www.icloud.com/shortcuts/be8a7f49530c45f79733cfe3e41887e6) - An iOS shortcut that lets you share URLs easily and quickly.\n- [ntfy-ios-filesharing](https://www.icloud.com/shortcuts/fe948d151b2e4ae08fb2f9d6b27d680b) - An iOS shortcut that lets you share files from your share feed to a topic of your choice.\n- [systemd-ntfy](https://hackage.haskell.org/package/systemd-ntfy) - monitor a set of systemd services an send a notification to ntfy.sh whenever their status changes\n- [RouterOS Scripts](https://git.eworm.de/cgit/routeros-scripts/about/) - a collection of scripts for MikroTik RouterOS\n- [ntfy-android-builder](https://github.com/TheBlusky/ntfy-android-builder) - Script for building ntfy-android with custom Firebase configuration (Docker/Shell)\n- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)\n- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)\n- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust) \n- [notify-via-ntfy](https://exchange.checkmk.com/p/notify-via-ntfy) - Checkmk plugin to send notifications via ntfy (Python)\n- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)\n- [container-update-check](https://github.com/stendler/container-update-check) - Scripts to check and notify if a podman or docker container image can be updated (Podman/Shell)\n- [ignition-combustion-template](https://github.com/stendler/ignition-combustion-template) - Templates and scripts to generate a configuration to automatically setup a system on first boot. Including systemd-ntfy-poweronoff (Shell)\n- [ntfy-run](https://github.com/quantum5/ntfy-run) - Tool to run a command, capture its output, and send it to ntfy (Rust)\n- [Clipboard IO](https://github.com/jim3692/clipboard-io) - End to end encrypted clipboard\n- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)\n- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)\n- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)\n- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)\n- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform\n- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy\n- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.\n- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)\n- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)\n- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)\n- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)\n- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)\n- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services\n\n## Blog + forum posts\n\n- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025\n- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025\n- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025\n- [How to Set Up Ntfy: Self-Hosted Push Notifications Made Easy](https://www.youtube.com/watch?v=wDJDiAYZ3H0) - youtube.com (sass drew) - 1/2025\n- [The NTFY is a game-changer FREE solution for IT people](https://www.youtube.com/watch?v=NtlztHT-sRw) - youtube.com (Valters Tech Turf) - 1/2025\n- [Notify: A Powerful Tool for Real-Time Notifications (ntfy.sh)](https://www.youtube.com/watch?v=XXTTeVfGBz0) - youtube.com (LinuxCloudHacks) - 12/2025\n- [Push notifications with ntfy and n8n](https://www.youtube.com/watch?v=DKG1R3xYvwQ) - youtube.com (Oskar) - 10/2024\n- [Setup ntfy for selfhosted notifications with Cloudflare Tunnel](https://medium.com/@svenvanginkel/setup-ntfy-for-selfhosted-notifications-with-cloudflare-tunnel-e342f470177d) - medium.com (Sven van Ginkel) - 10/2024\n- [Self-Host NTFY - How It Works & Easy Setup Guide](https://www.youtube.com/watch?v=79wHc_jfrJE) ⭐ - youtube.com (Techdox)- 9/2024\n- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024\n- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024\n- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024\n- [ZFS and SMART Warnings via Ntfy](https://rair.dev/zfs-smart-ntfy/) - rair.dev - 2/2024\n- [Automating Security Camera Notifications With Home Assistant and Ntfy](https://runtimeterror.dev/automating-camera-notifications-home-assistant-ntfy/) ⭐ - runtimeterror.dev - 2/2024\n- [Ntfy: self-hosted notification service](https://medium.com/@williamdonze/ntfy-self-hosted-notification-service-0f3eada6e657) ⭐ - williamdonze.medium.com - 1/2024\n- [Let’s Supercharge Snowflake Alerts with Cool ntfy Open-source Notifications!](https://sarathi-data-ml-cloud.medium.com/lets-supercharge-snowflake-alerts-with-cool-ntfy-open-source-notifications-296da442c331) - sarathi-data-ml-cloud.medium.com - 1/2024\n- [Setting up NTFY with Ngnix-Proxy-Manager, authentication and Ansible notifications](https://random-it-blog.de/rocky-linux/setting-up-ntfy-with-ngnix-proxy-manager-authentication-and-ansible-notifications/) - random-it-blog.de - 12/2023\n- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023\n- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023\n- [Podman Update Notifications via Ntfy](https://rair.dev/podman-update-notifications-ntfy/) - rair.dev - 9/2023\n- [Easy Push Notifications With ntfy.sh](https://runtimeterror.dev/easy-push-notifications-with-ntfy/) ⭐ - runtimeterror.dev - 9/2023\n- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023\n- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023\n- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023 \n- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023\n- [Supercharge Your Alerts: Ntfy — The Ultimate Push Notification Solution](https://medium.com/spring-boot/supercharge-your-alerts-ntfy-the-ultimate-push-notification-solution-a3dda79651fe) - spring-boot.medium.com - 9/2023\n- [Deploy Ntfy using Docker](https://www.linkedin.com/pulse/deploy-ntfy-mohamed-sharfy/) - linkedin.com - 9/2023\n- [Send Notifications With Ntfy for New WordPress Posts](https://www.activepieces.com/blog/ntfy-notifications-for-wordpress-new-posts) - activepieces.com - 9/2023\n- [Get Ntfy Notifications About New Zendesk Ticket](https://www.activepieces.com/blog/ntfy-notifications-about-new-zendesk-tickets) - activepieces.com - 9/2023\n- [Set reminder for recurring events using ntfy & Cron](https://www.youtube.com/watch?v=J3O4aQ-EcYk) - youtube.com - 9/2023\n- [ntfy - Installation and full configuration setup](https://www.youtube.com/watch?v=QMy14rGmpFI) - youtube.com - 9/2023\n- [How to install Ntfy.sh on Portainer / Docker Compose](https://www.youtube.com/watch?v=utD9GNbAwyg) - youtube.com - 9/2023\n- [ntfy - Push-Benachrichtigungen // Push Notifications](https://www.youtube.com/watch?v=LE3vRPPqZOU) - youtube.com - 9/2023\n- [Podman Update Notifications via Ntfy](https://rair.dev/podman-upadte-notifications-ntfy/) - rair.dev - 9/2023\n- [How to Send Alerts From Raspberry Pi Pico W to a Phone or Tablet](https://www.tomshardware.com/how-to/send-alerts-raspberry-pi-pico-w-to-mobile-device) - tomshardware.com - 8/2023\n- [NetworkChunk - how did I NOT know about this?](https://www.youtube.com/watch?v=poDIT2ruQ9M) ⭐ - youtube.com - 8/2023\n- [NTFY - Command-Line Notifications](https://academy.networkchuck.com/blog/ntfy/) - academy.networkchuck.com - 8/2023\n- [Open Source Push Notifications! Get notified of any event you can imagine. Triggers abound!](https://www.youtube.com/watch?v=WJgwWXt79pE) ⭐ - youtube.com - 8/2023\n- [How to install and self host an Ntfy server on Linux](https://linuxconfig.org/how-to-install-and-self-host-an-ntfy-server-on-linux) - linuxconfig.org - 7/2023\n- [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023 \n- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023 \n- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023 \n- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023\n- [桌面通知：ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023 \n- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023 \n- [Install ntfy Inside Docker Container in Linux](https://lindevs.com/install-ntfy-inside-docker-container-in-linux) - lindevs.com - 4/2023 \n- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023 \n- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023 \n- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023 \n- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023\n- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023 \n- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023 \n- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023 \n- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023 \n- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023\n- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023 \n- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023 \n- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023\n- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023\n- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022\n- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022\n- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022 \n- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022\n- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022\n- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022\n- [Enabling SSH Login Notifications using Ntfy](https://paramdeo.com/blog/enabling-ssh-login-notifications-using-ntfy) - paramdeo.com - 11/2022\n- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022 \n- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022\n- [How to make my phone buzz*](https://evbogue.com/howtomakemyphonebuzz) - evbogue.com - 11/2022\n- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022\n- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022\n- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022\n- [Envie Push Notifications por POST (de graça e sem cadastro)](https://www.tabnews.com.br/filipedeschamps/envie-push-notifications-por-post-de-graca-e-sem-cadastro) - tabnews.com.br - 11/2022\n- [Push Notifications for KDE](https://volkerkrause.eu/2022/11/12/kde-unifiedpush-push-notifications.html) - volkerkrause.eu - 11/2022\n- [TLDR Newsletter Daily Update 2022-11-09](https://tldr.tech/tech/newsletter/2022-11-09) ⭐ - tldr.tech - 11/2022\n- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022\n- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022\n- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022\n- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022\n- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022\n- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022 \n- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022\n- [Neue Services: Ntfy, TikTok und RustDesk](https://adminforge.de/tools/neue-services-ntfy-tiktok-und-rustdesk/) - adminforge.de - 9/2022\n- [Ntfy, le service de notifications qu’il vous faut](https://www.cachem.fr/ntfy-le-service-de-notifications-quil-vous-faut/) - cachem.fr - 9/2022\n- [NAS Synology et notifications avec ntfy](https://www.cachem.fr/synology-notifications-ntfy/) - cachem.fr - 9/2022 \n- [Self hosted Mobile Push Notifications using NTFY | Thejesh GN](https://thejeshgn.com/2022/08/23/self-hosted-mobile-push-notifications-using-ntfy/) - thejeshgn.com - 8/2022\n- [Fedora Magazine | 4 cool new projects to try in Copr](https://fedoramagazine.org/4-cool-new-projects-to-try-in-copr-for-august-2022/) - fedoramagazine.org - 8/2022\n- [Docker로 오픈소스 푸시알람 프로젝트 ntfy.sh 설치 및 사용하기.(Feat. Uptimekuma)](https://svrforum.com/svr/398979) - svrforum.com - 8/2022\n- [Easy notifications from R](https://sometimesir.com/posts/easy-notifications-from-r/) - sometimesir.com - 6/2022\n- [ntfy is finally coming to iOS, and Matrix/UnifiedPush gateway support](https://www.reddit.com/r/selfhosted/comments/vdzvxi/ntfy_is_finally_coming_to_ios_with_full/) ⭐ - reddit.com - 6/2022\n- [Install guide (with Docker)](https://chowdera.com/2022/150/202205301257379077.html) - chowdera.com - 5/2022\n- [无需注册的通知服务ntfy](https://blog.csdn.net/wbsu2004/article/details/125040247) - blog.csdn.net - 5/2022\n- [Updated review post (Jan-Lukas Else)](https://jlelse.blog/thoughts/2022/04/ntfy) - jlelse.blog - 4/2022\n- [Using ntfy and Tasker together](https://lachlanlife.net/posts/2022-04-tasker-ntfy/) - lachlanlife.net - 4/2022 \n- [Reddit feature update post](https://www.reddit.com/r/selfhosted/comments/uetlso/ntfy_is_a_tool_to_send_push_notifications_to_your/) ⭐ - reddit.com - 4/2022\n- [無料で簡単に通知の送受信ができつつオープンソースでセルフホストも可能な「ntfy」を使ってみた](https://gigazine.net/news/20220404-ntfy-push-notification/) - gigazine.net - 4/2022\n- [Pocketmags ntfy review](https://pocketmags.com/us/linux-format-magazine/march-2022/articles/1104187/ntfy) - pocketmags.com - 3/2022\n- [Reddit web app release post](https://www.reddit.com/r/selfhosted/comments/tc0p0u/say_hello_to_the_brand_new_ntfysh_web_app_push/) ⭐ - reddit.com- 3/2022\n- [Lemmy post (Jakob)](https://lemmy.eus/post/15541) - lemmy.eus - 1/2022\n- [Reddit UnifiedPush release post](https://www.reddit.com/r/selfhosted/comments/s5jylf/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 1/2022\n- [ntfy: send notifications from your computer to your phone](https://rs1.es/tutorials/2022/01/19/ntfy-send-notifications-phone.html) - rs1.es - 1/2022\n- [Short ntfy review (Jan-Lukas Else)](https://jlelse.blog/links/2021/12/ntfy-sh) - jlelse.blog - 12/2021\n- [Free MacroDroid webhook alternative (FrameXX)](https://www.macrodroidforum.com/index.php?threads/ntfy-sh-free-macrodroid-webhook-alternative.1505/) - macrodroidforum.com - 12/2021\n- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021\n- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021\n- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021\n- [ntfy on The Canary in the Cage Podcast](https://odysee.com/@TheCanaryInTheCage:b/The-Canary-in-the-Cage-Episode-42:1?r=4gitYjTacQqPEjf22874USecDQYJ5y5E&t=3062) - odysee.com - 1/2025\n- [NtfyPwsh - A PowerShell Module to Send Ntfy Messages](https://ptmorris1.github.io/posts/NtfyPwsh/) - github.io - 5/2025\n\n## Alternative ntfy servers\n\nHere's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the\nntfy community. Thanks to everyone running a public server. **You guys rock!**\n\n| URL                                               | Country            |\n|---------------------------------------------------|--------------------|\n| [ntfy.sh](https://ntfy.sh/) (*Official*)          | 🇺🇸 United States |\n| [ntfy.tedomum.fr](https://ntfy.tedomum.fr/)     | 🇫🇷 France        |\n| [ntfy.jae.fi](https://ntfy.jae.fi/)               | 🇫🇮 Finland       |\n| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany       |\n| [ntfy.envs.net](https://ntfy.envs.net)            | 🇩🇪 Germany       |\n| [ntfy.mzte.de](https://ntfy.mzte.de/)             | 🇩🇪 Germany       |\n| [ntfy.hostux.net](https://ntfy.hostux.net/)       | 🇫🇷 France        |\n| [ntfy.fossman.de](https://ntfy.fossman.de/)       | 🇩🇪 Germany       |\n\nPlease be aware that **server operators can log your messages**. The project also cannot guarantee the reliability\nand uptime of third party servers, so use of each server is **at your own discretion**.\n"
  },
  {
    "path": "docs/known-issues.md",
    "content": "# Known issues\nThis is an incomplete list of known issues with the ntfy server, web app, Android app, and iOS app. You can find a complete\nlist [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful\nto have the prominent ones here to link to.\n\n## iOS app not refreshing (see [#267](https://github.com/binwiederhier/ntfy/issues/267))\nFor some (many?) users, the iOS app is not refreshing the view when new notifications come in. Until you manually\nswipe down, you do not see the newly arrived messages, even though the popup appeared before.\n\nThis is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely\nclueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it.\n\nPlease send experienced iOS developers my way to help me figure this out.\n\n## iOS app not receiving notifications (anymore)\nIf notifications do not show up at all anymore, there are a few causes for it (that I know of):\n\n**Firebase+APNS are being weird and buggy**:    \nIf this is the case, usually it helps to **remove the topic/subscription and re-add it**. That will force Firebase to \nre-subscribe to the Firebase topic.\n\n**Self-hosted only: No `upstream-base-url` set, or `base-url` mismatch**:   \nTo make self-hosted servers work with the iOS\napp, I had to do some horrible things (see [iOS instant notifications](config.md#ios-instant-notifications) for details).\nBe sure that in your selfhosted server:\n\n* Set `upstream-base-url: \"https://ntfy.sh\"` (**not your own hostname!**)\n* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to \n\n## iOS app seeing \"New message\", but not real message content\nIf you see `New message` notifications on iOS, your iPhone can likely not talk to your self-hosted server. Be sure that\nyour iOS device and your ntfy server are either on the same network, or that your phone can actually reach the server.\n\nTurn on tracing/debugging on the server (via `log-level: trace` or `log-level: debug`, see [troubleshooting](troubleshooting.md)),\nand read docs on [iOS instant notifications](https://docs.ntfy.sh/config/#ios-instant-notifications).\n\n## Safari does not play sounds for web push notifications\nSafari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with\niOS 17 / Safari 17, which will be released later in 2023.\n\n## PWA on iOS sometimes crashes with an IndexedDB error (see [#787](https://github.com/binwiederhier/ntfy/issues/787))\nWhen resuming the installed PWA from the background, it sometimes crashes with an error from IndexedDB/Dexie, due to a\n[WebKit bug]( https://bugs.webkit.org/show_bug.cgi?id=197050). A reload will fix it until a permanent fix is found.\n"
  },
  {
    "path": "docs/privacy.md",
    "content": "# Privacy policy\n\n**Last updated:** January 2, 2026\n\nThis privacy policy describes how ntfy (\"we\", \"us\", or \"our\") collects, uses, and handles your information\nwhen you use the ntfy.sh service, web app, and mobile applications (Android and iOS).\n\n## Our commitment to privacy\n\nWe love free software, and we're doing this because it's fun. We have no bad intentions, and **we will\nnever monetize or sell your information**. The ntfy service and software will always stay free and open source.\nIf you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md).\n\n## Information we collect\n\n### Account information (optional)\n\nIf you create an account on ntfy.sh, we collect:\n\n- **Username** - A unique identifier you choose\n- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)\n- **Email address** - Only if you subscribe to a paid plan (for billing purposes)\n- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)\n\nYou can use ntfy without creating an account. Anonymous usage is fully supported.\n\n### Messages and notifications\n\n- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support \n  message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires.\n- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted.\n- **Topic names** - The topic names you publish to or subscribe to are processed by our servers.\n\n### Technical information\n\n- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes,\n  though this is typically turned off.\n- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time, \n  and the IP address of the last access.\n- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription \n  endpoint to deliver notifications.\n\n### Billing information (paid plans only)\n\nIf you subscribe to a paid plan, payment processing is handled by Stripe. We store:\n\n- Stripe customer ID\n- Subscription status and billing period\n\nWe do not store your credit card numbers or payment details directly. These are handled entirely by Stripe.\n\n## Third-party services\n\nTo provide the ntfy.sh service, we use the following third-party services:\n\n### Firebase Cloud Messaging (FCM)\n\nWe use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you \nreceive a notification through the mobile apps (Google Play or App Store versions):\n\n- Message metadata and content may be transmitted through Google's FCM infrastructure\n- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data\n\n**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of \nthe Android app and use a self-hosted server, or use the instant delivery feature with your own server.\n\n### Twilio (phone calls)\n\nIf you use the phone call notification feature (`X-Call` header), we use Twilio to:\n\n- Make voice calls to your verified phone number\n- Send SMS or voice calls for phone number verification\n\nYour phone number is shared with Twilio to deliver these services. Twilio's \n[privacy policy](https://www.twilio.com/legal/privacy) applies.\n\n### Amazon SES (email delivery)\n\nIf you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to \ndeliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure. \nAmazon's [privacy policy](https://aws.amazon.com/privacy/) applies.\n\n### Stripe (payments)\n\nIf you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly \nby Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy).\n\nNote: We have explicitly disabled Stripe's telemetry features in our integration.\n\n### Web push providers\n\nIf you enable browser notifications in the ntfy web app, push messages are delivered through your browser \nvendor's push service:\n\n- Google (Chrome)\n- Mozilla (Firefox)\n- Apple (Safari)\n- Microsoft (Edge)\n\nYour browser's push subscription endpoint is shared with these providers to deliver notifications.\n\n## Mobile applications\n\n### Android app\n\nThe Android app is available from two sources:\n\n- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is \n  **explicitly disabled** in our app.\n- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain \n  a direct connection to the server.\n\nThe Android app stores the following data locally on your device:\n\n- Subscribed topics and their settings\n- Cached notifications\n- User credentials (if you add a server with authentication)\n- Application logs (for debugging, stored locally only)\n\n### iOS app\n\nThe iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications. \nThe app stores the following data locally on your device:\n\n- Subscribed topics\n- Cached notifications\n- User credentials (if configured)\n\n## Web application\n\nThe ntfy web app is a static website that stores all data locally in your browser:\n\n- **IndexedDB** - Stores your subscriptions and cached notifications\n- **Local Storage** - Stores your preferences and session information\n\nNo cookies are used for tracking. The web app does not have a backend beyond the ntfy API.\n\n## Data retention\n\n| Data type              | Retention period                                  |\n|------------------------|---------------------------------------------------|\n| Messages               | 12 hours (configurable by server operators)       |\n| Attachments            | 3 hours (configurable by server operators)        |\n| User accounts          | Until you delete your account                     |\n| Access tokens          | Until you revoke them or delete your account      |\n| Phone numbers          | Until you remove them or delete your account      |\n| Web push subscriptions | 60 days of inactivity, then automatically removed |\n| Server logs            | Varies; debugging logs are typically temporary    |\n\n## Self-hosting\n\nIf you prefer complete control over your data, you can [self-host your own ntfy server](install.md). \nWhen self-hosting:\n\n- You control all data storage and retention\n- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations\n- No data is shared with ntfy.sh or any third party (unless you configure those integrations)\n\nThe server and all apps are fully open source:\n\n- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)\n- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android)\n- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios)\n\n## Data security\n\n- All connections to ntfy.sh are encrypted using TLS/HTTPS\n- Passwords are hashed using bcrypt before storage\n- Access tokens are generated using cryptographically secure random values\n- The server does not log message content by default\n\n## Your rights\n\nYou have the right to:\n\n- **Access** - View your account information and data\n- **Delete** - Delete your account and associated data via the web app\n- **Export** - Your messages are available via the API while cached\n\nTo delete your account, use the account settings in the web app or contact us.\n\n## Changes to this policy\n\nWe may update this privacy policy from time to time. Changes will be posted on this page with an updated \n\"Last updated\" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md). \n\nFor significant changes, we may provide additional notice on Discord/Matrix or through the\n[announcements](https://ntfy.sh/announcements) ntfy topic.\n\n## Contact\n\nFor privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).\n\nFor all other contact options, see the [contact page](contact.md).\n"
  },
  {
    "path": "docs/publish/template-functions.md",
    "content": "# Template Functions\n\nThese template functions may be used in the **[message template](../publish.md#message-templating)** feature of ntfy. Please refer to the examples in the documentation for how to use them.\n\nThe original set of template functions is based on the [Sprig library](https://masterminds.github.io/sprig/). This documentation page is a (slightly modified) copy of their docs. **Thank you to the Sprig developers for their work!** 🙏\n\n## Table of Contents\n\n- [String Functions](#string-functions)\n- [String List Functions](#string-list-functions)\n- [Integer Math Functions](#integer-math-functions)\n- [Integer List Functions](#integer-list-functions)\n- [Float Math Functions](#float-math-functions)\n- [Date Functions](#date-functions)\n- [Default Functions](#default-functions)\n- [Encoding Functions](#encoding-functions)\n- [Lists and List Functions](#lists-and-list-functions)\n- [Dictionaries and Dict Functions](#dictionaries-and-dict-functions)\n- [Type Conversion Functions](#type-conversion-functions)\n- [Path and Filepath Functions](#path-and-filepath-functions)\n- [Flow Control Functions](#flow-control-functions)\n- [Reflection Functions](#reflection-functions)\n- [Cryptographic and Security Functions](#cryptographic-and-security-functions)\n- [URL Functions](#url-functions)\n\n## String Functions\n\nSprig has a number of string manipulation functions.\n\n### trim\n\nThe `trim` function removes space from either side of a string:\n\n```\ntrim \"   hello    \"\n```\n\nThe above produces `hello`\n\n### trimAll\n\nRemove given characters from the front or back of a string:\n\n```\ntrimAll \"$\" \"$5.00\"\n```\n\nThe above returns `5.00` (as a string).\n\n### trimSuffix\n\nTrim just the suffix from a string:\n\n```\ntrimSuffix \"-\" \"hello-\"\n```\n\nThe above returns `hello`\n\n### trimPrefix\n\nTrim just the prefix from a string:\n\n```\ntrimPrefix \"-\" \"-hello\"\n```\n\nThe above returns `hello`\n\n### upper\n\nConvert the entire string to uppercase:\n\n```\nupper \"hello\"\n```\n\nThe above returns `HELLO`\n\n### lower\n\nConvert the entire string to lowercase:\n\n```\nlower \"HELLO\"\n```\n\nThe above returns `hello`\n\n### title\n\nConvert to title case:\n\n```\ntitle \"hello world\"\n```\n\nThe above returns `Hello World`\n\n### repeat\n\nRepeat a string multiple times:\n\n```\nrepeat 3 \"hello\"\n```\n\nThe above returns `hellohellohello`\n\n### substr\n\nGet a substring from a string. It takes three parameters:\n\n- start (int)\n- end (int)\n- string (string)\n\n```\nsubstr 0 5 \"hello world\"\n```\n\nThe above returns `hello`\n\n### trunc\n\nTruncate a string (and add no suffix)\n\n```\ntrunc 5 \"hello world\"\n```\n\nThe above produces `hello`.\n\n```\ntrunc -5 \"hello world\"\n```\n\nThe above produces `world`.\n\n### contains\n\nTest to see if one string is contained inside of another:\n\n```\ncontains \"cat\" \"catch\"\n```\n\nThe above returns `true` because `catch` contains `cat`.\n\n### hasPrefix and hasSuffix\n\nThe `hasPrefix` and `hasSuffix` functions test whether a string has a given\nprefix or suffix:\n\n```\nhasPrefix \"cat\" \"catch\"\n```\n\nThe above returns `true` because `catch` has the prefix `cat`.\n\n### quote and squote\n\nThese functions wrap a string in double quotes (`quote`) or single quotes\n(`squote`).\n\n### cat\n\nThe `cat` function concatenates multiple strings together into one, separating\nthem with spaces:\n\n```\ncat \"hello\" \"beautiful\" \"world\"\n```\n\nThe above produces `hello beautiful world`\n\n### indent\n\nThe `indent` function indents every line in a given string to the specified\nindent width. This is useful when aligning multi-line strings:\n\n```\nindent 4 $lots_of_text\n```\n\nThe above will indent every line of text by 4 space characters.\n\n### nindent\n\nThe `nindent` function is the same as the indent function, but prepends a new\nline to the beginning of the string.\n\n```\nnindent 4 $lots_of_text\n```\n\nThe above will indent every line of text by 4 space characters and add a new\nline to the beginning.\n\n### replace\n\nPerform simple string replacement.\n\nIt takes three arguments:\n\n- string to replace\n- string to replace with\n- source string\n\n```\n\"I Am Henry VIII\" | replace \" \" \"-\"\n```\n\nThe above will produce `I-Am-Henry-VIII`\n\n### plural\n\nPluralize a string.\n\n```\nlen $fish | plural \"one anchovy\" \"many anchovies\"\n```\n\nIn the above, if the length of the string is 1, the first argument will be\nprinted (`one anchovy`). Otherwise, the second argument will be printed\n(`many anchovies`).\n\nThe arguments are:\n\n- singular string\n- plural string\n- length integer\n\nNOTE: Sprig does not currently support languages with more complex pluralization\nrules. And `0` is considered a plural because the English language treats it\nas such (`zero anchovies`). The Sprig developers are working on a solution for\nbetter internationalization.\n\n### regexMatch, mustRegexMatch\n\nReturns true if the input string contains any match of the regular expression.\n\n```\nregexMatch \"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\\\.[A-Za-z]{2,}$\" \"test@acme.com\"\n```\n\nThe above produces `true`\n\n`regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the\ntemplate engine if there is a problem.\n\n### regexFindAll, mustRegexFindAll\n\nReturns a slice of all matches of the regular expression in the input string.\nThe last parameter n determines the number of substrings to return, where -1 means return all matches\n\n```\nregexFindAll \"[2,4,6,8]\" \"123456789\" -1\n```\n\nThe above produces `[2 4 6 8]`\n\n`regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the\ntemplate engine if there is a problem.\n\n### regexFind, mustRegexFind\n\nReturn the first (left most) match of the regular expression in the input string\n\n```\nregexFind \"[a-zA-Z][1-9]\" \"abcd1234\"\n```\n\nThe above produces `d1`\n\n`regexFind` panics if there is a problem and `mustRegexFind` returns an error to the\ntemplate engine if there is a problem.\n\n### regexReplaceAll, mustRegexReplaceAll\n\nReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement.\nInside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch\n\n```\nregexReplaceAll \"a(x*)b\" \"-ab-axxb-\" \"${1}W\"\n```\n\nThe above produces `-W-xxW-`\n\n`regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the\ntemplate engine if there is a problem.\n\n### regexReplaceAllLiteral, mustRegexReplaceAllLiteral\n\nReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement\nThe replacement string is substituted directly, without using Expand\n\n```\nregexReplaceAllLiteral \"a(x*)b\" \"-ab-axxb-\" \"${1}\"\n```\n\nThe above produces `-${1}-${1}-`\n\n`regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the\ntemplate engine if there is a problem.\n\n### regexSplit, mustRegexSplit\n\nSlices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches\n\n```\nregexSplit \"z+\" \"pizza\" -1\n```\n\nThe above produces `[pi a]`\n\n`regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the\ntemplate engine if there is a problem.\n\n### regexQuoteMeta\n\nReturns a string that escapes all regular expression metacharacters inside the argument text;\nthe returned string is a regular expression matching the literal text.\n\n```\nregexQuoteMeta \"1.2.3\"\n```\n\nThe above produces `1\\.2\\.3`\n\n### See Also...\n\nThe [Conversion Functions](#type-conversion-functions) contain functions for converting strings. The [String List Functions](#string-list-functions) contains\nfunctions for working with an array of strings.\n\n## String List Functions\n\nThese functions operate on or generate slices of strings. In Go, a slice is a\ngrowable array. In Sprig, it's a special case of a `list`.\n\n### join\n\nJoin a list of strings into a single string, with the given separator.\n\n```\nlist \"hello\" \"world\" | join \"_\"\n```\n\nThe above will produce `hello_world`\n\n`join` will try to convert non-strings to a string value:\n\n```\nlist 1 2 3 | join \"+\"\n```\n\nThe above will produce `1+2+3`\n\n### splitList and split\n\nSplit a string into a list of strings:\n\n```\nsplitList \"$\" \"foo$bar$baz\"\n```\n\nThe above will return `[foo bar baz]`\n\nThe older `split` function splits a string into a `dict`. It is designed to make\nit easy to use template dot notation for accessing members:\n\n```\n$a := split \"$\" \"foo$bar$baz\"\n```\n\nThe above produces a map with index keys. `{_0: foo, _1: bar, _2: baz}`\n\n```\n$a._0\n```\n\nThe above produces `foo`\n\n### splitn\n\n`splitn` function splits a string into a `dict` with `n` keys. It is designed to make\nit easy to use template dot notation for accessing members:\n\n```\n$a := splitn \"$\" 2 \"foo$bar$baz\"\n```\n\nThe above produces a map with index keys. `{_0: foo, _1: bar$baz}`\n\n```\n$a._0\n```\n\nThe above produces `foo`\n\n### sortAlpha\n\nThe `sortAlpha` function sorts a list of strings into alphabetical (lexicographical)\norder.\n\nIt does _not_ sort in place, but returns a sorted copy of the list, in keeping\nwith the immutability of lists.\n\n## Integer Math Functions\n\nThe following math functions operate on `int64` values.\n\n### add\n\nSum numbers with `add`. Accepts two or more inputs.\n\n```\nadd 1 2 3\n```\n\n### add1\n\nTo increment by 1, use `add1`\n\n### sub\n\nTo subtract, use `sub`\n\n### div\n\nPerform integer division with `div`\n\n### mod\n\nModulo with `mod`\n\n### mul\n\nMultiply with `mul`. Accepts two or more inputs.\n\n```\nmul 1 2 3\n```\n\n### max\n\nReturn the largest of a series of integers:\n\nThis will return `3`:\n\n```\nmax 1 2 3\n```\n\n### min\n\nReturn the smallest of a series of integers.\n\n`min 1 2 3` will return `1`\n\n### floor\n\nReturns the greatest float value less than or equal to input value\n\n`floor 123.9999` will return `123.0`\n\n### ceil\n\nReturns the greatest float value greater than or equal to input value\n\n`ceil 123.001` will return `124.0`\n\n### round\n\nReturns a float value with the remainder rounded to the given number to digits after the decimal point.\n\n`round 123.555555 3` will return `123.556`\n\n### randInt\nReturns a random integer value from min (inclusive) to max (exclusive).\n\n```\nrandInt 12 30\n```\n\nThe above will produce a random number in the range [12,30].\n\n## Integer List Functions\n\n### until\n\nThe `until` function builds a range of integers.\n\n```\nuntil 5\n```\n\nThe above generates the list `[0, 1, 2, 3, 4]`.\n\nThis is useful for looping with `range $i, $e := until 5`.\n\n### untilStep\n\nLike `until`, `untilStep` generates a list of counting integers. But it allows\nyou to define a start, stop, and step:\n\n```\nuntilStep 3 6 2\n```\n\nThe above will produce `[3 5]` by starting with 3, and adding 2 until it is equal\nor greater than 6. This is similar to Python's `range` function.\n\n### seq\n\nWorks like the bash `seq` command.\n* 1 parameter  (end) - will generate all counting integers between 1 and `end` inclusive.\n* 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1.\n* 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`.\n\n```\nseq 5       => 1 2 3 4 5\nseq -3      => 1 0 -1 -2 -3\nseq 0 2     => 0 1 2\nseq 2 -2    => 2 1 0 -1 -2\nseq 0 2 10  => 0 2 4 6 8 10\nseq 0 -2 -5 => 0 -2 -4\n```\n\n## Float Math Functions\n\n### maxf\n\nReturn the largest of a series of floats:\n\nThis will return `3`:\n\n```\nmaxf 1 2.5 3\n```\n\n### minf\n\nReturn the smallest of a series of floats.\n\nThis will return `1.5`:\n\n```\nminf 1.5 2 3\n```\n\n## Date Functions\n\n### now\n\nThe current date/time. Use this in conjunction with other date functions.\n\n### ago\n\nThe `ago` function returns duration from time.Now in seconds resolution.\n\n```\nago .CreatedAt\n```\n\nreturns in `time.Duration` String() format\n\n```\n2h34m7s\n```\n\n### date\n\nThe `date` function formats a date.\n\nFormat the date to YEAR-MONTH-DAY:\n\n```\nnow | date \"2006-01-02\"\n```\n\nDate formatting in Go is a [little bit different](https://pauladamsmith.com/blog/2011/05/go_time.html).\n\nIn short, take this as the base date:\n\n```\nMon Jan 2 15:04:05 MST 2006\n```\n\nWrite it in the format you want. Above, `2006-01-02` is the same date, but\nin the format we want.\n\n### dateInZone\n\nSame as `date`, but with a timezone.\n\n```\ndateInZone \"2006-01-02\" (now) \"UTC\"\n```\n\n### duration\n\nFormats a given amount of seconds as a `time.Duration`.\n\nThis returns 1m35s\n\n```\nduration \"95\"\n```\n\n### durationRound\n\nRounds a given duration to the most significant unit. Strings and `time.Duration`\ngets parsed as a duration, while a `time.Time` is calculated as the duration since.\n\nThis return 2h\n\n```\ndurationRound \"2h10m5s\"\n```\n\nThis returns 3mo\n\n```\ndurationRound \"2400h10m5s\"\n```\n\n### unixEpoch\n\nReturns the seconds since the unix epoch for a `time.Time`.\n\n```\nnow | unixEpoch\n```\n\n### dateModify, mustDateModify\n\nThe `dateModify` takes a modification and a date and returns the timestamp.\n\nSubtract an hour and thirty minutes from the current time:\n\n```\nnow | dateModify \"-1.5h\"\n```\n\nIf the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise.\n\n### htmlDate\n\nThe `htmlDate` function formats a date for inserting into an HTML date picker\ninput field.\n\n```\nnow | htmlDate\n```\n\n### htmlDateInZone\n\nSame as htmlDate, but with a timezone.\n\n```\nhtmlDateInZone (now) \"UTC\"\n```\n\n### toDate, mustToDate\n\n`toDate` converts a string to a date. The first argument is the date layout and\nthe second the date string. If the string can't be convert it returns the zero\nvalue.\n`mustToDate` will return an error in case the string cannot be converted.\n\nThis is useful when you want to convert a string date to another format\n(using pipe). The example below converts \"2017-12-31\" to \"31/12/2017\".\n\n```\ntoDate \"2006-01-02\" \"2017-12-31\" | date \"02/01/2006\"\n```\n\n## Default Functions\n\nSprig provides tools for setting default values for templates.\n\n### default\n\nTo set a simple default value, use `default`:\n\n```\ndefault \"foo\" .Bar\n```\n\nIn the above, if `.Bar` evaluates to a non-empty value, it will be used. But if\nit is empty, `foo` will be returned instead.\n\nThe definition of \"empty\" depends on type:\n\n- Numeric: 0\n- String: \"\"\n- Lists: `[]`\n- Dicts: `{}`\n- Boolean: `false`\n- And always `nil` (aka null)\n\nFor structs, there is no definition of empty, so a struct will never return the\ndefault.\n\n### empty\n\nThe `empty` function returns `true` if the given value is considered empty, and\n`false` otherwise. The empty values are listed in the `default` section.\n\n```\nempty .Foo\n```\n\nNote that in Go template conditionals, emptiness is calculated for you. Thus,\nyou rarely need `if empty .Foo`. Instead, just use `if .Foo`.\n\n### coalesce\n\nThe `coalesce` function takes a list of values and returns the first non-empty\none.\n\n```\ncoalesce 0 1 2\n```\n\nThe above returns `1`.\n\nThis function is useful for scanning through multiple variables or values:\n\n```\ncoalesce .name .parent.name \"Matt\"\n```\n\nThe above will first check to see if `.name` is empty. If it is not, it will return\nthat value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness.\nFinally, if both `.name` and `.parent.name` are empty, it will return `Matt`.\n\n### all\n\nThe `all` function takes a list of values and returns true if all values are non-empty.\n\n```\nall 0 1 2\n```\n\nThe above returns `false`.\n\nThis function is useful for evaluating multiple conditions of variables or values:\n\n```\nall (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Method \"POST\")\n```\n\nThe above will check http.Request is POST with tls 1.3 and http/2.\n\n### any\n\nThe `any` function takes a list of values and returns true if any value is non-empty.\n\n```\nany 0 1 2\n```\n\nThe above returns `true`.\n\nThis function is useful for evaluating multiple conditions of variables or values:\n\n```\nany (eq .Request.Method \"GET\") (eq .Request.Method \"POST\") (eq .Request.Method \"OPTIONS\")\n```\n\nThe above will check http.Request method is one of GET/POST/OPTIONS.\n\n### fromJSON, mustFromJSON\n\n`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string.\n`mustFromJSON` will return an error in case the JSON is invalid.\n\n```\nfromJSON \"{\\\"foo\\\": 55}\"\n```\n\n### toJSON, mustToJSON\n\nThe `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string.\n`mustToJSON` will return an error in case the item cannot be encoded in JSON.\n\n```\ntoJSON .Item\n```\n\nThe above returns JSON string representation of `.Item`.\n\n### toPrettyJSON, mustToPrettyJSON\n\nThe `toPrettyJSON` function encodes an item into a pretty (indented) JSON string.\n\n```\ntoPrettyJSON .Item\n```\n\nThe above returns indented JSON string representation of `.Item`.\n\n### toRawJSON, mustToRawJSON\n\nThe `toRawJSON` function encodes an item into JSON string with HTML characters unescaped.\n\n```\ntoRawJSON .Item\n```\n\nThe above returns unescaped JSON string representation of `.Item`.\n\n### ternary\n\nThe `ternary` function takes two values, and a test value. If the test value is\ntrue, the first value will be returned. If the test value is empty, the second\nvalue will be returned. This is similar to the c ternary operator.\n\n#### true test value\n\n```\nternary \"foo\" \"bar\" true\n```\n\nor\n\n```\ntrue | ternary \"foo\" \"bar\"\n```\n\nThe above returns `\"foo\"`.\n\n#### false test value\n\n```\nternary \"foo\" \"bar\" false\n```\n\nor\n\n```\nfalse | ternary \"foo\" \"bar\"\n```\n\nThe above returns `\"bar\"`.\n\n## Encoding Functions\n\nSprig has the following encoding and decoding functions:\n\n- `b64enc`/`b64dec`: Encode or decode with Base64\n- `b32enc`/`b32dec`: Encode or decode with Base32\n\n## Lists and List Functions\n\nSprig provides a simple `list` type that can contain arbitrary sequential lists\nof data. This is similar to arrays or slices, but lists are designed to be used\nas immutable data types.\n\nCreate a list of integers:\n\n```\n$myList := list 1 2 3 4 5\n```\n\nThe above creates a list of `[1 2 3 4 5]`.\n\n### first, mustFirst\n\nTo get the head item on a list, use `first`.\n\n`first $myList` returns `1`\n\n`first` panics if there is a problem while `mustFirst` returns an error to the\ntemplate engine if there is a problem.\n\n### rest, mustRest\n\nTo get the tail of the list (everything but the first item), use `rest`.\n\n`rest $myList` returns `[2 3 4 5]`\n\n`rest` panics if there is a problem while `mustRest` returns an error to the\ntemplate engine if there is a problem.\n\n### last, mustLast\n\nTo get the last item on a list, use `last`:\n\n`last $myList` returns `5`. This is roughly analogous to reversing a list and\nthen calling `first`.\n\n`last` panics if there is a problem while `mustLast` returns an error to the\ntemplate engine if there is a problem.\n\n### initial, mustInitial\n\nThis compliments `last` by returning all _but_ the last element.\n`initial $myList` returns `[1 2 3 4]`.\n\n`initial` panics if there is a problem while `mustInitial` returns an error to the\ntemplate engine if there is a problem.\n\n### append, mustAppend\n\nAppend a new item to an existing list, creating a new list.\n\n```\n$new = append $myList 6\n```\n\nThe above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered.\n\n`append` panics if there is a problem while `mustAppend` returns an error to the\ntemplate engine if there is a problem.\n\n### prepend, mustPrepend\n\nPush an element onto the front of a list, creating a new list.\n\n```\nprepend $myList 0\n```\n\nThe above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered.\n\n`prepend` panics if there is a problem while `mustPrepend` returns an error to the\ntemplate engine if there is a problem.\n\n### concat\n\nConcatenate arbitrary number of lists into one.\n\n```\nconcat $myList ( list 6 7 ) ( list 8 )\n```\n\nThe above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered.\n\n### reverse, mustReverse\n\nProduce a new list with the reversed elements of the given list.\n\n```\nreverse $myList\n```\n\nThe above would generate the list `[5 4 3 2 1]`.\n\n`reverse` panics if there is a problem while `mustReverse` returns an error to the\ntemplate engine if there is a problem.\n\n### uniq, mustUniq\n\nGenerate a list with all of the duplicates removed.\n\n```\nlist 1 1 1 2 | uniq\n```\n\nThe above would produce `[1 2]`\n\n`uniq` panics if there is a problem while `mustUniq` returns an error to the\ntemplate engine if there is a problem.\n\n### without, mustWithout\n\nThe `without` function filters items out of a list.\n\n```\nwithout $myList 3\n```\n\nThe above would produce `[1 2 4 5]`\n\nWithout can take more than one filter:\n\n```\nwithout $myList 1 3 5\n```\n\nThat would produce `[2 4]`\n\n`without` panics if there is a problem while `mustWithout` returns an error to the\ntemplate engine if there is a problem.\n\n### has, mustHas\n\nTest to see if a list has a particular element.\n\n```\nhas 4 $myList\n```\n\nThe above would return `true`, while `has \"hello\" $myList` would return false.\n\n`has` panics if there is a problem while `mustHas` returns an error to the\ntemplate engine if there is a problem.\n\n### compact, mustCompact\n\nAccepts a list and removes entries with empty values.\n\n```\n$list := list 1 \"a\" \"foo\" \"\"\n$copy := compact $list\n```\n\n`compact` will return a new list with the empty (i.e., \"\") item removed.\n\n`compact` panics if there is a problem and `mustCompact` returns an error to the\ntemplate engine if there is a problem.\n\n### slice, mustSlice\n\nTo get partial elements of a list, use `slice list [n] [m]`. It is\nequivalent of `list[n:m]`.\n\n- `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`.\n- `slice $myList 3` returns `[4 5]`. It is same as `myList[3:]`.\n- `slice $myList 1 3` returns `[2 3]`. It is same as `myList[1:3]`.\n- `slice $myList 0 3` returns `[1 2 3]`. It is same as `myList[:3]`.\n\n`slice` panics if there is a problem while `mustSlice` returns an error to the\ntemplate engine if there is a problem.\n\n### chunk\n\nTo split a list into chunks of given size, use `chunk size list`. This is useful for pagination.\n\n```\nchunk 3 (list 1 2 3 4 5 6 7 8)\n```\n\nThis produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`.\n\n### A Note on List Internals\n\nA list is implemented in Go as a `[]any`. For Go developers embedding\nSprig, you may pass `[]any` items into your template context and be\nable to use all of the `list` functions on those items.\n\n## Dictionaries and Dict Functions\n\nSprig provides a key/value storage type called a `dict` (short for \"dictionary\",\nas in Python). A `dict` is an _unorder_ type.\n\nThe key to a dictionary **must be a string**. However, the value can be any\ntype, even another `dict` or `list`.\n\nUnlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will\nmodify the contents of a dictionary.\n\n### dict\n\nCreating dictionaries is done by calling the `dict` function and passing it a\nlist of pairs.\n\nThe following creates a dictionary with three items:\n\n```\n$myDict := dict \"name1\" \"value1\" \"name2\" \"value2\" \"name3\" \"value 3\"\n```\n\n### get\n\nGiven a map and a key, get the value from the map.\n\n```\nget $myDict \"name1\"\n```\n\nThe above returns `\"value1\"`\n\nNote that if the key is not found, this operation will simply return `\"\"`. No error\nwill be generated.\n\n### set\n\nUse `set` to add a new key/value pair to a dictionary.\n\n```\n$_ := set $myDict \"name4\" \"value4\"\n```\n\nNote that `set` _returns the dictionary_ (a requirement of Go template functions),\nso you may need to trap the value as done above with the `$_` assignment.\n\n### unset\n\nGiven a map and a key, delete the key from the map.\n\n```\n$_ := unset $myDict \"name4\"\n```\n\nAs with `set`, this returns the dictionary.\n\nNote that if the key is not found, this operation will simply return. No error\nwill be generated.\n\n### hasKey\n\nThe `hasKey` function returns `true` if the given dict contains the given key.\n\n```\nhasKey $myDict \"name1\"\n```\n\nIf the key is not found, this returns `false`.\n\n### pluck\n\nThe `pluck` function makes it possible to give one key and multiple maps, and\nget a list of all of the matches:\n\n```\npluck \"name1\" $myDict $myOtherDict\n```\n\nThe above will return a `list` containing every found value (`[value1 otherValue1]`).\n\nIf the give key is _not found_ in a map, that map will not have an item in the\nlist (and the length of the returned list will be less than the number of dicts\nin the call to `pluck`.\n\nIf the key is _found_ but the value is an empty value, that value will be\ninserted.\n\nA common idiom in Sprig templates is to uses `pluck... | first` to get the first\nmatching key out of a collection of dictionaries.\n\n### dig\n\nThe `dig` function traverses a nested set of dicts, selecting keys from a list\nof values. It returns a default value if any of the keys are not found at the\nassociated dict.\n\n```\ndig \"user\" \"role\" \"humanName\" \"guest\" $dict\n```\n\nGiven a dict structured like\n```\n{\n  user: {\n    role: {\n      humanName: \"curator\"\n    }\n  }\n}\n```\n\nthe above would return `\"curator\"`. If the dict lacked even a `user` field,\nthe result would be `\"guest\"`.\n\nDig can be very useful in cases where you'd like to avoid guard clauses,\nespecially since Go's template package's `and` doesn't shortcut. For instance\n`and a.maybeNil a.maybeNil.iNeedThis` will always evaluate\n`a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.)\n\n`dig` accepts its dict argument last in order to support pipelining.\n\n### keys\n\nThe `keys` function will return a `list` of all of the keys in one or more `dict`\ntypes. Since a dictionary is _unordered_, the keys will not be in a predictable order.\nThey can be sorted with `sortAlpha`.\n\n```\nkeys $myDict | sortAlpha\n```\n\nWhen supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`\nfunction along with `sortAlpha` to get a unique, sorted list of keys.\n\n```\nkeys $myDict $myOtherDict | uniq | sortAlpha\n```\n\n### pick\n\nThe `pick` function selects just the given keys out of a dictionary, creating a\nnew `dict`.\n\n```\n$new := pick $myDict \"name1\" \"name2\"\n```\n\nThe above returns `{name1: value1, name2: value2}`\n\n### omit\n\nThe `omit` function is similar to `pick`, except it returns a new `dict` with all\nthe keys that _do not_ match the given keys.\n\n```\n$new := omit $myDict \"name1\" \"name3\"\n```\n\nThe above returns `{name2: value2}`\n\n### values\n\nThe `values` function is similar to `keys`, except it returns a new `list` with\nall the values of the source `dict` (only one dictionary is supported).\n\n```\n$vals := values $myDict\n```\n\nThe above returns `list[\"value1\", \"value2\", \"value 3\"]`. Note that the `values`\nfunction gives no guarantees about the result ordering- if you care about this,\nthen use `sortAlpha`.\n\n## Type Conversion Functions\n\nThe following type conversion functions are provided by Sprig:\n\n- `atoi`: Convert a string to an integer.\n- `float64`: Convert to a `float64`.\n- `int`: Convert to an `int` at the system's width.\n- `int64`: Convert to an `int64`.\n- `toDecimal`: Convert a unix octal to a `int64`.\n- `toString`: Convert to a string.\n- `toStrings`: Convert a list, slice, or array to a list of strings.\n\nOnly `atoi` requires that the input be a specific type. The others will attempt\nto convert from any type to the destination type. For example, `int64` can convert\nfloats to ints, and it can also convert strings to ints.\n\n### toStrings\n\nGiven a list-like collection, produce a slice of strings.\n\n```\nlist 1 2 3 | toStrings\n```\n\nThe above converts `1` to `\"1\"`, `2` to `\"2\"`, and so on, and then returns\nthem as a list.\n\n### toDecimal\n\nGiven a unix octal permission, produce a decimal.\n\n```\n\"0777\" | toDecimal\n```\n\nThe above converts `0777` to `511` and returns the value as an int64.\n\n## Path and Filepath Functions\n\nWhile Sprig does not grant access to the filesystem, it does provide functions\nfor working with strings that follow file path conventions.\n\n### Paths\n\nPaths separated by the slash character (`/`), processed by the `path` package.\n\nExamples:\n\n* The [Linux](https://en.wikipedia.org/wiki/Linux) and\n  [MacOS](https://en.wikipedia.org/wiki/MacOS)\n  [filesystems](https://en.wikipedia.org/wiki/File_system):\n  `/home/user/file`, `/etc/config`;\n* The path component of\n  [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier):\n  `https://example.com/some/content/`, `ftp://example.com/file/`.\n\n#### base\n\nReturn the last element of a path.\n\n```\nbase \"foo/bar/baz\"\n```\n\nThe above prints \"baz\".\n\n#### dir\n\nReturn the directory, stripping the last part of the path. So `dir \"foo/bar/baz\"`\nreturns `foo/bar`.\n\n#### clean\n\nClean up a path.\n\n```\nclean \"foo/bar/../baz\"\n```\n\nThe above resolves the `..` and returns `foo/baz`.\n\n#### ext\n\nReturn the file extension.\n\n```\next \"foo.bar\"\n```\n\nThe above returns `.bar`.\n\n#### isAbs\n\nTo check whether a path is absolute, use `isAbs`.\n\n### Filepaths\n\nPaths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package.\n\nThese are the recommended functions to use when parsing paths of local filesystems, usually when dealing with local files, directories, etc.\n\nExamples:\n\n* Running on Linux or MacOS the filesystem path is separated by the slash character (`/`):\n  `/home/user/file`, `/etc/config`;\n* Running on [Windows](https://en.wikipedia.org/wiki/Microsoft_Windows)\n  the filesystem path is separated by the backslash character (`\\`):\n  `C:\\Users\\Username\\`, `C:\\Program Files\\Application\\`;\n\n#### osBase\n\nReturn the last element of a filepath.\n\n```\nosBase \"/foo/bar/baz\"\nosBase \"C:\\\\foo\\\\bar\\\\baz\"\n```\n\nThe above prints \"baz\" on Linux and Windows, respectively.\n\n#### osDir\n\nReturn the directory, stripping the last part of the path. So `osDir \"/foo/bar/baz\"`\nreturns `/foo/bar` on Linux, and `osDir \"C:\\\\foo\\\\bar\\\\baz\"`\nreturns `C:\\\\foo\\\\bar` on Windows.\n\n#### osClean\n\nClean up a path.\n\n```\nosClean \"/foo/bar/../baz\"\nosClean \"C:\\\\foo\\\\bar\\\\..\\\\baz\"\n```\n\nThe above resolves the `..` and returns `foo/baz` on Linux and `C:\\\\foo\\\\baz` on Windows.\n\n#### osExt\n\nReturn the file extension.\n\n```\nosExt \"/foo.bar\"\nosExt \"C:\\\\foo.bar\"\n```\n\nThe above returns `.bar` on Linux and Windows, respectively.\n\n#### osIsAbs\n\nTo check whether a file path is absolute, use `osIsAbs`.\n\n## Flow Control Functions\n\n### fail\n\nUnconditionally returns an empty `string` and an `error` with the specified\ntext. This is useful in scenarios where other conditionals have determined that\ntemplate rendering should fail.\n\n```\nfail \"Please accept the end user license agreement\"\n```\n\n## Reflection Functions\n\nSprig provides rudimentary reflection tools. These help advanced template\ndevelopers understand the underlying Go type information for a particular value.\n\nGo has several primitive _kinds_, like `string`, `slice`, `int64`, and `bool`.\n\nGo has an open _type_ system that allows developers to create their own types.\n\nSprig provides a set of functions for each.\n\n### Kind Functions\n\nThere are two Kind functions: `kindOf` returns the kind of an object.\n\n```\nkindOf \"hello\"\n```\n\nThe above would return `string`. For simple tests (like in `if` blocks), the\n`kindIs` function will let you verify that a value is a particular kind:\n\n```\nkindIs \"int\" 123\n```\n\nThe above will return `true`\n\n### Type Functions\n\nTypes are slightly harder to work with, so there are three different functions:\n\n- `typeOf` returns the underlying type of a value: `typeOf $foo`\n- `typeIs` is like `kindIs`, but for types: `typeIs \"*io.Buffer\" $myVal`\n- `typeIsLike` works as `typeIs`, except that it also dereferences pointers.\n\n**Note:** None of these can test whether or not something implements a given\ninterface, since doing so would require compiling the interface in ahead of time.\n\n### deepEqual\n\n`deepEqual` returns true if two values are [\"deeply equal\"](https://golang.org/pkg/reflect/#DeepEqual)\n\nWorks for non-primitive types as well (compared to the built-in `eq`).\n\n```\ndeepEqual (list 1 2 3) (list 1 2 3)\n```\n\nThe above will return `true`\n\n## Cryptographic and Security Functions\n\nSprig provides a couple of advanced cryptographic functions.\n\n### sha1sum\n\nThe `sha1sum` function receives a string, and computes it's SHA1 digest.\n\n```\nsha1sum \"Hello world!\"\n```\n\n### sha256sum\n\nThe `sha256sum` function receives a string, and computes it's SHA256 digest.\n\n```\nsha256sum \"Hello world!\"\n```\n\nThe above will compute the SHA 256 sum in an \"ASCII armored\" format that is\nsafe to print.\n\n### sha512sum\n\nThe `sha512sum` function receives a string, and computes it's SHA512 digest.\n\n```\nsha512sum \"Hello world!\"\n```\n\nThe above will compute the SHA 512 sum in an \"ASCII armored\" format that is\nsafe to print.\n\n### adler32sum\n\nThe `adler32sum` function receives a string, and computes its Adler-32 checksum.\n\n```\nadler32sum \"Hello world!\"\n```\n\n## URL Functions\n\n### urlParse\nParses string for URL and produces dict with URL parts\n\n```\nurlParse \"http://admin:secret@server.com:8080/api?list=false#anchor\"\n```\n\nThe above returns a dict, containing URL object:\n```yaml\nscheme:   'http'\nhost:     'server.com:8080'\npath:     '/api'\nquery:    'list=false'\nopaque:   nil\nfragment: 'anchor'\nuserinfo: 'admin:secret'\n```\n\nFor more info, check https://golang.org/pkg/net/url/#URL\n\n### urlJoin\nJoins map (produced by `urlParse`) to produce URL string\n\n```\nurlJoin (dict \"fragment\" \"fragment\" \"host\" \"host:80\" \"path\" \"/path\" \"query\" \"query\" \"scheme\" \"http\")\n```\n\nThe above returns the following string:\n```\nproto://host:80/path?query#fragment\n```\n"
  },
  {
    "path": "docs/publish.md",
    "content": "# Publishing\nPublishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).\nTopics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick \nsomething that's not easily guessable.\n\nHere's an example showing how to publish a simple message using a POST request:\n\n=== \"Command line (curl)\"\n    ```\n    curl -d \"Backup successful 😀\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish mytopic \"Backup successful 😀\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n\n    Backup successful 😀\n    ```\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic', {\n      method: 'POST', // PUT works too\n      body: 'Backup successful 😀'\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    http.Post(\"https://ntfy.sh/mytopic\", \"text/plain\",\n        strings.NewReader(\"Backup successful 😀\"))\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mytopic\"\n      Body = \"Backup successful\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mytopic\", \n        data=\"Backup successful 😀\".encode(encoding='utf-8'))\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' => 'Content-Type: text/plain',\n            'content' => 'Backup successful 😀'\n        ]\n    ]));\n    ```\n\nIf you have the [Android app](subscribe/phone.md) installed on your phone, this will create a notification that looks like this:\n\n<figure markdown>\n  ![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }\n  <figcaption>Android notification</figcaption>\n</figure>\n\nThere are more features related to publishing messages: You can set a [notification priority](#message-priority), \na [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an example that uses some of them at together:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -H \"Title: Unauthorized access detected\" \\\n      -H \"Priority: urgent\" \\\n      -H \"Tags: warning,skull\" \\\n      -d \"Remote access to phils-laptop detected. Act right away.\" \\\n      ntfy.sh/phil_alerts\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --title \"Unauthorized access detected\" \\\n        --tags warning,skull \\\n        --priority urgent \\\n        mytopic \\\n        \"Remote access to phils-laptop detected. Act right away.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /phil_alerts HTTP/1.1\n    Host: ntfy.sh\n    Title: Unauthorized access detected\n    Priority: urgent\n    Tags: warning,skull\n    \n    Remote access to phils-laptop detected. Act right away.\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/phil_alerts', {\n        method: 'POST', // PUT works too\n        body: 'Remote access to phils-laptop detected. Act right away.',\n        headers: {\n            'Title': 'Unauthorized access detected',\n            'Priority': 'urgent',\n            'Tags': 'warning,skull'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n\treq, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/phil_alerts\",\n\t\tstrings.NewReader(\"Remote access to phils-laptop detected. Act right away.\"))\n\treq.Header.Set(\"Title\", \"Unauthorized access detected\")\n\treq.Header.Set(\"Priority\", \"urgent\")\n\treq.Header.Set(\"Tags\", \"warning,skull\")\n\thttp.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/phil_alerts\"\n      Headers = @{\n        Title = \"Unauthorized access detected\"\n        Priority = \"urgent\"\n        Tags = \"warning,skull\"\n      }\n      Body = \"Remote access to phils-laptop detected. Act right away.\"\n    }\n    Invoke-RestMethod @Request\n    ```\n    \n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/phil_alerts\",\n        data=\"Remote access to phils-laptop detected. Act right away.\",\n        headers={\n            \"Title\": \"Unauthorized access detected\",\n            \"Priority\": \"urgent\",\n            \"Tags\": \"warning,skull\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Title: Unauthorized access detected\\r\\n\" .\n                \"Priority: urgent\\r\\n\" .\n                \"Tags: warning,skull\",\n            'content' => 'Remote access to phils-laptop detected. Act right away.'\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![priority notification](static/img/priority-notification.png){ width=500 }\n  <figcaption>Urgent notification with tags and title</figcaption>\n</figure>\n\nYou can also do multi-line messages. Here's an example using a [click action](#click-action), an [action button](#action-buttons),\nan [external image attachment](#attach-file-from-a-url) and [email publishing](#e-mail-publishing):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -H \"Click: https://home.nest.com/\" \\\n      -H \"Attach: https://nest.com/view/yAxkasd.jpg\" \\\n      -H \"Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true\" \\\n      -H \"Email: phil@example.com\" \\\n      -d \"There's someone at the door. 🐶\n   \n    Please check if it's a good boy or a hooman. \n    Doggies have been known to ring the doorbell.\" \\\n      ntfy.sh/mydoorbell\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n\t    --click=\"https://home.nest.com/\" \\\n        --attach=\"https://nest.com/view/yAxkasd.jpg\" \\\n        --actions=\"http, Open door, https://api.nest.com/open/yAxkasd, clear=true\" \\\n        --email=\"phil@example.com\" \\\n        mydoorbell \\\n        \"There's someone at the door. 🐶\n   \n    Please check if it's a good boy or a hooman. \n    Doggies have been known to ring the doorbell.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mydoorbell HTTP/1.1\n    Host: ntfy.sh\n    Click: https://home.nest.com/\n    Attach: https://nest.com/view/yAxkasd.jpg\n    Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true\n    Email: phil@example.com\n    \n    There's someone at the door. 🐶\n   \n    Please check if it's a good boy or a hooman. \n    Doggies have been known to ring the doorbell.\n    ```\n    \n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mydoorbell', {\n        method: 'POST', // PUT works too\n        headers: {\n            'Click': 'https://home.nest.com/',\n            'Attach': 'https://nest.com/view/yAxkasd.jpg',\n\t        'Actions': 'http, Open door, https://api.nest.com/open/yAxkasd, clear=true',\n\t        'Email': 'phil@example.com'\n        },\n        body: `There's someone at the door. 🐶\n       \n    Please check if it's a good boy or a hooman. \n    Doggies have been known to ring the doorbell.`,\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n\treq, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mydoorbell\",\n\t\tstrings.NewReader(`There's someone at the door. 🐶\n   \n    Please check if it's a good boy or a hooman. \n    Doggies have been known to ring the doorbell.`))\n\treq.Header.Set(\"Click\", \"https://home.nest.com/\")\n\treq.Header.Set(\"Attach\", \"https://nest.com/view/yAxkasd.jpg\")\n\treq.Header.Set(\"Actions\", \"http, Open door, https://api.nest.com/open/yAxkasd, clear=true\")\n\treq.Header.Set(\"Email\", \"phil@example.com\")\n\thttp.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mydoorbell\"\n      Headers = @{\n        Click = \"https://home.nest.com\"\n        Attach = \"https://nest.com/view/yAxksd.jpg\"\n        Actions = \"http, Open door, https://api.nest.com/open/yAxkasd, clear=true\"\n        Email = \"phil@example.com\"\n      }\n      Body = \"There's someone at the door. 🐶`n\n      `n\n      Please check if it's a good boy or a hooman.`n\n      Doggies have been known to ring the doorbell.`n\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mydoorbell\",\n        data=\"\"\"There's someone at the door. 🐶\n\n    Please check if it's a good boy or a hooman.\n    Doggies have been known to ring the doorbell.\"\"\".encode('utf-8'),\n        headers={\n            \"Click\": \"https://home.nest.com/\",\n            \"Attach\": \"https://nest.com/view/yAxkasd.jpg\",\n            \"Actions\": \"http, Open door, https://api.nest.com/open/yAxkasd, clear=true\",\n            \"Email\": \"phil@example.com\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mydoorbell', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Click: https://home.nest.com/\\r\\n\" .\n                \"Attach: https://nest.com/view/yAxkasd.jpg\\r\\n\" .\n                \"Actions\": \"http, Open door, https://api.nest.com/open/yAxkasd, clear=true\\r\\n\" .\n                \"Email\": \"phil@example.com\\r\\n\",\n            'content' => 'There\\'s someone at the door. 🐶\n   \n    Please check if it\\'s a good boy or a hooman.\n    Doggies have been known to ring the doorbell.'\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![priority notification](static/img/android-screenshot-notification-multiline.jpg){ width=500 }\n  <figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>\n</figure>\n\n## Message title\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nThe notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title, \nyou can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).\n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"X-Title: Dogs are better than cats\" -d \"Oh my ...\" ntfy.sh/controversial\n    curl -H \"Title: Dogs are better than cats\" -d \"Oh my ...\" ntfy.sh/controversial\n    curl -H \"t: Dogs are better than cats\" -d \"Oh my ...\" ntfy.sh/controversial\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        -t \"Dogs are better than cats\" \\\n        controversial \"Oh my ...\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /controversial HTTP/1.1\n    Host: ntfy.sh\n    Title: Dogs are better than cats\n    \n    Oh my ...\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/controversial', {\n        method: 'POST',\n        body: 'Oh my ...',\n        headers: { 'Title': 'Dogs are better than cats' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/controversial\", strings.NewReader(\"Oh my ...\"))\n    req.Header.Set(\"Title\", \"Dogs are better than cats\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/controversial\"\n      Headers = @{\n        Title = \"Dogs are better than cats\"\n      }\n      Body = \"Oh my ...\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/controversial\",\n        data=\"Oh my ...\",\n        headers={ \"Title\": \"Dogs are better than cats\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/controversial', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Title: Dogs are better than cats\",\n            'content' => 'Oh my ...'\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![notification with title](static/img/notification-with-title.png){ width=500 }\n  <figcaption>Detail view of notification with title</figcaption>\n</figure>\n\n!!! info\n    ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).\n    If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title)\n    as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),\n    or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).\n\n## Message priority\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nAll messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom\nnotification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).\n\nThe following priorities exist:\n\n| Priority             | Icon                                       | ID  | Name           | Description                                                                                            |\n|----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------|\n| Max priority         | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification.                 |\n| High priority        | ![min priority](static/img/priority-4.svg) | `4` | `high`         | Long vibration burst, default notification sound with a pop-over notification.                         |\n| **Default priority** | *(none)*                                   | `3` | `default`      | Short default vibration and sound. Default notification behavior.                                      |\n| Low priority         | ![min priority](static/img/priority-2.svg) | `2` | `low`          | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |\n| Min priority         | ![min priority](static/img/priority-1.svg) | `1` | `min`          | No vibration or sound. The notification will be under the fold in \"Other notifications\".               |\n\nYou can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`).\n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"X-Priority: 5\" -d \"An urgent message\" ntfy.sh/phil_alerts\n    curl -H \"Priority: low\" -d \"Low priority message\" ntfy.sh/phil_alerts\n    curl -H p:4 -d \"A high priority message\" ntfy.sh/phil_alerts\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\ \n        -p 5 \\\n        phil_alerts An urgent message\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /phil_alerts HTTP/1.1\n    Host: ntfy.sh\n    Priority: 5\n\n    An urgent message\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/phil_alerts', {\n        method: 'POST',\n        body: 'An urgent message',\n        headers: { 'Priority': '5' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/phil_alerts\", strings.NewReader(\"An urgent message\"))\n    req.Header.Set(\"Priority\", \"5\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = 'POST'\n      URI = \"https://ntfy.sh/phil_alerts\"\n      Headers = @{\n        Priority = \"5\"\n      }\n      Body = \"An urgent message\"\n    }\n    Invoke-RestMethod @Request\n    ```\n    \n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/phil_alerts\",\n        data=\"An urgent message\",\n        headers={ \"Priority\": \"5\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Priority: 5\",\n            'content' => 'An urgent message'\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![priority notification](static/img/priority-detail-overview.png){ width=500 }\n  <figcaption>Detail view of priority notifications</figcaption>\n</figure>\n\n## Tags & emojis 🥳 🎉\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can tag messages with emojis and other relevant strings:\n\n* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended \n  to title or message.\n* **Other tags:** If a tag doesn't match, it will be listed below the notification. \n\nThis feature is useful for things like warnings (⚠️, ️🚨, or 🚩), but also to simply tag messages otherwise (e.g. script \nnames, hostnames, etc.). Use [the emoji short code list](emojis.md) to figure out what tags can be converted to emojis. \nHere's an **excerpt of emojis** I've found very useful in alert messages:\n\n<table class=\"remove-md-box\"><tr>\n<td>\n    <table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody>\n    <tr><td><code>+1</code></td><td>👍</td></tr>\n    <tr><td><code>partying_face</code></td><td>🥳</td></tr>\n    <tr><td><code>tada</code></td><td>🎉</td></tr>\n    <tr><td><code>heavy_check_mark</code></td><td>✔️</td></tr>\n    <tr><td><code>loudspeaker</code></td><td>📢</td></tr>\n    <tr><td>...</td><td>...</td></tr>\n    </tbody></table>\n</td>\n<td>\n    <table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody> \n    <tr><td><code>-1</code></td><td>👎️</td></tr>\n    <tr><td><code>warning</code></td><td>⚠️</td></tr>\n    <tr><td><code>rotating_light</code></td><td>️🚨</td></tr>\n    <tr><td><code>triangular_flag_on_post</code></td><td>🚩</td></tr>\n    <tr><td><code>skull</code></td><td>💀</td></tr>\n    <tr><td>...</td><td>...</td></tr>\n    </tbody></table>\n</td>\n<td>\n    <table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody>\n    <tr><td><code>facepalm</code></td><td>🤦</td></tr>\n    <tr><td><code>no_entry</code></td><td>⛔</td></tr>\n    <tr><td><code>no_entry_sign</code></td><td>🚫</td></tr>\n    <tr><td><code>cd</code></td><td>💿</td></tr> \n    <tr><td><code>computer</code></td><td>💻</td></tr>\n    <tr><td>...</td><td>...</td></tr>\n    </tbody></table>\n</td>\n</tr></table>\n\nYou can set tags with the `X-Tags` header (or any of its aliases: `Tags`, `tag`, or `ta`). Specify multiple tags by separating\nthem with a comma, e.g. `tag1,tag2,tag3`.\n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"X-Tags: warning,mailsrv13,daily-backup\" -d \"Backup of mailsrv13 failed\" ntfy.sh/backups\n    curl -H \"Tags: horse,unicorn\" -d \"Unicorns are just horses with unique horns\" ntfy.sh/backups\n    curl -H ta:dog -d \"Dogs are awesome\" ntfy.sh/backups\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --tags=warning,mailsrv13,daily-backup \\\n        backups \"Backup of mailsrv13 failed\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /backups HTTP/1.1\n    Host: ntfy.sh\n    Tags: warning,mailsrv13,daily-backup\n    \n    Backup of mailsrv13 failed\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/backups', {\n        method: 'POST',\n        body: 'Backup of mailsrv13 failed',\n        headers: { 'Tags': 'warning,mailsrv13,daily-backup' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/backups\", strings.NewReader(\"Backup of mailsrv13 failed\"))\n    req.Header.Set(\"Tags\", \"warning,mailsrv13,daily-backup\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/backups\"\n      Headers = @{\n        Tags = \"warning,mailsrv13,daily-backup\"\n      }\n      Body = \"Backup of mailsrv13 failed\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/backups\",\n        data=\"Backup of mailsrv13 failed\",\n        headers={ \"Tags\": \"warning,mailsrv13,daily-backup\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/backups', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Tags: warning,mailsrv13,daily-backup\",\n            'content' => 'Backup of mailsrv13 failed'\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![priority notification](static/img/notification-with-tags.png){ width=500 }\n  <figcaption>Detail view of notifications with tags</figcaption>\n</figure>\n\n!!! info\n    ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).\n    If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags\n    as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),\n    or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).\n\n## Markdown formatting\n_Supported on:_ :material-android: :material-firefox:\n\nYou can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use \n**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):\n\n- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`)\n- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`)\n- [Images](https://www.markdownguide.org/basic-syntax/#images) (`![some image](https://bing.com/logo.png)`)\n- [Code blocks](https://www.markdownguide.org/basic-syntax/#code-blocks) (` ```code blocks``` `) and [inline code](https://www.markdownguide.org/basic-syntax/#inline-code) (`` `inline code` ``)\n- [Headings](https://www.markdownguide.org/basic-syntax/#headings) (`# headings`, `## headings`, etc.)\n- [Lists](https://www.markdownguide.org/basic-syntax/#lists) (`- lists`, `1. lists`, etc.)\n- [Blockquotes](https://www.markdownguide.org/basic-syntax/#blockquotes) (`> blockquotes`)\n- [Horizontal rules](https://www.markdownguide.org/basic-syntax/#horizontal-rules) (`---`)\n\nBy default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of\nits aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`.\nHere's an example of how to enable Markdown formatting:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"Look ma, **bold text**, *italics*, ...\" \\\n        -H \"Markdown: yes\" \\\n        ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --markdown \\\n        mytopic \\\n        \"Look ma, **bold text**, *italics*, ...\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    Markdown: yes\n\n    Look ma, **bold text**, *italics*, ...\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic', {\n      method: 'POST', // PUT works too\n      body: 'Look ma, **bold text**, *italics*, ...',\n      headers: { 'Markdown': 'yes' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    http.Post(\"https://ntfy.sh/mytopic\", \"text/markdown\",\n        strings.NewReader(\"Look ma, **bold text**, *italics*, ...\"))\n\n    // or\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic\", \n        strings.NewReader(\"Look ma, **bold text**, *italics*, ...\"))\n    req.Header.Set(\"Markdown\", \"yes\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mytopic\"\n      Body = \"Look ma, **bold text**, *italics*, ...\"\n      Headers = @{\n        Markdown = \"yes\"\n      }\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mytopic\", \n        data=\"Look ma, **bold text**, *italics*, ...\",\n        headers={ \"Markdown\": \"yes\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' => 'Content-Type: text/markdown', // !\n            'content' => 'Look ma, **bold text**, *italics*, ...'\n        ]\n    ]));\n    ```\n\nHere's what that looks like in the web app:\n\n<figure markdown>\n  ![markdown](static/img/web-markdown.png){ width=500 }\n  <figcaption>Markdown formatting in the web app</figcaption>\n</figure>\n\n## Click action\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can define which URL to open when a notification is clicked. This may be useful if your notification is related \nto a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open\nthe web browser (or the app) and open the website.\n\nTo define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`).\nIf you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled\nby another app, the responsible app may open. \n\nExamples:\n\n* `http://` or `https://` will open your browser (or an app if it registered for a URL)\n* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`\n* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`\n* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`\n* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..`\n* ...\n\nHere's an example that will open Reddit when the notification is clicked:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"New messages on Reddit\" \\\n        -H \"Click: https://www.reddit.com/message/messages\" \\\n        ntfy.sh/reddit_alerts\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --click=\"https://www.reddit.com/message/messages\" \\\n        reddit_alerts \"New messages on Reddit\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /reddit_alerts HTTP/1.1\n    Host: ntfy.sh\n    Click: https://www.reddit.com/message/messages \n\n    New messages on Reddit\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/reddit_alerts', {\n        method: 'POST',\n        body: 'New messages on Reddit',\n        headers: { 'Click': 'https://www.reddit.com/message/messages' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/reddit_alerts\", strings.NewReader(\"New messages on Reddit\"))\n    req.Header.Set(\"Click\", \"https://www.reddit.com/message/messages\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/reddit_alerts\"\n      Headers = @{ Click=\"https://www.reddit.com/message/messages\" }\n      Body = \"New messages on Reddit\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/reddit_alerts\",\n        data=\"New messages on Reddit\",\n        headers={ \"Click\": \"https://www.reddit.com/message/messages\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Click: https://www.reddit.com/message/messages\",\n            'content' => 'New messages on Reddit'\n        ]\n    ]));\n    ```\n\n## Icons\n_Supported on:_ :material-android:\n\nYou can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query\nparameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download\nthe icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are \ncached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**.\n\nHere's an example showing how to include an icon:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -H \"Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\" \\\n        -H \"Title: Kodi: Resuming Playback\" \\\n        -H \"Tags: arrow_forward\" \\\n        -d \"The Wire, S01E01\" \\\n        ntfy.sh/tvshows\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --icon=\"https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\" \\\n        --title=\"Kodi: Resuming Playback\" \\\n        --tags=\"arrow_forward\" \\\n        tvshows \\\n        \"The Wire, S01E01\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /tvshows HTTP/1.1\n    Host: ntfy.sh\n    Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\n    Tags: arrow_forward\n    Title: Kodi: Resuming Playback\n\n    The Wire, S01E01\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/tvshows', {\n        method: 'POST',\n        headers: { \n            'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png',\n            'Title': 'Kodi: Resuming Playback',\n            'Tags': 'arrow_forward'\n        },\n        body: \"The Wire, S01E01\"\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/tvshows\", strings.NewReader(\"The Wire, S01E01\"))\n    req.Header.Set(\"Icon\", \"https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\")\n    req.Header.Set(\"Tags\", \"arrow_forward\")\n    req.Header.Set(\"Title\", \"Kodi: Resuming Playback\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/tvshows\"\n      Headers = @{\n        Title = \"Kodi: Resuming Playback\"\n        Tags = \"arrow_forward\"\n        Icon = \"https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\"\n      }\n      Body = \"The Wire, S01E01\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/tvshows\",\n        data=\"The Wire, S01E01\",\n        headers={\n            \"Title\": \"Kodi: Resuming Playback\",\n            \"Tags\": \"arrow_forward\",\n            \"Icon\": \"https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([\n        'http' => [\n        'method' => 'PUT',\n        'header' =>\n            \"Content-Type: text/plain\\r\\n\" . // Does not matter\n            \"Title: Kodi: Resuming Playback\\r\\n\" .\n            \"Tags: arrow_forward\\r\\n\" .\n            \"Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png\",\n        ],\n        'content' => \"The Wire, S01E01\"\n    ]));\n    ```\n\nHere's an example of how it will look on Android:\n\n<figure markdown>\n  ![file attachment](static/img/android-screenshot-icon.png){ width=500 }\n  <figcaption>Custom icon from an external URL</figcaption>\n</figure>\n\n## Attachments\n_Supported on:_ :material-android: :material-firefox:\n\nYou can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded\nonto your phone (depending on size and setting automatically), and can be used from the Downloads folder.\n\nThere are two different ways to send attachments: \n\n* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`\n* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` \n\n### Attach local file\nTo **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater \nthan the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically \ndetect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files \nas attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases \n`Filename`, `File` or `f`). \n\nBy default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). \nAttachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app\nto auto-download it. Please also check out the [other limits below](#limitations).\n\nHere's an example showing how to upload an image:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -T flower.jpg \\\n        -H \"Filename: flower.jpg\" \\\n        ntfy.sh/flowers\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --file=flower.jpg \\\n        flowers\n    ```\n\n=== \"HTTP\"\n    ``` http\n    PUT /flowers HTTP/1.1\n    Host: ntfy.sh\n    Filename: flower.jpg\n    Content-Type: 52312\n     \n    (binary JPEG data)\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/flowers', {\n        method: 'PUT',\n        body: document.getElementById(\"file\").files[0],\n        headers: { 'Filename': 'flower.jpg' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    file, _ := os.Open(\"flower.jpg\")\n    req, _ := http.NewRequest(\"PUT\", \"https://ntfy.sh/flowers\", file)\n    req.Header.Set(\"Filename\", \"flower.jpg\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      Uri = \"ntfy.sh/flowers\"\n      InFile = \"flower.jpg\"\n      Headers = @{\"Filename\" = \"flower.jpg\"}\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.put(\"https://ntfy.sh/flowers\",\n        data=open(\"flower.jpg\", 'rb'),\n        headers={ \"Filename\": \"flower.jpg\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([\n        'http' => [\n            'method' => 'PUT',\n            'header' =>\n                \"Content-Type: application/octet-stream\\r\\n\" . // Does not matter\n                \"Filename: flower.jpg\",\n            'content' => file_get_contents('flower.jpg') // Dangerous for large files \n        ]\n    ]));\n    ```\n\nHere's what that looks like on Android:\n\n<figure markdown>\n  ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 }\n  <figcaption>Image attachment sent from a local file</figcaption>\n</figure>\n\n### Attach file from a URL\nInstead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted.\nThis could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are \nexternally hosted, the expiration or size limits from above do not apply here.\n\nTo attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)\nto specify the attachment URL. It can be any type of file. \n\nntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a \nfilename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its\naliases `Filename`, `File` or `f`).\n\nHere's an example showing how to attach an APK file:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -X POST \\\n        -H \"Attach: https://f-droid.org/F-Droid.apk\" \\\n        ntfy.sh/mydownloads\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --attach=\"https://f-droid.org/F-Droid.apk\" \\\n        mydownloads\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mydownloads HTTP/1.1\n    Host: ntfy.sh\n    Attach: https://f-droid.org/F-Droid.apk\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mydownloads', {\n        method: 'POST',\n        headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mydownloads\", file)\n    req.Header.Set(\"Attach\", \"https://f-droid.org/F-Droid.apk\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mydownloads\"\n      Headers = @{ Attach=\"https://f-droid.org/F-Droid.apk\" }\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.put(\"https://ntfy.sh/mydownloads\",\n        headers={ \"Attach\": \"https://f-droid.org/F-Droid.apk\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([\n        'http' => [\n        'method' => 'PUT',\n        'header' =>\n            \"Content-Type: text/plain\\r\\n\" . // Does not matter\n            \"Attach: https://f-droid.org/F-Droid.apk\",\n        ]\n    ]));\n    ```\n\n<figure markdown>\n  ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 }\n  <figcaption>File attachment sent from an external URL</figcaption>\n</figure>\n\n## Action buttons\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly\nuseful and has countless applications. \n\nYou can control your home appliances (open/close garage door, change temperature on thermostat, ...), react to common \nmonitoring alerts (clear logs when disk is full, ...), and many other things. The sky is the limit.\n\nAs of today, the following actions are supported:\n\n* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped\n* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent\n  when the action button is tapped (only supported on Android)\n* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped\n* [`copy`](#copy-to-clipboard): Copies a given value to the clipboard when the action button is tapped\n\nHere's an example of what a notification with actions can look like:\n\n<figure markdown>\n  ![notification with actions](static/img/android-screenshot-notification-actions.png){ width=500 }\n  <figcaption>Notification with two user actions</figcaption>\n</figure>\n\n### Defining actions\nYou can define **up to three user actions** in your notifications, using either of the following methods:\n\n* In the [`X-Actions` header](#using-a-header), using a simple comma-separated format\n* As a [JSON array](#using-a-json-array) in the `actions` key, when [publishing as JSON](#publish-as-json) \n\n#### Using a header\nTo define actions using the `X-Actions` header (or any of its aliases: `Actions`, `Action`), use the following format:\n\n=== \"Header format (long)\"\n    ```\n    action=<action1>, label=<label1>, paramN=... [; action=<action2>, label=<label2>, ...]\n    ```\n\n=== \"Header format (short)\"\n    ```\n    <action1>, <label1>, paramN=... [; <action2>, <label2>, ...]\n    ```\n\nMultiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be \nquoted with double quotes (`\"`) or single quotes (`'`) if the value itself contains commas or semicolons. \n\nEach action type has a short format where some key prefixes can be omitted:\n\n* [`view`](#open-websiteapp): `view, <label>, <url>[, clear=true]`\n* [`broadcast`](#send-android-broadcast):`broadcast, <label>[, extras.<param>=<value>][, intent=<intent>][, clear=true]`\n* [`http`](#send-http-request): `http, <label>, <url>[, method=<method>][, headers.<header>=<value>][, body=<body>][, clear=true]`\n* [`copy`](#copy-to-clipboard): `copy, <label>, <value>[, clear=true]`\n\nAs an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and \n[`http` action](#send-http-request) section for details on the specific actions:\n\n=== \"Command line (curl)\"\n    ```\n    body='{\"temperature\": 65}'\n    curl \\\n        -d \"You left the house. Turn down the A/C?\" \\\n        -H \"Actions: view, Open portal, https://home.nest.com/, clear=true; \\\n                     http, Turn down, https://api.nest.com/, body='$body'\" \\\n        ntfy.sh/myhome\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    body='{\"temperature\": 65}'\n    ntfy publish \\\n        --actions=\"view, Open portal, https://home.nest.com/, clear=true; \\\n                   http, Turn down, https://api.nest.com/, body='$body'\" \\\n        myhome \\\n        \"You left the house. Turn down the A/C?\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /myhome HTTP/1.1\n    Host: ntfy.sh\n    Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'\n\n    You left the house. Turn down the A/C?\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/myhome', {\n        method: 'POST',\n        body: 'You left the house. Turn down the A/C?',\n        headers: { \n            'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\\'{\"temperature\": 65}\\'' \n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/myhome\", strings.NewReader(\"You left the house. Turn down the A/C?\"))\n    req.Header.Set(\"Actions\", \"view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\\\"temperature\\\": 65}'\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/myhome\"\n      Headers = @{\n        Actions=\"view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\\\"temperature\\\": 65}'\"\n      }\n      Body = \"You left the house. Turn down the A/C?\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/myhome\",\n        data=\"You left the house. Turn down the A/C?\",\n        headers={ \"Actions\": \"view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\\\"temperature\\\": 65}'\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\\\"temperature\\\": 65}'\",\n            'content' => 'You left the house. Turn down the A/C?'\n        ]\n    ]));\n    ```\n\n!!! info\n    ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).\n    If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions) \n    as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),\n    or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).\n\n#### Using a JSON array\nAlternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body \n(see [publish as JSON](#publish-as-json)):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"myhome\",\n        \"message\": \"You left the house. Turn down the A/C?\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open portal\",\n            \"url\": \"https://home.nest.com/\",\n            \"clear\": true\n          },\n          {\n            \"action\": \"http\",\n            \"label\": \"Turn down\",\n            \"url\": \"https://api.nest.com/\",\n            \"body\": \"{\\\"temperature\\\": 65}\"\n          }\n        ]\n      }'\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions '[\n            {\n                \"action\": \"view\",\n                \"label\": \"Open portal\",\n                \"url\": \"https://home.nest.com/\",\n                \"clear\": true\n            },\n            {\n                \"action\": \"http\",\n                \"label\": \"Turn down\",\n                \"url\": \"https://api.nest.com/\",\n                \"body\": \"{\\\"temperature\\\": 65}\"\n            }\n        ]' \\\n        myhome \\\n        \"You left the house. Turn down the A/C?\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"myhome\",\n        \"message\": \"You left the house. Turn down the A/C?\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open portal\",\n            \"url\": \"https://home.nest.com/\",\n            \"clear\": true\n          },\n          {\n            \"action\": \"http\",\n            \"label\": \"Turn down\",\n            \"url\": \"https://api.nest.com/\",\n            \"body\": \"{\\\"temperature\\\": 65}\"\n          }\n        ]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            topic: \"myhome\",\n            message: \"You left the house. Turn down the A/C?\",\n            actions: [\n                {\n                    action: \"view\",\n                    label: \"Open portal\",\n                    url: \"https://home.nest.com/\",\n                    clear: true\n                },\n                {\n                    action: \"http\",\n                    label: \"Turn down\",\n                    url: \"https://api.nest.com/\",\n                    body: \"{\\\"temperature\\\": 65}\"\n                }\n            ]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // but for the sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"myhome\",\n        \"message\": \"You left the house. Turn down the A/C?\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open portal\",\n            \"url\": \"https://home.nest.com/\",\n            \"clear\": true\n          },\n          {\n            \"action\": \"http\",\n            \"label\": \"Turn down\",\n            \"url\": \"https://api.nest.com/\",\n            \"body\": \"{\\\"temperature\\\": 65}\"\n          }\n        ]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-JSON @{\n        Topic   = \"myhome\"\n        Message = \"You left the house. Turn down the A/C?\"\n        Actions = @(\n          @{\n            Action = \"view\"\n            Label  = \"Open portal\"\n            URL    = \"https://home.nest.com/\"\n            Clear  = $true\n          },\n          @{\n            Action = \"http\"\n            Label  = \"Turn down\"\n            URL    = \"https://api.nest.com/\"\n            Body   = '{\"temperature\": 65}'\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"myhome\",\n            \"message\": \"You left the house. Turn down the A/C?\",\n            \"actions\": [\n                {\n                    \"action\": \"view\",\n                    \"label\": \"Open portal\",\n                    \"url\": \"https://home.nest.com/\",\n                    \"clear\": true\n                },\n                {\n                    \"action\": \"http\",\n                    \"label\": \"Turn down\",\n                    \"url\": \"https://api.nest.com/\",\n                    \"body\": \"{\\\"temperature\\\": 65}\"\n                }\n            ]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"myhome\",\n                \"message\": \"You left the house. Turn down the A/C?\",\n                \"actions\": [\n                    [\n                        \"action\": \"view\",\n                        \"label\": \"Open portal\",\n                        \"url\": \"https://home.nest.com/\",\n                        \"clear\": true\n                    ],\n                    [\n                        \"action\": \"http\",\n                        \"label\": \"Turn down\",\n                        \"url\": \"https://api.nest.com/\",\n                        \"headers\": [\n                            \"Authorization\": \"Bearer ...\"\n                        ],\n                        \"body\": \"{\\\"temperature\\\": 65}\"\n                    ]\n                ]\n            ])\n        ]\n    ]));\n    ```\n\nThe required/optional fields for each action depend on the type of the action itself. Please refer to \n[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), [`http` action](#send-http-request),\nand [`copy` action](#copy-to-clipboard) for details.\n\n### Open website/app\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nThe `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or\neven a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your \ndesktop browser treat the links. Normally it'll just open a link in the browser. \n\nExamples:\n\n* `http://` or `https://` will open your browser (or an app if it registered for a URL)\n* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`\n* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`\n* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`\n* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..`\n* ...\n\nHere's an example using the [`X-Actions` header](#using-a-header):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"Somebody retweeted your tweet.\" \\\n        -H \"Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\" \\\n    ntfy.sh/myhome\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions=\"view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\" \\\n        myhome \\\n        \"Somebody retweeted your tweet.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /myhome HTTP/1.1\n    Host: ntfy.sh\n    Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\n\n    Somebody retweeted your tweet.\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/myhome', {\n        method: 'POST',\n        body: 'Somebody retweeted your tweet.',\n        headers: { \n            'Actions': 'view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392' \n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/myhome\", strings.NewReader(\"Somebody retweeted your tweet.\"))\n    req.Header.Set(\"Actions\", \"view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/myhome\"\n      Headers = @{\n        Actions = \"view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\"\n      }\n      Body = \"Somebody retweeted your tweet.\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/myhome\",\n        data=\"Somebody retweeted your tweet.\",\n        headers={ \"Actions\": \"view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392\",\n            'content' => 'Somebody retweeted your tweet.'\n        ]\n    ]));\n    ```\n\nAnd the same example using [JSON publishing](#publish-as-json):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"myhome\",\n        \"message\": \"Somebody retweeted your tweet.\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open Twitter\",\n            \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n          }\n        ]\n      }'\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions '[\n            {\n                \"action\": \"view\",\n                \"label\": \"Open Twitter\",\n                \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n            }\n        ]' \\\n        myhome \\\n        \"Somebody retweeted your tweet.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"myhome\",\n        \"message\": \"Somebody retweeted your tweet.\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open Twitter\",\n            \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n          }\n        ]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            topic: \"myhome\",\n            message: \"Somebody retweeted your tweet.\",\n            actions: [\n                {\n                    action: \"view\",\n                    label: \"Open Twitter\",\n                    url: \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n                }\n            ]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // but for the sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"myhome\",\n        \"message\": \"Somebody retweeted your tweet.\",\n        \"actions\": [\n          {\n            \"action\": \"view\",\n            \"label\": \"Open Twitter\",\n            \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n          }\n        ]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-JSON @{\n        Topic = \"myhome\"\n        Message = \"Somebody retweeted your tweet.\"\n        Actions = @(\n          @{\n            Action = \"view\"\n            Label  = \"Open Twitter\"\n            URL    = \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"myhome\",\n            \"message\": \"Somebody retweeted your tweet.\",\n            \"actions\": [\n                {\n                    \"action\": \"view\",\n                    \"label\": \"Open Twitter\",\n                    \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n                }\n            ]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"myhome\",\n                \"message\": \"Somebody retweeted your tweet.\",\n                \"actions\": [\n                    [\n                        \"action\": \"view\",\n                        \"label\": \"Open Twitter\",\n                        \"url\": \"https://twitter.com/binwiederhier/status/1467633927951163392\"\n                    ]\n                ]\n            ])\n        ]\n    ]));\n    ```\n\nThe short format for the `view` action is `view, <label>, <url>` (e.g. `view, Open Google, https://google.com`),\nbut you can always just use the `<key>=<value>` notation as well (e.g. `action=view, url=https://google.com, label=Open Google`).\n\nThe `view` action supports the following fields:\n\n| Field    | Required | Type      | Default | Example               | Description                                      |\n|----------|----------|-----------|---------|-----------------------|--------------------------------------------------|\n| `action` | ✔️       | *string*  | -       | `view`                | Action type (**must be `view`**)                 |\n| `label`  | ✔️       | *string*  | -       | `Turn on light`       | Label of the action button in the notification   |\n| `url`    | ✔️       | *URL*     | -       | `https://example.com` | URL to open when action is tapped                |\n| `clear`  | -️       | *boolean* | `false` | `true`                | Clear notification after action button is tapped |\n\n### Send Android broadcast\n_Supported on:_ :material-android:\n\nThe `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent\nwhen the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)\nor [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means\nyou can do everything your phone is capable of. Examples include taking pictures, launching/killing apps, change device\nsettings, write/read files, etc.\n\nBy default, the intent action **`io.heckel.ntfy.USER_ACTION`** is broadcast, though this can be changed with the `intent` parameter (see below).\nTo send extras, use the `extras` parameter. Currently, **only string extras are supported**.\n\n!!! info\n    If you have no idea what this is, check out the [automation apps](subscribe/phone.md#automation-apps) section, which shows\n    how to integrate Tasker and MacroDroid **with screenshots**. The action button integration is identical, except that\n    you have to use **the intent action `io.heckel.ntfy.USER_ACTION`** instead.\n\nHere's an example using the [`X-Actions` header](#using-a-header):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"Your wife requested you send a picture of yourself.\" \\\n        -H \"Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front\" \\\n    ntfy.sh/wifey\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions=\"broadcast, Take picture, extras.cmd=pic, extras.camera=front\" \\\n        wifey \\\n        \"Your wife requested you send a picture of yourself.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /wifey HTTP/1.1\n    Host: ntfy.sh\n    Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front\n\n    Your wife requested you send a picture of yourself.\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/wifey', {\n        method: 'POST',\n        body: 'Your wife requested you send a picture of yourself.',\n        headers: { \n            'Actions': 'broadcast, Take picture, extras.cmd=pic, extras.camera=front' \n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/wifey\", strings.NewReader(\"Your wife requested you send a picture of yourself.\"))\n    req.Header.Set(\"Actions\", \"broadcast, Take picture, extras.cmd=pic, extras.camera=front\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/wifey\"\n      Headers = @{\n        Actions = \"broadcast, Take picture, extras.cmd=pic, extras.camera=front\"\n      }\n      Body = \"Your wife requested you send a picture of yourself.\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/wifey\",\n        data=\"Your wife requested you send a picture of yourself.\",\n        headers={ \"Actions\": \"broadcast, Take picture, extras.cmd=pic, extras.camera=front\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/wifey', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front\",\n            'content' => 'Your wife requested you send a picture of yourself.'\n        ]\n    ]));\n    ```\n\nAnd the same example using [JSON publishing](#publish-as-json):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"wifey\",\n        \"message\": \"Your wife requested you send a picture of yourself.\",\n        \"actions\": [\n          {\n            \"action\": \"broadcast\",\n            \"label\": \"Take picture\",\n            \"extras\": {\n                \"cmd\": \"pic\",\n                \"camera\": \"front\"\n            }\n          }\n        ]\n      }'\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions '[\n            {\n                \"action\": \"broadcast\",\n                \"label\": \"Take picture\",\n                \"extras\": {\n                    \"cmd\": \"pic\",\n                    \"camera\": \"front\"\n                }\n            }\n        ]' \\\n        wifey \\\n        \"Your wife requested you send a picture of yourself.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"wifey\",\n        \"message\": \"Your wife requested you send a picture of yourself.\",\n        \"actions\": [\n          {\n            \"action\": \"broadcast\",\n            \"label\": \"Take picture\",\n            \"extras\": {\n                \"cmd\": \"pic\",\n                \"camera\": \"front\"\n            }\n          }\n        ]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            topic: \"wifey\",\n            message: \"Your wife requested you send a picture of yourself.\",\n            actions: [\n                {\n                    \"action\": \"broadcast\",\n                    \"label\": \"Take picture\",\n                    \"extras\": {\n                        \"cmd\": \"pic\",\n                        \"camera\": \"front\"\n                    }\n                }\n            ]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // but for the sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"wifey\",\n        \"message\": \"Your wife requested you send a picture of yourself.\",\n        \"actions\": [\n          {\n            \"action\": \"broadcast\",\n            \"label\": \"Take picture\",\n            \"extras\": {\n                \"cmd\": \"pic\",\n                \"camera\": \"front\"\n            }\n          }\n        ]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras',\n    # otherwise it will read System.Collections.Hashtable in the returned JSON\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-Json -Depth 3 @{\n        Topic = \"wifey\"\n        Message = \"Your wife requested you send a picture of yourself.\"\n        Actions = @(\n          @{\n            Action = \"broadcast\"\n            Label = \"Take picture\"\n            Extras = @{\n              CMD =\"pic\"\n              Camera = \"front\"\n            }\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"wifey\",\n            \"message\": \"Your wife requested you send a picture of yourself.\",\n            \"actions\": [\n                {\n                    \"action\": \"broadcast\",\n                    \"label\": \"Take picture\",\n                    \"extras\": {\n                        \"cmd\": \"pic\",\n                        \"camera\": \"front\"\n                    }\n                }\n            ]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"wifey\",\n                \"message\": \"Your wife requested you send a picture of yourself.\",\n                \"actions\": [\n                    [\n                    \"action\": \"broadcast\",\n                    \"label\": \"Take picture\",\n                    \"extras\": [\n                        \"cmd\": \"pic\",\n                        \"camera\": \"front\"\n                    ]\n                ]\n            ])\n        ]\n    ]));\n    ```\n\nThe short format for the `broadcast` action is `broadcast, <label>, <url>` (e.g. `broadcast, Take picture, extras.cmd=pic`),\nbut you can always just use the `<key>=<value>` notation as well (e.g. `action=broadcast, label=Take picture, extras.cmd=pic`).\n\nThe `broadcast` action supports the following fields:\n\n| Field    | Required | Type             | Default                      | Example                 | Description                                                                                                                                                                            |\n|----------|----------|------------------|------------------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `action` | ✔️       | *string*         | -                            | `broadcast`             | Action type (**must be `broadcast`**)                                                                                                                                                  |\n| `label`  | ✔️       | *string*         | -                            | `Turn on light`         | Label of the action button in the notification                                                                                                                                         |\n| `intent` | -️       | *string*         | `io.heckel.ntfy.USER_ACTION` | `com.example.AN_INTENT` | Android intent name, **default is `io.heckel.ntfy.USER_ACTION`**                                                                                                                       |\n| `extras` | -️       | *map of strings* | -                            | *see above*             | Android intent extras. Currently, only string extras are supported. When publishing as JSON, extras are passed as a map. When the simple format is used, use `extras.<param>=<value>`. |\n| `clear`  | -️       | *boolean*        | `false`                      | `true`                  | Clear notification after action button is tapped                                                                                                                                       |\n\n### Send HTTP request\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nThe `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs\nfor whatever systems you have, e.g. opening the garage door, or turning on/off lights.\n\nBy default, this action sends a **POST request** (not GET!), though this can be changed with the `method` parameter.\nThe only required parameter is `url`. Headers can be passed along using the `headers` parameter.  \n\nHere's an example using the [`X-Actions` header](#using-a-header):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"Garage door has been open for 15 minutes. Close it?\" \\\n        -H \"Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}\" \\\n        ntfy.sh/myhome\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions=\"http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}\" \\\n        myhome \\\n        \"Garage door has been open for 15 minutes. Close it?\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /myhome HTTP/1.1\n    Host: ntfy.sh\n    Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}\n\n    Garage door has been open for 15 minutes. Close it?\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/myhome', {\n        method: 'POST',\n        body: 'Garage door has been open for 15 minutes. Close it?',\n        headers: { \n            'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}' \n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/myhome\", strings.NewReader(\"Garage door has been open for 15 minutes. Close it?\"))\n    req.Header.Set(\"Actions\", \"http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/myhome\"\n      Headers = @{\n        Actions=\"http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}\"\n      }\n      Body = \"Garage door has been open for 15 minutes. Close it?\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/myhome\",\n        data=\"Garage door has been open for 15 minutes. Close it?\",\n        headers={ \"Actions\": \"http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                'Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\\\"action\\\": \\\"close\\\"}',\n            'content' => 'Garage door has been open for 15 minutes. Close it?'\n        ]\n    ]));\n    ```\n\nAnd the same example using [JSON publishing](#publish-as-json):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"myhome\",\n        \"message\": \"Garage door has been open for 15 minutes. Close it?\",\n        \"actions\": [\n          {\n            \"action\": \"http\",\n            \"label\": \"Close door\",\n            \"url\": \"https://api.mygarage.lan/\",\n            \"method\": \"PUT\",\n            \"headers\": {\n                \"Authorization\": \"Bearer zAzsx1sk..\"\n            },\n            \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n          }\n        ]\n      }'\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions '[\n            {\n              \"action\": \"http\",\n              \"label\": \"Close door\",\n              \"url\": \"https://api.mygarage.lan/\",\n              \"method\": \"PUT\",\n              \"headers\": {\n                \"Authorization\": \"Bearer zAzsx1sk..\"\n              },\n              \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n            }\n        ]' \\\n        myhome \\\n        \"Garage door has been open for 15 minutes. Close it?\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"myhome\",\n        \"message\": \"Garage door has been open for 15 minutes. Close it?\",\n        \"actions\": [\n          {\n            \"action\": \"http\",\n            \"label\": \"Close door\",\n            \"url\": \"https://api.mygarage.lan/\",\n            \"method\": \"PUT\",\n            \"headers\": {\n              \"Authorization\": \"Bearer zAzsx1sk..\"\n            },\n            \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n          }\n        ]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            topic: \"myhome\",\n            message: \"Garage door has been open for 15 minutes. Close it?\",\n            actions: [\n              {\n                \"action\": \"http\",\n                \"label\": \"Close door\",\n                \"url\": \"https://api.mygarage.lan/\",\n                \"method\": \"PUT\",\n                \"headers\": {\n                  \"Authorization\": \"Bearer zAzsx1sk..\"\n                },\n                \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n              }\n            ]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // but for the sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"myhome\",\n        \"message\": \"Garage door has been open for 15 minutes. Close it?\",\n        \"actions\": [\n          {\n            \"action\": \"http\",\n            \"label\": \"Close door\",\n            \"method\": \"PUT\",\n            \"url\": \"https://api.mygarage.lan/\",\n            \"headers\": {\n              \"Authorization\": \"Bearer zAzsx1sk..\"\n            },\n            \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n          }\n        ]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Powershell requires the 'Depth' argument to equal 3 here to expand 'headers', \n    # otherwise it will read System.Collections.Hashtable in the returned JSON\n\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-Json -Depth 3 @{\n        Topic   = \"myhome\"\n        Message = \"Garage door has been open for 15 minutes. Close it?\"\n        Actions = @(\n          @{\n            Action  = \"http\"\n            Label   = \"Close door\"\n            URL     = \"https://api.mygarage.lan/\"\n            Method  = \"PUT\"\n            Headers = @{\n              Authorization = \"Bearer zAzsx1sk..\"\n            }\n            Body    = ConvertTo-JSON @{Action = \"close\"}\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"myhome\",\n            \"message\": \"Garage door has been open for 15 minutes. Close it?\",\n            \"actions\": [\n                {\n                  \"action\": \"http\",\n                  \"label\": \"Close door\",\n                  \"url\": \"https://api.mygarage.lan/\",\n                  \"method\": \"PUT\",\n                  \"headers\": {\n                    \"Authorization\": \"Bearer zAzsx1sk..\"\n                  },\n                  \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n                }\n            ]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"myhome\",\n                \"message\": \"Garage door has been open for 15 minutes. Close it?\",\n                \"actions\": [\n                    [\n                        \"action\": \"http\",\n                        \"label\": \"Close door\",\n                        \"url\": \"https://api.mygarage.lan/\",\n                        \"method\": \"PUT\",\n                        \"headers\": [\n                            \"Authorization\": \"Bearer zAzsx1sk..\"\n                         ],\n                        \"body\": \"{\\\"action\\\": \\\"close\\\"}\"\n                    ]\n                ]\n            ])\n        ]\n    ]));\n    ```\n\nThe short format for the `http` action is `http, <label>, <url>` (e.g. `http, Close door, https://api.mygarage.lan/close`),\nbut you can always just use the `<key>=<value>` notation as well (e.g. `action=http, label=Close door, url=https://api.mygarage.lan/close`).\n\nThe `http` action supports the following fields:\n\n| Field     | Required | Type               | Default   | Example                   | Description                                                                                                                                             |\n|-----------|----------|--------------------|-----------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `action`  | ✔️       | *string*           | -         | `http`                    | Action type (**must be `http`**)                                                                                                                        |\n| `label`   | ✔️       | *string*           | -         | `Open garage door`        | Label of the action button in the notification                                                                                                          |\n| `url`     | ✔️       | *string*           | -         | `https://ntfy.sh/mytopic` | URL to which the HTTP request will be sent                                                                                                              |\n| `method`  | -️       | *GET/POST/PUT/...* | `POST` ⚠️ | `GET`                     | HTTP method to use for request, **default is POST** ⚠️                                                                                                  |\n| `headers` | -️       | *map of strings*   | -         | *see above*               | HTTP headers to pass in request. When publishing as JSON, headers are passed as a map. When the simple format is used, use `headers.<header1>=<value>`. |\n| `body`    | -️       | *string*           | *empty*   | `some body, somebody?`    | HTTP body                                                                                                                                               |\n| `clear`   | -️       | *boolean*          | `false`   | `true`                    | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared.                                                  |\n\n### Copy to clipboard\n_Supported on:_ :material-android: :material-firefox:\n\nThe `copy` action **copies a given value to the clipboard when the action button is tapped**. This is useful for \none-time passcodes, tokens, or any other value you want to quickly copy without opening the full notification.\n\n!!! info\n    The copy action button is only shown in the web app and Android app notification list, **not** in browser desktop\n    notifications. This is because browsers do not allow clipboard access from notification actions without direct \n    user interaction with the page.\n\nHere's an example using the [`X-Actions` header](#using-a-header):\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -d \"Your one-time passcode is 123456\" \\\n        -H \"Actions: copy, Copy code, 123456\" \\\n        ntfy.sh/myhome\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions=\"copy, Copy code, 123456\" \\\n        myhome \\\n        \"Your one-time passcode is 123456\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /myhome HTTP/1.1\n    Host: ntfy.sh\n    Actions: copy, Copy code, 123456\n\n    Your one-time passcode is 123456\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/myhome', {\n        method: 'POST',\n        body: 'Your one-time passcode is 123456',\n        headers: { \n            'Actions': 'copy, Copy code, 123456' \n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/myhome\", strings.NewReader(\"Your one-time passcode is 123456\"))\n    req.Header.Set(\"Actions\", \"copy, Copy code, 123456\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/myhome\"\n      Headers = @{\n        Actions = \"copy, Copy code, 123456\"\n      }\n      Body = \"Your one-time passcode is 123456\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/myhome\",\n        data=\"Your one-time passcode is 123456\",\n        headers={ \"Actions\": \"copy, Copy code, 123456\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/myhome', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Actions: copy, Copy code, 123456\",\n            'content' => 'Your one-time passcode is 123456'\n        ]\n    ]));\n    ```\n\nAnd the same example using [JSON publishing](#publish-as-json):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"myhome\",\n        \"message\": \"Your one-time passcode is 123456\",\n        \"actions\": [\n          {\n            \"action\": \"copy\",\n            \"label\": \"Copy code\",\n            \"value\": \"123456\"\n          }\n        ]\n      }'\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --actions '[\n            {\n                \"action\": \"copy\",\n                \"label\": \"Copy code\",\n                \"value\": \"123456\"\n            }\n        ]' \\\n        myhome \\\n        \"Your one-time passcode is 123456\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"myhome\",\n        \"message\": \"Your one-time passcode is 123456\",\n        \"actions\": [\n          {\n            \"action\": \"copy\",\n            \"label\": \"Copy code\",\n            \"value\": \"123456\"\n          }\n        ]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            topic: \"myhome\",\n            message: \"Your one-time passcode is 123456\",\n            actions: [\n                {\n                    action: \"copy\",\n                    label: \"Copy code\",\n                    value: \"123456\"\n                }\n            ]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // but for the sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"myhome\",\n        \"message\": \"Your one-time passcode is 123456\",\n        \"actions\": [\n          {\n            \"action\": \"copy\",\n            \"label\": \"Copy code\",\n            \"value\": \"123456\"\n          }\n        ]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-JSON @{\n        Topic = \"myhome\"\n        Message = \"Your one-time passcode is 123456\"\n        Actions = @(\n          @{\n            Action = \"copy\"\n            Label  = \"Copy code\"\n            Value  = \"123456\"\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"myhome\",\n            \"message\": \"Your one-time passcode is 123456\",\n            \"actions\": [\n                {\n                    \"action\": \"copy\",\n                    \"label\": \"Copy code\",\n                    \"value\": \"123456\"\n                }\n            ]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"myhome\",\n                \"message\": \"Your one-time passcode is 123456\",\n                \"actions\": [\n                    [\n                        \"action\": \"copy\",\n                        \"label\": \"Copy code\",\n                        \"value\": \"123456\"\n                    ]\n                ]\n            ])\n        ]\n    ]));\n    ```\n\nThe short format for the `copy` action is `copy, <label>, <value>` (e.g. `copy, Copy code, 123456`),\nbut you can always just use the `<key>=<value>` notation as well (e.g. `action=copy, label=Copy code, value=123456`).\n\nThe `copy` action supports the following fields:\n\n| Field    | Required | Type      | Default | Example         | Description                                      |\n|----------|----------|-----------|---------|-----------------|--------------------------------------------------|\n| `action` | ✔️       | *string*  | -       | `copy`          | Action type (**must be `copy`**)                 |\n| `label`  | ✔️       | *string*  | -       | `Copy code`     | Label of the action button in the notification   |\n| `value`  | ✔️       | *string*  | -       | `123456`        | Value to copy to the clipboard                   |\n| `clear`  | -️       | *boolean* | `false` | `true`          | Clear notification after action button is tapped |\n\n## Scheduled delivery\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself \nreminders or even to execute commands at a later date (if your subscriber acts on messages).\n\nUsage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`, \n`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`, \n`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`, \n[and more](https://github.com/olebedev/when)). \n\nAs of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured\nwith the `message-delay-limit` option.\n\nFor the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours \nafter they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled\nto be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally, \n[turning off server-side caching](#message-caching) is not possible in combination with this feature.  \n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"At: tomorrow, 10am\" -d \"Good morning\" ntfy.sh/hello\n    curl -H \"In: 30min\" -d \"It's 30 minutes later now\" ntfy.sh/reminder\n    curl -H \"Delay: 1639194738\" -d \"Unix timestamps are awesome\" ntfy.sh/itsaunixsystem\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --at=\"tomorrow, 10am\" \\\n        hello \"Good morning\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /hello HTTP/1.1\n    Host: ntfy.sh\n    At: tomorrow, 10am\n\n    Good morning\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/hello', {\n        method: 'POST',\n        body: 'Good morning',\n        headers: { 'At': 'tomorrow, 10am' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/hello\", strings.NewReader(\"Good morning\"))\n    req.Header.Set(\"At\", \"tomorrow, 10am\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/hello\"\n      Headers = @{\n        At = \"tomorrow, 10am\"\n      }\n      Body = \"Good morning\"\n    }\n    Invoke-RestMethod @Request\n    ```\n    \n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/hello\",\n        data=\"Good morning\",\n        headers={ \"At\": \"tomorrow, 10am\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/backups', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"At: tomorrow, 10am\",\n            'content' => 'Good morning'\n        ]\n    ]));\n    ```\n\nHere are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):\n\n<table class=\"remove-md-box\"><tr>\n<td>\n    <table><thead><tr><th><code>Delay/At/In</code> header</th><th>Message will be delivered at</th><th>Explanation</th></tr></thead><tbody>\n    <tr><td><code>30m</code></td><td>12/10/2021, 9:<b>30</b>am</td><td>30 minutes from now</td></tr>\n    <tr><td><code>2 hours</code></td><td>12/10/2021, <b>11:30</b>am</td><td>2 hours from now</td></tr>\n    <tr><td><code>1 day</code></td><td>12/<b>11</b>/2021, 9am</td><td>24 hours from now</td></tr>\n    <tr><td><code>10am</code></td><td>12/10/2021, <b>10am</b></td><td>Today at 10am (same day, because it's only 9am)</td></tr>\n    <tr><td><code>8am</code></td><td>12/<b>11</b>/2021, <b>8am</b></td><td>Tomorrow at 8am (because it's 9am already)</td></tr>\n    <tr><td><code>1639152000</code></td><td>12/10/2021, 11am (EST)</td><td> Today at 11am (EST)</td></tr>\n    </tbody></table>\n</td>\n</tr></table>\n\n### Updating scheduled notifications\n\nYou can update or replace a scheduled message before it is delivered by publishing a new message with the same \n[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** \nfrom the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) \nafter delivery, where both messages are kept in the cache.\n\nThis is particularly useful for implementing a **watchdog that triggers when your script stops sending heartbeat messages**.\nThis mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch).\n\nFor example, you could schedule a message to be delivered in 5 minutes, but continuously update it every minute to push\nthe delivery time further into the future. If your script or system stops running, the message will eventually be delivered as an alert.\n\nHere's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes:\n\n=== \"Command line (curl)\"\n    ```bash\n    # Dead man's switch: keeps pushing a scheduled message into the future\n    # If this script stops, the alert will be delivered after 5 minutes\n    while true; do\n        curl -H \"In: 5m\" -d \"Warning: Server heartbeat stopped!\" \\\n            ntfy.sh/mytopic/heartbeat-check\n        sleep 60  # Update every minute\n    done\n    ```\n\n=== \"ntfy CLI\"\n    ```bash\n    # Dead man's switch: keeps pushing a scheduled message into the future\n    # If this script stops, the alert will be delivered after 5 minutes\n    while true; do\n        ntfy publish \\\n            --in=\"5m\" \\\n            --sequence-id=\"heartbeat-check\" \\\n            mytopic \"Warning: Server heartbeat stopped!\"\n        sleep 60  # Update every minute\n    done\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic/heartbeat-check HTTP/1.1\n    Host: ntfy.sh\n    In: 5m\n\n    Warning: Server heartbeat stopped!\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    // Dead man's switch: keeps pushing a scheduled message into the future\n    // If this script stops, the alert will be delivered after 5 minutes\n    setInterval(() => {\n        fetch('https://ntfy.sh/mytopic/heartbeat-check', {\n            method: 'POST',\n            body: 'Warning: Server heartbeat stopped!',\n            headers: { 'In': '5m' }\n        })\n    }, 60000) // Update every minute\n    ```\n\n=== \"Go\"\n    ``` go\n    // Dead man's switch: keeps pushing a scheduled message into the future\n    // If this script stops, the alert will be delivered after 5 minutes\n    for {\n        req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic/heartbeat-check\",\n            strings.NewReader(\"Warning: Server heartbeat stopped!\"))\n        req.Header.Set(\"In\", \"5m\")\n        http.DefaultClient.Do(req)\n        time.Sleep(60 * time.Second) // Update every minute\n    }\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Dead man's switch: keeps pushing a scheduled message into the future\n    # If this script stops, the alert will be delivered after 5 minutes\n    while ($true) {\n        $Request = @{\n            Method = \"POST\"\n            URI = \"https://ntfy.sh/mytopic/heartbeat-check\"\n            Headers = @{ In = \"5m\" }\n            Body = \"Warning: Server heartbeat stopped!\"\n        }\n        Invoke-RestMethod @Request\n        Start-Sleep -Seconds 60  # Update every minute\n    }\n    ```\n\n=== \"Python\"\n    ``` python\n    import requests\n    import time\n\n    # Dead man's switch: keeps pushing a scheduled message into the future\n    # If this script stops, the alert will be delivered after 5 minutes\n    while True:\n        requests.post(\n            \"https://ntfy.sh/mytopic/heartbeat-check\",\n            data=\"Warning: Server heartbeat stopped!\",\n            headers={\"In\": \"5m\"}\n        )\n        time.sleep(60) # Update every minute\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    // Dead man's switch: keeps pushing a scheduled message into the future\n    // If this script stops, the alert will be delivered after 5 minutes\n    while (true) {\n        file_get_contents('https://ntfy.sh/mytopic/heartbeat-check', false, stream_context_create([\n            'http' => [\n                'method' => 'POST',\n                'header' => \"Content-Type: text/plain\\r\\nIn: 5m\",\n                'content' => 'Warning: Server heartbeat stopped!'\n            ]\n        ]));\n        sleep(60); // Update every minute\n    }\n    ```\n\n### Canceling scheduled notifications\n\nYou can cancel a scheduled message before it is delivered by sending a DELETE request to the \n`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the \nscheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.\n\n=== \"Command line (curl)\"\n    ```bash\n    # Schedule a reminder for 2 hours from now\n    curl -H \"In: 2h\" -d \"Take a break!\" ntfy.sh/mytopic/break-reminder\n\n    # Changed your mind? Cancel the scheduled message\n    curl -X DELETE ntfy.sh/mytopic/break-reminder\n    ```\n\n=== \"ntfy CLI\"\n    ```bash\n    # Schedule a reminder for 2 hours from now\n    ntfy publish --in=\"2h\" mytopic/break-reminder \"Take a break!\"\n\n    # Changed your mind? Cancel the scheduled message\n    # (ntfy CLI does not support DELETE, use curl instead)\n    curl -X DELETE ntfy.sh/mytopic/break-reminder\n    ```\n\n=== \"HTTP\"\n    ``` http\n    DELETE /mytopic/break-reminder HTTP/1.1\n    Host: ntfy.sh\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    // Schedule a reminder for 2 hours from now\n    await fetch('https://ntfy.sh/mytopic/break-reminder', {\n        method: 'POST',\n        body: 'Take a break!',\n        headers: { 'In': '2h' }\n    });\n\n    // Changed your mind? Cancel the scheduled message\n    await fetch('https://ntfy.sh/mytopic/break-reminder', {\n        method: 'DELETE'\n    });\n    ```\n\n=== \"Go\"\n    ``` go\n    // Schedule a reminder for 2 hours from now\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic/break-reminder\",\n        strings.NewReader(\"Take a break!\"))\n    req.Header.Set(\"In\", \"2h\")\n    http.DefaultClient.Do(req)\n\n    // Changed your mind? Cancel the scheduled message\n    req, _ = http.NewRequest(\"DELETE\", \"https://ntfy.sh/mytopic/break-reminder\", nil)\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Schedule a reminder for 2 hours from now\n    $Request = @{\n        Method = \"POST\"\n        URI = \"https://ntfy.sh/mytopic/break-reminder\"\n        Headers = @{ In = \"2h\" }\n        Body = \"Take a break!\"\n    }\n    Invoke-RestMethod @Request\n\n    # Changed your mind? Cancel the scheduled message\n    Invoke-RestMethod -Method DELETE -Uri \"https://ntfy.sh/mytopic/break-reminder\"\n    ```\n\n=== \"Python\"\n    ``` python\n    import requests\n\n    # Schedule a reminder for 2 hours from now\n    requests.post(\n        \"https://ntfy.sh/mytopic/break-reminder\",\n        data=\"Take a break!\",\n        headers={\"In\": \"2h\"}\n    )\n\n    # Changed your mind? Cancel the scheduled message\n    requests.delete(\"https://ntfy.sh/mytopic/break-reminder\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    // Schedule a reminder for 2 hours from now\n    file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: text/plain\\r\\nIn: 2h\",\n            'content' => 'Take a break!'\n        ]\n    ]));\n\n    // Changed your mind? Cancel the scheduled message\n    file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([\n        'http' => ['method' => 'DELETE']\n    ]));\n    ```\n\n## Message templating\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nTemplating lets you **format a JSON message body into human-friendly message and title text** using\n[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/), \n[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and\n[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when\n**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks),\n[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/),\n[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks.\n\nInstead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated\nmessage and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body\nis valid JSON).\n\nYou can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`):\n\n* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`,\n  `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name.\n  See [pre-defined templates](#pre-defined-templates) for more details.\n* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`)\n  will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`).\n  See [custom templates](#custom-templates) for more details.\n* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`)\n  will enable inline templating, which means that the `message`, `title`, and/or `priority` will be parsed as a Go template.\n  See [inline templating](#inline-templating) for more details.\n\nTo learn the basics of Go's templating language, please see [template syntax](#template-syntax).\n\n### Pre-defined templates\n\nWhen `X-Template: <name>` (aliases: `Template: <name>`, `Tpl: <name>`) or `?template=<name>` is set, ntfy will transform the\nmessage and/or title based on one of the built-in pre-defined templates.\n\nThe following **pre-defined templates** are available:\n\n* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml).\n* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml).\n* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml).\n\nTo override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`,\ncan be overridden with `template-dir`). See [custom templates](#custom-templates) for more details.\n\nHere's an example of how to use the **pre-defined `github` template**: \n\nFirst, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`.\n<figure markdown>\n  ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 }\n  <figcaption>GitHub webhook configuration</figcaption>\n</figure>\n\nAfter that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules\nand you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository:\n\n<figure markdown>\n  ![pre-defined template](static/img/android-screenshot-template-predefined.png){ width=500 }\n  <figcaption>Receiving a webhook, formatted using the pre-defined \"github\" template</figcaption>\n</figure>\n\n### Custom templates\n\nTo define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`)\nand set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension). \n\nFor example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or\nthe query parameter `?template=myapp` to use it.\n\nTemplate files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title`, `message`, and `priority` keys,\nwhich are interpreted as Go templates.\n\nHere's an **example custom template**:\n\n=== \"Custom template (/etc/ntfy/templates/myapp.yml)\"\n    ```yaml\n    title: |\n      {{- if eq .status \"firing\" }}\n        {{- if gt .percent 90.0 }}🚨 Critical alert\n        {{- else }}⚠️ Alert{{- end }}\n      {{- else if eq .status \"resolved\" }}\n      ✅ Alert resolved\n      {{- end }}\n    message: |\n      Status: {{ .status }}\n      Type: {{ .type | upper }} ({{ .percent }}%)\n      Server: {{ .server }}\n    priority: |\n      {{ if gt .percent 90.0 }}5\n      {{ else if gt .percent 75.0 }}4\n      {{ else }}3\n      {{ end }}\n    ```\n\nOnce you have the template file in place, you can send the payload to your topic using the `X-Template`\nheader or query parameter:\n\n=== \"Command line (curl)\"\n    ```\n    echo '{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}' | \\\n      curl -sT- \"https://ntfy.example.com/mytopic?template=myapp\"\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    echo '{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}' | \\\n      ntfy publish --template=myapp https://ntfy.example.com/mytopic \n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic?template=myapp HTTP/1.1\n    Host: ntfy.example.com\n\n    {\n      \"status\": \"firing\",\n      \"type\": \"cpu\",\n      \"server\": \"ntfy.sh\",\n      \"percent\": 99\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mytopic?template=myapp', {\n        method: 'POST',\n        body: '{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}'\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    payload := `{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mytopic?template=myapp\", strings.NewReader(payload))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      Uri = \"https://ntfy.example.com/mytopic?template=myapp\"\n      Body = '{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}'\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mytopic?template=myapp\",\n      json={\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99})\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => '{\"status\":\"firing\",\"type\":\"cpu\",\"server\":\"ntfy.sh\",\"percent\":99}'\n        ]\n    ]));\n    ```\n\nWhich will result in a notification that looks like this:\n\n<figure markdown>\n  ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ width=500 }\n  <figcaption>JSON webhook, transformed using a custom template</figcaption>\n</figure>\n\n### Inline templating\n\nWhen `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message`, `title`, and `priority` fields of your\nwebhook payload. \n\nInline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).\nConsider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, \nif you control the ntfy server, as templates are much easier to maintain.\n\nHere's an **example for a Grafana alert**:\n\n<figure markdown>\n  ![notification with actions](static/img/android-screenshot-template.jpg){ width=500 }\n  <figcaption>Grafana webhook, formatted using templates</figcaption>\n</figure>\n\nThis was sent using the following templates and payloads\n\n=== \"Message template\"\n    ```\n    {{range .alerts}}\n      {{.annotations.summary}}\n      \n      Values:\n      {{range $k,$v := .values}}\n        - {{$k}}={{$v}}\n      {{end}}\n    {{end}}\n    ```\n\n=== \"Title template\"\n    ```\n    {{.title}}\n    ```\n\n=== \"Encoded webhook URL\"\n    ```\n    # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana, \n    # and may be required for other tools too\n\n    https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D\n    ```\n\n=== \"Grafana-sent payload\"\n    ```\n    {\"receiver\":\"ntfy\\\\.example\\\\.com/alerts\",\"status\":\"resolved\",\"alerts\":[{\"status\":\"resolved\",\"labels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\",\"instance\":\"10.108.0.2:9100\",\"job\":\"node-exporter\"},\"annotations\":{\"summary\":\"15m load average too high\"},\"startsAt\":\"2024-03-15T02:28:00Z\",\"endsAt\":\"2024-03-15T02:42:00Z\",\"generatorURL\":\"localhost:3000/alerting/grafana/NW9oDw-4z/view\",\"fingerprint\":\"becbfb94bd81ef48\",\"silenceURL\":\"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\",\"dashboardURL\":\"\",\"panelURL\":\"\",\"values\":{\"B\":18.98211314475876,\"C\":0},\"valueString\":\"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]\"}],\"groupLabels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\"},\"commonLabels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\",\"instance\":\"10.108.0.2:9100\",\"job\":\"node-exporter\"},\"commonAnnotations\":{\"summary\":\"15m load average too high\"},\"externalURL\":\"localhost:3000/\",\"version\":\"1\",\"groupKey\":\"{}:{alertname=\\\"Load avg 15m too high\\\", grafana_folder=\\\"Node alerts\\\"}\",\"truncatedAlerts\":0,\"orgId\":1,\"title\":\"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)\",\"state\":\"ok\",\"message\":\"**Resolved**\\n\\nValue: B=18.98211314475876, C=0\\nLabels:\\n - alertname = Load avg 15m too high\\n - grafana_folder = Node alerts\\n - instance = 10.108.0.2:9100\\n - job = node-exporter\\nAnnotations:\\n - summary = 15m load average too high\\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\\n\"}\n    ```\n\nHere's an **easier example with a shorter JSON payload**:\n\n=== \"Command line (curl)\"\n    ```\n    # To use { and } in the URL without encoding, we need to turn off\n    # curl's globbing using --globoff\n\n    curl \\\n        --globoff \\\n        -d '{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}' \\\n        'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}}'\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}} HTTP/1.1\n    Host: ntfy.sh\n\n    {\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}}', {\n        method: 'POST',\n        body: '{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}'\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    body := `{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}`\n    uri := `https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if eq .error.level \"severe\"}}5{{else}}3{{end}}`\n    req, _ := http.NewRequest(\"POST\", uri, strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n        Method = \"POST\"\n        URI = 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}}'\n        Body = '{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}'\n        ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\n        'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}}',\n        data='{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}'\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+\"severe\"}}5{{else}}3{{end}}', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => '{\"hostname\": \"phil-pc\", \"error\": {\"level\": \"severe\", \"desc\": \"Disk has run out of space\"}}'\n        ]\n    ]));\n    ```\n\nThis example uses the `message`/`m`, `title`/`t`, and `priority`/`p` query parameters, but obviously this also works with the \ncorresponding headers. It will send a notification with a title `phil-pc: A severe error has occurred`, a message\n`Error message: Disk has run out of space`, and priority `5` (max) if the level is \"severe\", or `3` (default) otherwise.\n\n### Template syntax\nntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful,\nyet also one of the worst templating languages out there.\n\nYou can use the following features in your templates:\n\n* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}`\n* Conditionals (if/else, e.g. `{{if eq .action \"opened\"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))\n* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))\n\nA good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test\nyour templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).\n\n### Template functions\nntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),\nthank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.\n\nBelow are the functions that are available to use inside your message, title, and priority templates.\n\n* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc.\n* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc.\n* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc.\n* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep`\n* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf`\n* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc.\n* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary`\n* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc.\n* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc.\n* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc.\n* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc.\n* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs`\n* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail`\n* Advanced Functions\n    * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc.\n    * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc.\n    * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin`\n\n## E-mail notifications\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can forward messages to e-mail by specifying an address in the header. This can be useful for messages that \nyou'd like to persist longer, or to blast-notify yourself on all possible channels. \n\nUsage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).\nOnly one e-mail address is supported.\n\nSince ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the \ndefault configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of \nthat, your IP address appears in the e-mail body. This is to prevent abuse.\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -H \"Email: phil@example.com\" \\\n        -H \"Tags: warning,skull,backup-host,ssh-login\" \\\n        -H \"Priority: high\" \\\n        -d \"Unknown login from 5.31.23.83 to backups.example.com\" \\\n        ntfy.sh/alerts\n    curl -H \"Email: phil@example.com\" -d \"You've Got Mail\" \n    curl -d \"You've Got Mail\" \"ntfy.sh/alerts?email=phil@example.com\"\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --email=phil@example.com \\\n        --tags=warning,skull,backup-host,ssh-login \\\n        --priority=high \\\n        alerts \"Unknown login from 5.31.23.83 to backups.example.com\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /alerts HTTP/1.1\n    Host: ntfy.sh\n    Email: phil@example.com\n    Tags: warning,skull,backup-host,ssh-login\n    Priority: high\n\n    Unknown login from 5.31.23.83 to backups.example.com\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/alerts', {\n        method: 'POST',\n        body: \"Unknown login from 5.31.23.83 to backups.example.com\",\n        headers: { \n            'Email': 'phil@example.com',\n            'Tags': 'warning,skull,backup-host,ssh-login',\n            'Priority': 'high'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/alerts\", \n        strings.NewReader(\"Unknown login from 5.31.23.83 to backups.example.com\"))\n    req.Header.Set(\"Email\", \"phil@example.com\")\n    req.Header.Set(\"Tags\", \"warning,skull,backup-host,ssh-login\")\n    req.Header.Set(\"Priority\", \"high\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/alerts\"\n      Headers = @{\n        Title = \"Low disk space alert\"\n        Priority = \"high\"\n        Tags = \"warning,skull,backup-host,ssh-login\")\n        Email = \"phil@example.com\"\n      }\n      Body = \"Unknown login from 5.31.23.83 to backups.example.com\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/alerts\",\n        data=\"Unknown login from 5.31.23.83 to backups.example.com\",\n        headers={ \n            \"Email\": \"phil@example.com\",\n            \"Tags\": \"warning,skull,backup-host,ssh-login\",\n            \"Priority\": \"high\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Email: phil@example.com\\r\\n\" .\n                \"Tags: warning,skull,backup-host,ssh-login\\r\\n\" .\n                \"Priority: high\",\n            'content' => 'Unknown login from 5.31.23.83 to backups.example.com'\n        ]\n    ]));\n    ```\n\nHere's what that looks like in Google Mail:\n\n<figure markdown>\n  ![e-mail notification](static/img/screenshot-email.png){ width=600 }\n  <figcaption>E-mail notification</figcaption>\n</figure>\n\n## E-mail publishing\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can\npublish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail \nbased integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).\n\nDepending on the [server configuration](config.md#e-mail-publishing), the e-mail address format can have a prefix to \nprevent spam on topics. For ntfy.sh, the prefix is configured to `ntfy-`, meaning that the general e-mail address \nformat is:\n\n```\nntfy-$topic@ntfy.sh\n```\n\nIf [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work\nwithout providing an authorized access token or using SMTP AUTH PLAIN. \n\nIf you use [access tokens](#access-tokens), that will change the format of the e-mail's recipient address to\n```\nntfy-$topic+$token@ntfy.sh\n```\n\nTo use [username/password](https://docs.ntfy.sh/publish/#username-password), you can use SMTP PLAIN auth when authenticating\nto the ntfy server.\n\nAs of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,\ndelay and other features are not supported (yet). Here's an example that will publish a message with the \ntitle `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):\n\n<figure markdown>\n  ![e-mail publishing](static/img/screenshot-email-publishing-gmail.png){ width=500 }\n  <figcaption>Publishing a message via e-mail</figcaption>\n</figure>\n\n## Phone calls\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nYou can use ntfy to call a phone and **read the message out loud using text-to-speech**. \nSimilar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have \nthe ntfy app installed on their phone.\n\n**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is \n**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone\nnumber in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. \nYou may also simply pass `yes` as a value to pick the first of your verified phone numbers. \nOn ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.\n\n<figure markdown>\n  ![phone number verification](static/img/web-phone-verify.png)\n  <figcaption>Phone number verification in the <a href=\"https://ntfy.sh/account\">web app</a></figcaption>\n</figure>\n\nAs of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll\nbe happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues).\n\n!!! info\n    You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**.\n    This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or\n    violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated.\n\nHere's how you use it:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n        -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \\\n        -H \"Call: +12223334444\" \\\n        -d \"Your garage seems to be on fire. You should probably check that out.\" \\\n        ntfy.sh/alerts\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \\\n        --call=+12223334444 \\\n        alerts \"Your garage seems to be on fire. You should probably check that out.\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /alerts HTTP/1.1\n    Host: ntfy.sh\n    Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n    Call: +12223334444\n\n    Your garage seems to be on fire. You should probably check that out.\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/alerts', {\n        method: 'POST',\n        body: \"Your garage seems to be on fire. You should probably check that out.\",\n        headers: { \n            'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',\n            'Call': '+12223334444'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/alerts\", \n        strings.NewReader(\"Your garage seems to be on fire. You should probably check that out.\"))\n    req.Header.Set(\"Call\", \"+12223334444\")\n    req.Header.Set(\"Authorization\", \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/alerts\"\n      Headers = @{\n        Authorization = \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\"\n        Call = \"+12223334444\"\n      }\n      Body = \"Your garage seems to be on fire. You should probably check that out.\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/alerts\",\n        data=\"Your garage seems to be on fire. You should probably check that out.\",\n        headers={ \n            \"Authorization\": \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\",\n            \"Call\": \"+12223334444\"\n        })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\\r\\n\" .\n                \"Call: +12223334444\",\n            'content' => 'Your garage seems to be on fire. You should probably check that out.'\n        ]\n    ]));\n    ```\n\nHere's what a phone call from ntfy sounds like:\n\n<audio controls>\n    <source src=\"../static/audio/ntfy-phone-call.mp3\" type=\"audio/mpeg\">\n    <source src=\"../static/audio/ntfy-phone-call.ogg\" type=\"audio/ogg\">\n</audio>\n\nAudio transcript:\n\n> You have a notification from ntfy on topic alerts.\n> Message: Your garage seems to be on fire. You should probably check that out. End message.   \n> This message was sent by user phil. It will be repeated up to three times.\n\n## Publish as JSON\n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nFor some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), \nadding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message \nas JSON in the request body.\n\nTo publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below\nthe example.\n\n!!! info\n    To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're\n    POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect). \n\nHere's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter \nis the only required one:\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh \\\n      -d '{\n        \"topic\": \"mytopic\",\n        \"message\": \"Disk space is low at 5.1 GB\",\n        \"title\": \"Low disk space alert\",\n        \"tags\": [\"warning\",\"cd\"],\n        \"priority\": 4,\n        \"attach\": \"https://filesrv.lan/space.jpg\",\n        \"filename\": \"diskspace.jpg\",\n        \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n        \"actions\": [{ \"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" }]\n      }'\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST / HTTP/1.1\n    Host: ntfy.sh\n\n    {\n        \"topic\": \"mytopic\",\n        \"message\": \"Disk space is low at 5.1 GB\",\n        \"title\": \"Low disk space alert\",\n        \"tags\": [\"warning\",\"cd\"],\n        \"priority\": 4,\n        \"attach\": \"https://filesrv.lan/space.jpg\",\n        \"filename\": \"diskspace.jpg\",\n        \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n        \"actions\": [{ \"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" }]\n    }\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh', {\n        method: 'POST',\n        body: JSON.stringify({\n            \"topic\": \"mytopic\",\n            \"message\": \"Disk space is low at 5.1 GB\",\n            \"title\": \"Low disk space alert\",\n            \"tags\": [\"warning\",\"cd\"],\n            \"priority\": 4,\n            \"attach\": \"https://filesrv.lan/space.jpg\",\n            \"filename\": \"diskspace.jpg\",\n            \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n            \"actions\": [{ \"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" }]\n        })\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    // You should probably use json.Marshal() instead and make a proper struct,\n    // or even just use req.Header.Set() like in the other examples, but for the \n    // sake of the example, this is easier.\n    \n    body := `{\n        \"topic\": \"mytopic\",\n        \"message\": \"Disk space is low at 5.1 GB\",\n        \"title\": \"Low disk space alert\",\n        \"tags\": [\"warning\",\"cd\"],\n        \"priority\": 4,\n        \"attach\": \"https://filesrv.lan/space.jpg\",\n        \"filename\": \"diskspace.jpg\",\n        \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n        \"actions\": [{ \"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" }]\n    }`\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/\", strings.NewReader(body))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh\"\n      Body = ConvertTo-JSON @{\n        Topic    = \"mytopic\"\n        Title    = \"Low disk space alert\"\n        Message  = \"Disk space is low at 5.1 GB\"\n        Priority = 4\n        Attach   = \"https://filesrv.lan/space.jpg\"\n        FileName = \"diskspace.jpg\"\n        Tags     = @(\"warning\", \"cd\")\n        Click    = \"https://homecamera.lan/xasds1h2xsSsa/\"\n        Actions  = @(\n          @{ \n            Action = \"view\"\n            Label  = \"Admin panel\"\n            URL    = \"https://filesrv.lan/admin\"\n          }\n        )\n      }\n      ContentType = \"application/json\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/\",\n        data=json.dumps({\n            \"topic\": \"mytopic\",\n            \"message\": \"Disk space is low at 5.1 GB\",\n            \"title\": \"Low disk space alert\",\n            \"tags\": [\"warning\",\"cd\"],\n            \"priority\": 4,\n            \"attach\": \"https://filesrv.lan/space.jpg\",\n            \"filename\": \"diskspace.jpg\",\n            \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n            \"actions\": [{ \"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" }]\n        })\n    )\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"Content-Type: application/json\",\n            'content' => json_encode([\n                \"topic\": \"mytopic\",\n                \"message\": \"Disk space is low at 5.1 GB\",\n                \"title\": \"Low disk space alert\",\n                \"tags\": [\"warning\",\"cd\"],\n                \"priority\": 4,\n                \"attach\": \"https://filesrv.lan/space.jpg\",\n                \"filename\": \"diskspace.jpg\",\n                \"click\": \"https://homecamera.lan/xasds1h2xsSsa/\",\n                \"actions\": [[\"action\": \"view\", \"label\": \"Admin panel\", \"url\": \"https://filesrv.lan/admin\" ]]\n            ])\n        ]\n    ]));\n    ```\n\nThe JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md) \n(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of\nall the supported fields:\n\n| Field         | Required | Type                             | Example                                   | Description                                                                               |\n|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|\n| `topic`       | ✔️       | *string*                         | `topic1`                                  | Target topic name                                                                         |\n| `message`     | -        | *string*                         | `Some message`                            | Message body; set to `triggered` if empty or not passed                                   |\n| `title`       | -        | *string*                         | `Some title`                              | Message [title](#message-title)                                                           |\n| `tags`        | -        | *string array*                   | `[\"tag1\",\"tag2\"]`                         | List of [tags](#tags-emojis) that may or not map to emojis                                |\n| `priority`    | -        | *int (one of: 1, 2, 3, 4, or 5)* | `4`                                       | Message [priority](#message-priority) with 1=min, 3=default and 5=max                     |\n| `actions`     | -        | *JSON array*                     | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications                           |\n| `click`       | -        | *URL*                            | `https://example.com`                     | Website opened when notification is [clicked](#click-action)                              |\n| `attach`      | -        | *URL*                            | `https://example.com/file.jpg`            | URL of an attachment, see [attach via URL](#attach-file-from-a-url)                       |\n| `markdown`    | -        | *bool*                           | `true`                                    | Set to true if the `message` is Markdown-formatted                                        |\n| `icon`        | -        | *string*                         | `https://example.com/icon.png`            | URL to use as notification [icon](#icons)                                                 |\n| `filename`    | -        | *string*                         | `file.jpg`                                | File name of the attachment                                                               |\n| `delay`       | -        | *string*                         | `30min`, `9am`                            | Timestamp or duration for delayed delivery                                                |\n| `email`       | -        | *e-mail address*                 | `phil@example.com`                        | E-mail address for e-mail notifications                                                   |\n| `call`        | -        | *phone number or 'yes'*          | `+1222334444` or `yes`                    | Phone number to use for [voice call](#phone-calls)                                        |\n| `sequence_id` | -        | *string*                         | `my-sequence-123`                         | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications)   |\n\n## Webhooks (publish via GET) \n_Supported on:_ :material-android: :material-apple: :material-firefox:\n\nIn addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use \na ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support.\n\nTo send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without \nany arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are \nalso supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all \n[supported parameters and headers](#list-of-all-parameters) for details.\n\nFor instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message \n(aka trigger the webhook):\n\n=== \"Command line (curl)\"\n    ```\n    curl ntfy.sh/mywebhook/trigger\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy trigger mywebhook\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /mywebhook/trigger HTTP/1.1\n    Host: ntfy.sh\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mywebhook/trigger')\n    ```\n\n=== \"Go\"\n    ``` go\n    http.Get(\"https://ntfy.sh/mywebhook/trigger\")\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    Invoke-RestMethod \"ntfy.sh/mywebhook/trigger\"\n    ```    \n\n=== \"Python\"\n    ``` python\n    requests.get(\"https://ntfy.sh/mywebhook/trigger\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mywebhook/trigger');\n    ```\n\nTo add a custom message, simply append the `message=` URL parameter. And of course you can set the \n[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. \nFor a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).\n\nHere's an example with a custom message, tags and a priority:\n\n=== \"Command line (curl)\"\n    ```\n    curl \"ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull\"\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        -p 5 --tags=warning,skull \\\n        mywebhook \"Webhook triggered\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1\n    Host: ntfy.sh\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')\n    ```\n\n=== \"Go\"\n    ``` go\n    http.Get(\"https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull\")\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    Invoke-RestMethod \"ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull\"\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.get(\"https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');\n    ```\n\n## Updating + deleting notifications\n_Supported on:_ :material-android: :material-firefox:\n\nYou can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios\nlike download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.\n\n* [Updating notifications](#updating-notifications) will alter the content of an existing notification.\n* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.\n* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).\n\nHere's an example of a download progress notification being updated over time on Android:\n\n<div id=\"updating-notifications-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-notification-update-1.png\"><img src=\"../../static/img/android-screenshot-notification-update-1.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-notification-update-2.png\"><img src=\"../../static/img/android-screenshot-notification-update-2.png\"/></a>\n</div>\n\nTo facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence,\nusing a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the\nsame sequence ID and the clients will perform the appropriate action on the existing notification.\n\nExisting ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates\nthe update, clear, or delete action. This append-only behavior ensures that message history remains intact.\n\n### Updating notifications\nTo update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous\nnotification with the new one. You can either:\n\n1. **Use the message ID**: First publish like normal to `POST /<topic>` without a sequence ID, then use the returned message `id` as the sequence ID for updates\n2. **Use a custom sequence ID**: Publish directly to `POST /<topic>/<sequence_id>` with your own identifier, or use `POST /<topic>` with the \n   `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`)\n\nIf you don't know the sequence ID ahead of time, you can publish a message first and then use the returned \nmessage `id` to update it. Here's an example:\n\n=== \"Command line (curl)\"\n    ```bash\n    # First, publish a message and capture the message ID\n    curl -d \"Downloading file...\" ntfy.sh/mytopic\n    # Returns: {\"id\":\"xE73Iyuabi\",\"time\":1673542291,...}\n\n    # Then use the message ID to update it (via URL path)\n    curl -d \"Download 50% ...\" ntfy.sh/mytopic/xE73Iyuabi\n\n    # Or update using the X-Sequence-ID header\n    curl -H \"X-Sequence-ID: xE73Iyuabi\" -d \"Download complete\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```bash\n    # First, publish a message and capture the message ID\n    ntfy pub mytopic \"Downloading file...\"\n    # Returns: {\"id\":\"xE73Iyuabi\",\"time\":1673542291,...}\n\n    # Then use the message ID to update it\n    ntfy pub --sequence-id=xE73Iyuabi mytopic \"Download 50% ...\"\n\n    # Update again with the same sequence ID\n    ntfy pub -S xE73Iyuabi mytopic \"Download complete\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    # First, publish a message and capture the message ID\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n\n    Downloading file...\n\n    # Returns: {\"id\":\"xE73Iyuabi\",\"time\":1673542291,...}\n\n    # Then use the message ID to update it\n    POST /mytopic/xE73Iyuabi HTTP/1.1\n    Host: ntfy.sh\n\n    Download 50% ...\n\n    # Update again with the same sequence ID, this time using the header\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    X-Sequence-ID: xE73Iyuabi\n\n    Download complete\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    // First, publish and get the message ID\n    const response = await fetch('https://ntfy.sh/mytopic', {\n      method: 'POST',\n      body: 'Downloading file...'\n    });\n    const { id } = await response.json();\n\n    // Update via URL path\n    await fetch(`https://ntfy.sh/mytopic/${id}`, {\n      method: 'POST',\n      body: 'Download 50% ...'\n    });\n\n    // Or update using the X-Sequence-ID header\n    await fetch('https://ntfy.sh/mytopic', {\n      method: 'POST',\n      headers: { 'X-Sequence-ID': id },\n      body: 'Download complete'\n    });\n    ```\n\n=== \"Go\"\n    ``` go\n    // Publish and parse the response to get the message ID\n    resp, _ := http.Post(\"https://ntfy.sh/mytopic\", \"text/plain\",\n        strings.NewReader(\"Downloading file...\"))\n    var msg struct { ID string `json:\"id\"` }\n    json.NewDecoder(resp.Body).Decode(&msg)\n    \n    // Update via URL path\n    http.Post(\"https://ntfy.sh/mytopic/\"+msg.ID, \"text/plain\",\n        strings.NewReader(\"Download 50% ...\"))\n\n    // Or update using the X-Sequence-ID header\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic\",\n        strings.NewReader(\"Download complete\"))\n    req.Header.Set(\"X-Sequence-ID\", msg.ID)\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Publish and get the message ID\n    $response = Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic\" -Body \"Downloading file...\"\n    $messageId = $response.id\n    \n    # Update via URL path\n    Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic/$messageId\" -Body \"Download 50% ...\"\n\n    # Or update using the X-Sequence-ID header\n    Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic\" `\n        -Headers @{\"X-Sequence-ID\"=$messageId} -Body \"Download complete\"\n    ```\n\n=== \"Python\"\n    ``` python\n    import requests\n    \n    # Publish and get the message ID\n    response = requests.post(\"https://ntfy.sh/mytopic\", data=\"Downloading file...\")\n    message_id = response.json()[\"id\"]\n    \n    # Update via URL path\n    requests.post(f\"https://ntfy.sh/mytopic/{message_id}\", data=\"Download 50% ...\")\n\n    # Or update using the X-Sequence-ID header\n    requests.post(\"https://ntfy.sh/mytopic\",\n        headers={\"X-Sequence-ID\": message_id}, data=\"Download complete\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    // Publish and get the message ID\n    $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => ['method' => 'POST', 'content' => 'Downloading file...']\n    ]));\n    $messageId = json_decode($response)->id;\n    \n    // Update via URL path\n    file_get_contents(\"https://ntfy.sh/mytopic/$messageId\", false, stream_context_create([\n        'http' => ['method' => 'POST', 'content' => 'Download 50% ...']\n    ]));\n\n    // Or update using the X-Sequence-ID header\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => \"X-Sequence-ID: $messageId\",\n            'content' => 'Download complete'\n        ]\n    ]));\n    ```\n\nYou can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message. \n**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to\n`/<topic>/<sequence_id>`:\n\n=== \"Command line (curl)\"\n    ```bash\n    # Publish with a custom sequence ID\n    curl -d \"Downloading file...\" ntfy.sh/mytopic/my-download-123\n\n    # Update using the same sequence ID (via URL path)\n    curl -d \"Download 50% ...\" ntfy.sh/mytopic/my-download-123\n\n    # Or update using the X-Sequence-ID header\n    curl -H \"X-Sequence-ID: my-download-123\" -d \"Download complete\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```bash\n    # Publish with a sequence ID\n    ntfy pub --sequence-id=my-download-123 mytopic \"Downloading file...\"\n\n    # Update using the same sequence ID\n    ntfy pub --sequence-id=my-download-123 mytopic \"Download 50% ...\"\n\n    # Update again\n    ntfy pub -S my-download-123 mytopic \"Download complete\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    # Publish a message with a custom sequence ID\n    POST /mytopic/my-download-123 HTTP/1.1\n    Host: ntfy.sh\n\n    Downloading file...\n\n    # Update again using the X-Sequence-ID header\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    X-Sequence-ID: my-download-123\n\n    Download complete\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    // First message\n    await fetch('https://ntfy.sh/mytopic/my-download-123', {\n      method: 'POST',\n      body: 'Downloading file...'\n    });\n\n    // Update via URL path\n    await fetch('https://ntfy.sh/mytopic/my-download-123', {\n      method: 'POST',\n      body: 'Download 50% ...'\n    });\n\n    // Or update using the X-Sequence-ID header\n    await fetch('https://ntfy.sh/mytopic', {\n      method: 'POST',\n      headers: { 'X-Sequence-ID': 'my-download-123' },\n      body: 'Download complete'\n    });\n    ```\n\n=== \"Go\"\n    ``` go\n    // Publish with sequence ID in URL path\n    http.Post(\"https://ntfy.sh/mytopic/my-download-123\", \"text/plain\",\n        strings.NewReader(\"Downloading file...\"))\n    \n    // Update via URL path\n    http.Post(\"https://ntfy.sh/mytopic/my-download-123\", \"text/plain\",\n        strings.NewReader(\"Download 50% ...\"))\n\n    // Or update using the X-Sequence-ID header\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic\",\n        strings.NewReader(\"Download complete\"))\n    req.Header.Set(\"X-Sequence-ID\", \"my-download-123\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Publish with sequence ID\n    Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic/my-download-123\" -Body \"Downloading file...\"\n    \n    # Update via URL path\n    Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic/my-download-123\" -Body \"Download 50% ...\"\n\n    # Or update using the X-Sequence-ID header\n    Invoke-RestMethod -Method POST -Uri \"https://ntfy.sh/mytopic\" `\n        -Headers @{\"X-Sequence-ID\"=\"my-download-123\"} -Body \"Download complete\"\n    ```\n\n=== \"Python\"\n    ``` python\n    import requests\n    \n    # Publish with sequence ID\n    requests.post(\"https://ntfy.sh/mytopic/my-download-123\", data=\"Downloading file...\")\n    \n    # Update via URL path\n    requests.post(\"https://ntfy.sh/mytopic/my-download-123\", data=\"Download 50% ...\")\n\n    # Or update using the X-Sequence-ID header\n    requests.post(\"https://ntfy.sh/mytopic\",\n        headers={\"X-Sequence-ID\": \"my-download-123\"}, data=\"Download complete\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    // Publish with sequence ID\n    file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([\n        'http' => ['method' => 'POST', 'content' => 'Downloading file...']\n    ]));\n    \n    // Update via URL path\n    file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([\n        'http' => ['method' => 'POST', 'content' => 'Download 50% ...']\n    ]));\n\n    // Or update using the X-Sequence-ID header\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' => 'X-Sequence-ID: my-download-123',\n            'content' => 'Download complete'\n        ]\n    ]));\n    ```\n\nYou can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when\n[publishing as JSON](#publish-as-json) using the `sequence_id` field.\n\nIf the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id`\nfield the response. A sequence of updates may look like this (first example from above):\n\n```json\n{\"id\":\"xE73Iyuabi\",\"time\":1673542291,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Downloading file...\"}\n{\"id\":\"yF84Jzvbcj\",\"time\":1673542295,\"event\":\"message\",\"topic\":\"mytopic\",\"sequence_id\":\"xE73Iyuabi\",\"message\":\"Download 50% ...\"}\n{\"id\":\"zG95Kawdde\",\"time\":1673542300,\"event\":\"message\",\"topic\":\"mytopic\",\"sequence_id\":\"xE73Iyuabi\",\"message\":\"Download complete\"}\n```\n\n### Clearing notifications\nClearing a notification means **marking it as read and dismissing it from the notification drawer**. \n\nTo do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias). \nThis will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status\nand dismiss the notification.\n\n=== \"Command line (curl)\"\n    ```bash\n    curl -X PUT ntfy.sh/mytopic/my-download-123/clear\n    ```\n\n=== \"HTTP\"\n    ``` http\n    PUT /mytopic/my-download-123/clear HTTP/1.1\n    Host: ntfy.sh\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {\n      method: 'PUT'\n    });\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"PUT\", \"https://ntfy.sh/mytopic/my-download-123/clear\", nil)\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    Invoke-RestMethod -Method PUT -Uri \"https://ntfy.sh/mytopic/my-download-123/clear\"\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.put(\"https://ntfy.sh/mytopic/my-download-123/clear\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([\n        'http' => ['method' => 'PUT']\n    ]));\n    ```\n\nAn example response from the server with the `message_clear` event may look like this:\n\n```json\n{\"id\":\"jkl012\",\"time\":1673542305,\"event\":\"message_clear\",\"topic\":\"mytopic\",\"sequence_id\":\"my-download-123\"}\n```\n\n### Deleting notifications\nDeleting a notification means **removing it from the notification drawer and from the client's database**.\n\nTo do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event\nthat is used by the clients (web app and Android app) to remove the notification entirely.\n\n=== \"Command line (curl)\"\n    ```bash\n    curl -X DELETE ntfy.sh/mytopic/my-download-123\n    ```\n\n=== \"HTTP\"\n    ``` http\n    DELETE /mytopic/my-download-123 HTTP/1.1\n    Host: ntfy.sh\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    await fetch('https://ntfy.sh/mytopic/my-download-123', {\n      method: 'DELETE'\n    });\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"DELETE\", \"https://ntfy.sh/mytopic/my-download-123\", nil)\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    Invoke-RestMethod -Method DELETE -Uri \"https://ntfy.sh/mytopic/my-download-123\"\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.delete(\"https://ntfy.sh/mytopic/my-download-123\")\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([\n        'http' => ['method' => 'DELETE']\n    ]));\n    ```\n\nAn example response from the server with the `message_delete` event may look like this:\n\n```json\n{\"id\":\"mno345\",\"time\":1673542400,\"event\":\"message_delete\",\"topic\":\"mytopic\",\"sequence_id\":\"my-download-123\"}\n```\n\n!!! info\n    Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will\n    reappear as a new message.\n\n## Authentication\nDepending on whether the server is configured to support [access control](config.md#access-control), some topics\nmay be read/write protected so that only users with the correct credentials can subscribe or publish to them.\nTo publish/subscribe to protected topics, you can: \n\n* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`\n* Use [access tokens](#access-tokens) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`\n* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`\n\n!!! warning\n    When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your \n    self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password. \n\n### Username + password\nThe simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).\nHere's an example with a user `testuser` and password `fakepassword`:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -u testuser:fakepassword \\\n      -d \"Look ma, with auth\" \\\n      https://ntfy.example.com/mysecrets\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n      -u testuser:fakepassword \\\n      ntfy.example.com/mysecrets \\\n      \"Look ma, with auth\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mysecrets HTTP/1.1\n    Host: ntfy.example.com\n    Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk\n\n    Look ma, with auth\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mysecrets', {\n        method: 'POST', // PUT works too\n        body: 'Look ma, with auth',\n        headers: {\n            'Authorization': 'Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mysecrets\",\n    strings.NewReader(\"Look ma, with auth\"))\n    req.Header.Set(\"Authorization\", \"Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell 7+\"\n    ``` powershell\n    # Get the credentials from the user\n    $Credential = Get-Credential testuser\n\n    # Alternatively, create a PSCredential object with the password from scratch\n    $Credential = [PSCredential]::new(\"testuser\", (ConvertTo-SecureString \"password\" -AsPlainText -Force))\n    \n    # Note that the Authentication parameter requires PowerShell 7 or later\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets\"\n      Authentication = \"Basic\"\n      Credential = $Credential\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"PowerShell 5 and earlier\"\n    ``` powershell\n    # With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves\n    $CredentialString = \"$($Credential.Username):$($Credential.GetNetworkCredential().Password)\"\n    $EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString))\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets\"\n      Headers = @{ Authorization = \"Basic $EncodedCredential\"}\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mysecrets\",\n    data=\"Look ma, with auth\",\n    headers={\n        \"Authorization\": \"Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk\"\n    })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' =>\n                'Content-Type: text/plain\\r\\n' .\n                'Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk',\n            'content' => 'Look ma, with auth'\n        ]\n    ]));\n    ```\n\nTo generate the `Authorization` header, use **standard base64** to encode the colon-separated `<username>:<password>` \nand prepend the word `Basic`, i.e. `Authorization: Basic base64(<username>:<password>)`. Here's some pseudo-code that \nhopefully explains it better:\n\n```\nusername   = \"testuser\"\npassword   = \"fakepassword\"\nauthHeader = \"Basic \" + base64(username + \":\" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk\n```\n\nThe following command will generate the appropriate value for you on *nix systems:\n\n```\necho \"Basic $(echo -n 'testuser:fakepassword' | base64)\"\n```\n\n### Access tokens\nIn addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful\nto avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may\nwant to use a dedicated token to publish from your backup host, and one from your home automation system.\n\nYou can create access tokens using the `ntfy token` command, or in the web app in the \"Account\" section (when logged in).\nSee [access tokens](config.md#access-tokens) for details.\n\nOnce an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or \nsubscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/),\nwith the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -H \"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\" \\\n      -d \"Look ma, with auth\" \\\n      https://ntfy.example.com/mysecrets\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n      --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \\\n      ntfy.example.com/mysecrets \\\n      \"Look ma, with auth\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mysecrets HTTP/1.1\n    Host: ntfy.example.com\n    Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\n\n    Look ma, with auth\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mysecrets', {\n        method: 'POST', // PUT works too\n        body: 'Look ma, with auth',\n        headers: {\n            'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mysecrets\",\n    strings.NewReader(\"Look ma, with auth\"))\n    req.Header.Set(\"Authorization\", \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell 7+\"\n    ``` powershell\n    # With PowerShell 7 or greater, we can use the Authentication and Token parameters\n    # The Token parameter must be in the form of a System.Security.SecureString\n\t\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets\"\n      Authentication = \"Bearer\"\n      Token = ConvertTo-SecureString \"tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\" -AsPlainText\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"PowerShell 5 and earlier\"\n    ``` powershell\n    # In PowerShell 5 and below, we can only send the Bearer token as a string in the Headers\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets\"\n      Headers = @{ Authorization = \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\" }\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mysecrets\",\n    data=\"Look ma, with auth\",\n    headers={\n        \"Authorization\": \"Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\"\n    })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' =>\n                'Content-Type: text/plain\\r\\n' .\n                'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',\n            'content' => 'Look ma, with auth'\n        ]\n    ]));\n    ```\n\nAlternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the \naccess token. When sending an empty username, the basic auth password is treated by the ntfy server as an \naccess token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \\\n      -d \"Look ma, with auth\" \\\n      https://ntfy.example.com/mysecrets\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n      --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \\\n      ntfy.example.com/mysecrets \\\n      \"Look ma, with auth\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mysecrets HTTP/1.1\n    Host: ntfy.example.com\n    Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy\n\n    Look ma, with auth\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mysecrets', {\n        method: 'POST', // PUT works too\n        body: 'Look ma, with auth',\n        headers: {\n            'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy'\n        }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mysecrets\",\n    strings.NewReader(\"Look ma, with auth\"))\n    req.Header.Set(\"Authorization\", \"Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    # Note that PSCredentials *must* have a username, so we fall back to placing the authorization in the Headers as with PowerShell 5\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets\"\n      Headers = @{\n        Authorization = \"Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy\"\n      }\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mysecrets\",\n    data=\"Look ma, with auth\",\n    headers={\n        \"Authorization\": \"Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy\"\n    })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' =>\n                'Content-Type: text/plain\\r\\n' .\n                'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy',\n            'content' => 'Look ma, with auth'\n        ]\n    ]));\n    ```\n\n\n### Query param\nHere's an example using the `auth` query parameter:\n\n=== \"Command line (curl)\"\n    ```\n    curl \\\n      -d \"Look ma, with auth\" \\\n      \"https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw\"\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n      -u testuser:fakepassword \\\n      ntfy.example.com/mysecrets \\\n      \"Look ma, with auth\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw HTTP/1.1\n    Host: ntfy.example.com\n\n    Look ma, with auth\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', {\n        method: 'POST', // PUT works too\n        body: 'Look ma, with auth'\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw\",\n        strings.NewReader(\"Look ma, with auth\"))\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw\"\n      Body = \"Look ma, with auth\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw\",\n    data=\"Look ma, with auth\"\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', false, stream_context_create([\n        'http' => [\n            'method' => 'POST', // PUT also works\n            'header' => 'Content-Type: text/plain',\n            'content' => 'Look ma, with auth'\n        ]\n    ]));\n    ```\n\nTo generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using \n**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully \nexplains it better:\n\n```\nusername   = \"testuser\"\npassword   = \"fakepassword\"\nauthHeader = \"Basic \" + base64(username + \":\" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk\nauthParam  = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw (no trailing =)\n\n// If your language does not have a function to encode raw base64, simply use normal base64\n// and REMOVE TRAILING \"=\" characters. \n```\n\nThe following command will generate the appropriate value for you on *nix systems:\n\n```\necho -n \"Basic `echo -n 'testuser:fakepassword' | base64 -w0`\" | base64 -w0 | tr -d '='\n```\n\nFor access tokens, you can use this instead:\n\n```\necho -n \"Bearer faketoken\" | base64 -w0 | tr -d '='\n```\n\n## Advanced features\n\n### Message caching\n!!! info\n    If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a \n    client re-connects. If a subscriber has (temporary) network issues or is reconnecting momentarily, \n    **messages might be missed**.\n\nBy default, the ntfy server caches messages on disk for 12 hours (see [message caching](config.md#message-cache)), so\nall messages you publish are stored server-side for a little while. The reason for this is to overcome temporary \nclient-side network disruptions, but arguably this feature also may raise privacy concerns.\n\nTo avoid messages being cached server-side entirely, you can set `X-Cache` header (or its alias: `Cache`) to `no`. \nThis will make sure that your message is not cached on the server, even if server-side caching is enabled. Messages\nare still delivered to connected subscribers, but [`since=`](subscribe/api.md#fetch-cached-messages) and \n[`poll=1`](subscribe/api.md#poll-for-messages) won't return the message anymore.\n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"X-Cache: no\" -d \"This message won't be stored server-side\" ntfy.sh/mytopic\n    curl -H \"Cache: no\" -d \"This message won't be stored server-side\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --no-cache \\\n        mytopic \"This message won't be stored server-side\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    Cache: no\n\n    This message won't be stored server-side\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic', {\n        method: 'POST',\n        body: 'This message won't be stored server-side',\n        headers: { 'Cache': 'no' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic\", strings.NewReader(\"This message won't be stored server-side\"))\n    req.Header.Set(\"Cache\", \"no\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mytopic\"\n      Headers = @{ Cache=\"no\" }\n      Body = \"This message won't be stored server-side\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mytopic\",\n        data=\"This message won't be stored server-side\",\n        headers={ \"Cache\": \"no\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Cache: no\",\n            'content' => 'This message won't be stored server-side'\n        ]\n    ]));\n    ```\n\n### Disable Firebase\n!!! info\n    If `Firebase: no` is used and [instant delivery](subscribe/phone.md#instant-delivery) isn't enabled in the Android \n    app (Google Play variant only), **message delivery will be significantly delayed (up to 15 minutes)**. To overcome \n    this delay, simply enable instant delivery.\n\nThe ntfy server can be configured to use [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging)\n(see [Firebase config](config.md#firebase-fcm)) for message delivery on Android (to minimize the app's battery footprint). \nThe ntfy.sh server is configured this way, meaning that all messages published to ntfy.sh are also published to corresponding\nFCM topics.\n\nIf you'd like to avoid forwarding messages to Firebase, you can set the `X-Firebase` header (or its alias: `Firebase`)\nto `no`. This will instruct the server not to forward messages to Firebase.\n\n=== \"Command line (curl)\"\n    ```\n    curl -H \"X-Firebase: no\" -d \"This message won't be forwarded to FCM\" ntfy.sh/mytopic\n    curl -H \"Firebase: no\" -d \"This message won't be forwarded to FCM\" ntfy.sh/mytopic\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    ntfy publish \\\n        --no-firebase \\\n        mytopic \"This message won't be forwarded to FCM\"\n    ```\n\n=== \"HTTP\"\n    ``` http\n    POST /mytopic HTTP/1.1\n    Host: ntfy.sh\n    Firebase: no\n\n    This message won't be forwarded to FCM\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    fetch('https://ntfy.sh/mytopic', {\n        method: 'POST',\n        body: 'This message won't be forwarded to FCM',\n        headers: { 'Firebase': 'no' }\n    })\n    ```\n\n=== \"Go\"\n    ``` go\n    req, _ := http.NewRequest(\"POST\", \"https://ntfy.sh/mytopic\", strings.NewReader(\"This message won't be forwarded to FCM\"))\n    req.Header.Set(\"Firebase\", \"no\")\n    http.DefaultClient.Do(req)\n    ```\n\n=== \"PowerShell\"\n    ``` powershell\n    $Request = @{\n      Method = \"POST\"\n      URI = \"https://ntfy.sh/mytopic\"\n      Headers = @{ Firebase=\"no\" }\n      Body = \"This message won't be forwarded to FCM\"\n    }\n    Invoke-RestMethod @Request\n    ```\n\n=== \"Python\"\n    ``` python\n    requests.post(\"https://ntfy.sh/mytopic\",\n        data=\"This message won't be forwarded to FCM\",\n        headers={ \"Firebase\": \"no\" })\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([\n        'http' => [\n            'method' => 'POST',\n            'header' =>\n                \"Content-Type: text/plain\\r\\n\" .\n                \"Firebase: no\",\n            'content' => 'This message won't be stored server-side'\n        ]\n    ]));\n    ```\n\n### UnifiedPush\n!!! info\n    This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org). \n\n[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned\n[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications\nin the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.\n\nWhen publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query\nparameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this\noption is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally \nenables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.\n\n### Matrix Gateway\nThe ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with\n[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate\nwith self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since \nyou don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)).\n\nIn short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)), \nand forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the\nntfy Android app, and passed on to the Matrix client there.\n\nThere is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the\nntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol.\n\n!!! info\n    This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy.\n\n## Public topics\nObviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics\nthat you can use to try out what [authentication and access control](#authentication) looks like.\n\n| Topic                                          | User                              | Permissions                                          | Description                          |\n|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|\n| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated)             | Read-only for everyone                               | Release announcements and such       |\n| [stats](https://ntfy.sh/stats)                 | `*` (unauthenticated)             | Read-only for everyone                               | Daily statistics about ntfy.sh usage |\n\n## Limitations\nThere are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings \nare configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,\nbut just in case, let's list them all:\n\n| Limit                      | Description                                                                                                                                                                                                             |\n|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| **Message length**         | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments).                                                                                                                 |\n| **Requests**               | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds.                                                |\n| **Daily messages**         | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250.                                                                               |\n| **E-mails**                | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5.                      |\n| **Phone calls**            | By default, the server does not allow any phone calls, except for users with a tier that has a call limit.                                                                                                              |\n| **Subscription limit**     | By default, the server allows each visitor to keep 30 connections to the server open.                                                                                                                                   |\n| **Attachment size limit**  | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |\n| **Attachment expiry**      | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit.                                                                                            |\n| **Attachment bandwidth**   | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB.                      |\n| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though.                                                                                                               |\n\nThese limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing\na higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. \n\n## List of all parameters\nThe following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**\nwhen used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the \ntable in their canonical form.\n\n!!! info\n    ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).\n    If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any\n    header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),\n    or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).\n\n| Parameter       | Aliases                                    | Description                                                                                   |\n|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|\n| `X-Message`     | `Message`, `m`                             | Main body of the message as shown in the notification                                         |\n| `X-Title`       | `Title`, `t`                               | [Message title](#message-title)                                                               |\n| `X-Sequence-ID` | `Sequence-ID`, `SID`                       | [Sequence ID](#updating-deleting-notifications) for updating/clearing/deleting notifications  |\n| `X-Priority`    | `Priority`, `prio`, `p`                    | [Message priority](#message-priority)                                                         |\n| `X-Tags`        | `Tags`, `Tag`, `ta`                        | [Tags and emojis](#tags-emojis)                                                               |\n| `X-Delay`       | `Delay`, `X-At`, `At`, `X-In`, `In`        | Timestamp or duration for [delayed delivery](#scheduled-delivery)                             |\n| `X-Actions`     | `Actions`, `Action`                        | JSON array or short format of [user actions](#action-buttons)                                 |\n| `X-Click`       | `Click`                                    | URL to open when [notification is clicked](#click-action)                                     |\n| `X-Attach`      | `Attach`, `a`                              | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |\n| `X-Markdown`    | `Markdown`, `md`                           | Enable [Markdown formatting](#markdown-formatting) in the notification body                   |\n| `X-Icon`        | `Icon`                                     | URL to use as notification [icon](#icons)                                                     |\n| `X-Filename`    | `Filename`, `file`, `f`                    | Optional [attachment](#attachments) filename, as it appears in the client                     |\n| `X-Email`       | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications)                              |\n| `X-Call`        | `Call`                                     | Phone number for [phone calls](#phone-calls)                                                  |\n| `X-Cache`       | `Cache`                                    | Allows disabling [message caching](#message-caching)                                          |\n| `X-Firebase`    | `Firebase`                                 | Allows disabling [sending to Firebase](#disable-firebase)                                     |\n| `X-UnifiedPush` | `UnifiedPush`, `up`                        | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps               |\n| `X-Poll-ID`     | `Poll-ID`                                  | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications)    |\n| `Authorization` | -                                          | If supported by the server, you can [login to access](#authentication) protected topics       |\n| `Content-Type`  | -                                          | If set to `text/markdown`, [Markdown formatting](#markdown-formatting) is enabled             |\n"
  },
  {
    "path": "docs/releases.md",
    "content": "# Release notes\nBinaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)\nand the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).\n\n## Current stable releases\n\n| Component        | Version | Release date |\n|------------------|---------|--------------|\n| ntfy server      | v2.19.2 | Mar 16, 2026 |\n| ntfy Android app | v1.24.0 | Mar 5, 2026  |\n| ntfy iOS app     | v1.3    | Nov 26, 2023 |\n\nPlease check out the release notes for [upcoming releases](#not-released-yet) below.\n\n### ntfy server v2.19.2\nReleased March 16, 2026\n\nThis is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to\nfurther reduce primary load.\n\n**Bug fixes + maintenance:**\n\n* Fix race condition in web push subscription causing FK constraint violation when concurrent requests hit the same endpoint\n* Route authorization query to read-only database replica to reduce primary database load\n\n## ntfy server v2.19.1\nReleased March 15, 2026\n\nThis is a bugfix release to avoid PostgreSQL insert failures due to invalid UTF-8 messages. It also fixes `database-url`\nvalidation incorrectly rejecting `postgresql://` connection strings.\n\n**Bug fixes + maintenance:**\n\n* Fix invalid UTF-8 in HTTP headers (e.g. Latin-1 encoded text) causing PostgreSQL insert failures and dropping entire message batches\n* Fix `database-url` validation rejecting `postgresql://` connection strings ([#1657](https://github.com/binwiederhier/ntfy/issues/1657)/[#1658](https://github.com/binwiederhier/ntfy/pull/1658))\n\n## ntfy server v2.19.0\nReleased March 15, 2026\n\nThis is a fast-follow release that enables Postgres read replica support.\n\nTo offload read-heavy queries from the primary database, you can optionally configure one or more read replicas\nusing the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, \nchecking access permissions, etc) are distributed across the replicas using round-robin, while all writes and\ncorrectness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back\nto the primary until the replica recovers.\n\n**Features:**\n\n* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))\n* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))\n\n**Bug fixes + maintenance:**\n\n* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)\n* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)\n\n## ntfy server v2.18.0\nReleased March 7, 2026\n\nThis is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from\none [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental).\n\nThe code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,\nwent through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,\nwhich took lots of evenings.\n\nI'll not instantly switch ntfy.sh over. Instead, I'm kindly asking the community to test the Postgres support and report back to me\nif things are working (or not working). There is a [one-off migration tool](https://github.com/binwiederhier/ntfy/tree/main/tools/pgimport) (entirely written by AI) that you can use to migrate.\n\n**Features:**\n\n* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)\n\n**Bug fixes + maintenance:**\n\n* Preserve `<br>` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting)\n\n## ntfy Android v1.24.0\nReleased March 5, 2026\n\nThis is a tiny release that will revert the \"reconnecting ...\" behavior of the foreground notification. Lots of people\nhave complained about it, so I'm replacing it with a notification that shows up when the server connection has failed\nfor >15 minutes, hoping that people will be less annoyed by that.\n\n**Features:**\n\n* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions\n\n**Bug fixes + maintenance:**\n\n* Fix crash in settings when fragment is detached during backup/restore or log operations\n\n## ntfy Android v1.23.0\nReleased February 22, 2026\n\nThis release adds support for search within a topic, and adds [copy action](publish.md#copy-to-clipboard) support\nto the Android app.\n\n**Features:**\n\n* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)\n* Add \"reconnecting to N topics ...\" to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)\n* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))\n* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)\n* Support \"copy\" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)\n\n**Bug fixes + maintenance:**\n\n* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)\n* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))\n* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)\n* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)\n\n## ntfy server v2.17.0\nReleased February 8, 2026\n\nThis release adds support for templating in the priority field, a new \"copy\" action button to copy values to the clipboard,\na red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several\ncrash fixes, web app improvements, and documentation updates.\n\n❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), \nor by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.\n\n**Features:**\n\n* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)\n* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)\n* Server/Web: [Support \"copy\" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)\n* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)\n\n**Bug fixes + maintenance:**\n\n* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)\n* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))\n* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)\n* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))\n* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)\n* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)\n* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)\n* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)\n* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)\n* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))\n* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)\n* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))\n* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)\n\n## ntfy Android app v1.22.2\nReleased January 20, 2026\n\nThis release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0),\nas well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),\nand a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).\n\n<div id=\"v1221-screenshots-1\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-notification-update-1.png\"><img src=\"../../static/img/android-screenshot-notification-update-1.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-notification-update-2.png\"><img src=\"../../static/img/android-screenshot-notification-update-2.png\"/></a>\n</div>\n\n<div id=\"v1221-screenshots-2\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-certs-warning-dialog.jpg\"><img src=\"../../static/img/android-screenshot-certs-warning-dialog.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-certs-manage.jpg\"><img src=\"../../static/img/android-screenshot-certs-manage.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-connection-error-dialog.jpg\"><img src=\"../../static/img/android-screenshot-connection-error-dialog.jpg\"/></a>\n</div>\n\n**Features:**\n\n* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)\n  ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),\n  [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)\n  for the initial implementation)\n* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),\n  [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),\n  thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)\n* Connection error dialog to help diagnose connection issues\n\n**Bug fixes + maintenance:**\n\n* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),\n  thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)\n* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))\n* Fix crash when exiting multi-delete in detail view\n* Fix potential crashes with icon downloader and backuper\n\n## ntfy server v2.16.0\nReleased January 19, 2026\n\nThis release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,\ncustom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a \"New version available\" banner to the web app.\n\nThis one is very exciting, as it brings a lot of highly requested features to ntfy.\n\n**Features:**\n\n* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),\n  [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)\n* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka\n  [updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),\n  [#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),\n  thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)\n* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)\n* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),\n  [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),\n  thanks to [@wtf911](https://github.com/wtf911))\n* Web app: \"New version available\" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))\n\n## ntfy Android app v1.21.1\nReleased January 6, 2026\n\nThis is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies\nand the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),\nsupport for passing headers to your proxy, an in-app language switcher, and more.\n\n<div id=\"v1211-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-publish-message-bar.jpg\"><img src=\"../../static/img/android-screenshot-publish-message-bar.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-publish-dialog.jpg\"><img src=\"../../static/img/android-screenshot-publish-dialog.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-custom-headers.jpg\"><img src=\"../../static/img/android-screenshot-custom-headers.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-language-selection.jpg\"><img src=\"../../static/img/android-screenshot-language-selection.jpg\"/></a>\n</div>\n\nIf you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).\nIf you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.\n\n**Features:**\n\n* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))\n* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))\n* Implement UnifiedPush \"raise to foreground\" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))\n* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)\n* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)\n* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))\n\n**Bug fixes + maintenance:**\n\n* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))\n* Unify \"copy to clipboard\" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))\n* Fix crash in user add dialog (onAddUser)\n* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))\n* Hide \"Exact alarms\" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))\n\n## ntfy Android app v1.20.0\nReleased December 28, 2025\n\nThis is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version,\nand fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries\nthemselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum\nSDK version is 26 (Android 8, 2017).\n\n**Bug fixes + maintenance:**\n\n* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140),\n  thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation)\n* Updated target SDK version to 36 (Android 8, 2017)\n* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520))\n* Fixed crashes with redrawing the list when temporarily muted topics expire\n\n## ntfy Android app v1.19.4\nReleased December 21, 2025\n\nThis release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the\nability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors).\n**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock!\n\n**Features:**\n\n* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580),\n  [ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126),\n  [ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro)\n  and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting)\n\n## ntfy Android app v1.18.0\nReleased December 4, 2025\n\n**Features:**\n\n* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting)\n* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla))\n* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g))\n\n**Bug fixes + maintenance:**\n\n* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684))\n* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g))\n\n## ntfy server v2.15.0\nReleased Nov 16, 2025\n\nThis release adds a `require-login` flag to topics, which forces users to log in before they can\nuse the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.\n\n**Features:**\n\n* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)\n\n**Bug fixes + maintenance:**\n\n* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)\n* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))\n* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for\n  packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)\n* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)\n\n## ntfy Android app v1.17.13\nReleased October 21, 2025\n\nThis release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)\nor [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.\n\nThe policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.\n\n**Changes:**\n\n* Remove the \"Donate\" button from menu (all variants)\n* Change default display name from \"ntfy.sh/mytopic\" to \"mytopic\" (all variants)\n* Remove links to ntfy docs and issue tracker (Play variant only)\n* Remove how-to links to ntfy.sh in a few places (Play variant only)\n* Remove \"Copy topic address\" from subscription menu (Play variant only)\n\n## ntfy Android app v1.17.8\nReleased September 23, 2025\n\nThis is largely a maintenance update to ensure the SDK is up-to-date.\n\n**Features:**\n\n* Markdown is now rendered if \"Markdown: yes\" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)\n* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)\n\n**Bug fixes + maintenance:**\n\n* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))\n* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)\n* Bumped all dependencies to the latest versions (no ticket)\n\n## ntfy server v2.14.0\nReleased August 5, 2025\n\nThis release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.\n\nIt also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.\n\n❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy\nwill always remain open source.\n\n**Features:**\n\n* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)\n* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))\n* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)\n\n## ntfy server v2.13.0\nReleased July 10, 2025\n\nThis is a relatively small release, mainly to support IPv6 and to add more sophisticated\nproxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us**\nvia [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app).\nntfy will always remain open source.\n\n**Features:**\n\n* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))\n* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))\n* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))\n\n**Languages**\n\n* Update new languages from Weblate. Thanks to all the contributors!\n* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app\n\n## ntfy server v2.12.0\nReleased May 29, 2025\n\nThis is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few\nnew features and bug fixes as well. \n\nThanks to everyone who contributed to this release, and special thanks to [@wunter8](https://github.com/wunter8) for his continued\nuser support in Discord/Matrix/GitHub! You rock, man!  \n\n**Features:**\n\n* Add username/password auth to email publishing ([#1164](https://github.com/binwiederhier/ntfy/pull/1164), thanks to [@bishtawi](https://github.com/bishtawi))\n* Write VAPID keys to file in `ntfy webpush --output-file` ([#1138](https://github.com/binwiederhier/ntfy/pull/1138), thanks to [@nogweii](https://github.com/nogweii))\n* Add Docker major/minor version to image tags ([#1271](https://github.com/binwiederhier/ntfy/pull/1271), thanks to [@RoboMagus](https://github.com/RoboMagus))\n* Add `latest` subscription param for grabbing just the most recent message ([#1216](https://github.com/binwiederhier/ntfy/pull/1216), thanks to [@wunter8](https://github.com/wunter8))\n* Allow using `NTFY_PASSWORD_HASH` in `ntfy user` command instead of raw password ([#1340](https://github.com/binwiederhier/ntfy/pull/1340), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht) for implementing)\n* You can now change passwords via `v1/users` API ([#1267](https://github.com/binwiederhier/ntfy/pull/1267), thanks to [@wunter8](https://github.com/wunter8) for implementing)\n* Make WebPush subscription warning/expiry configurable, increase default to 55/60 days ([#1212](https://github.com/binwiederhier/ntfy/pull/1212), thanks to [@KuroSetsuna29](https://github.com/KuroSetsuna29))\n* Support [systemd user service](https://docs.ntfy.sh/subscribe/cli/#using-the-systemd-service) `ntfy-client.service` ([#1002](https://github.com/binwiederhier/ntfy/pull/1002), thanks to [@dandersch](https://github.com/dandersch))\n\n**Bug fixes + maintenance:**\n\n* Security updates for dependencies and Docker images ([#1341](https://github.com/binwiederhier/ntfy/pull/1341))\n* Upgrade to Vite 6 ([#1342](https://github.com/binwiederhier/ntfy/pull/1342), thanks Dependabot)\n* Fix iOS delivery issues for read-protected topics ([#1207](https://github.com/binwiederhier/ntfy/pull/1287), thanks a lot to [@barart](https://github.com/barart)!)\n* Add `Date` header to outgoing emails to avoid rejection ([#1141](https://github.com/binwiederhier/ntfy/pull/1141), thanks to [@pcouy](https://github.com/pcouy))\n* Fix IP address parsing when behind a proxy ([#1266](https://github.com/binwiederhier/ntfy/pull/1266), thanks to [@mmatuska](https://github.com/mmatuska))\n* Make sure UnifiedPush messages are not treated as attachments ([#1312](https://github.com/binwiederhier/ntfy/pull/1312), thanks to [@vkrause](https://github.com/vkrause))\n* Add OCI image version to Docker image ([#1307](https://github.com/binwiederhier/ntfy/pull/1307), thanks to [@jlssmt](https://github.com/jlssmt))\n* WebSocket returning incorrect HTTP error code ([#1338](https://github.com/binwiederhier/ntfy/pull/1338) / [#1337](https://github.com/binwiederhier/ntfy/pull/1337), thanks to [@wunter8](https://github.com/wunter8) for debugging and implementing)\n* Make Markdown in the web app scrollable horizontally ([#1262](https://github.com/binwiederhier/ntfy/pull/1262), thanks to [@rake5k](https://github.com/rake5k) for fixing)\n* Make sure WebPush subscription topics are actually deleted (no ticket)\n* Increase the number of access tokens per user to 60 ([#1308](https://github.com/binwiederhier/ntfy/issues/1308))\n* Allow specifying `cache` and `firebase` via JSON publishing ([#1119](https://github.com/binwiederhier/ntfy/issues/1119)/[#1123](https://github.com/binwiederhier/ntfy/pull/1123), thanks to [@stendler](https://github.com/stendler))\n\n**Documentation:**\n\n* Lots of new integrations and projects. Amazing!\n    * [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)\n    * [UptimeObserver](https://uptimeobserver.com)\n    * [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)\n    * [Monibot](https://monibot.io/)\n    * [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)\n    * [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)\n    * [ntfy-run](https://github.com/quantum5/ntfy-run)\n    * [Clipboard IO](https://github.com/jim3692/clipboard-io)\n    * [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)\n    * [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)\n* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))\n* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))\n* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))\n* Correction about MacroDroid ([#1137](https://github.com/binwiederhier/ntfy/pull/1137), thanks to [@ShlomoCode](https://github.com/ShlomoCode))\n* Note about fail2ban in Docker ([#1175](https://github.com/binwiederhier/ntfy/pull/1175)), thanks to [@Measurity](https://github.com/Measurity))\n* Lots of other tiny docs updates, thanks to everyone who contributed!\n\n**Languages**\n\n* Update new languages from Weblate. Thanks to all the contributors!\n* Added Tamil (தமிழ்) as a new language to the web app\n\n## ntfy server v2.11.0\nReleased May 13, 2024\n\nThis is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug\nin the rate visitor logic that caused rate visitors to be assigned to seemingly random topics. Nothing major this time.\n\n❤️ Quick reminder that if you like ntfy, **please consider sponsoring us** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.\n\n**Bug fixes + maintenance:**\n\n* Re-add database index `idx_topic` to the `messages` table to fix performance issues on ntfy.sh (no ticket, big thanks to [@tcaputi](https://github.com/tcaputi) for finding this issue)\n* Do not set rate visitor for non-eligible topics (no ticket)\n* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))\n\n## ntfy server v2.10.0\nReleased Mar 27, 2024\n\nThis release adds support for **message templating** in the ntfy server, which allows you to include a message and/or\ntitle template that will be filled with values from a JSON body (e.g. `curl -gd '{\"alert\":\"Disk space low\"}' \"ntfy.sh/mytopic?tpl=1&m={{.alert}}\"`).\nThis is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana).\n\n**Features:**\n\n* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)\n\n## ntfy server v2.9.0\nReleased Mar 7, 2024\n\nA small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer\nmessage delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other\nthan that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.\n\n!!! info\n    ⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects\n    installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.\n    Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.\n\n**Features:**\n\n* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)\n* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)\n* Web app: You can now paste images into the message bar or publish dialog ([#963](https://github.com/binwiederhier/ntfy/pull/963)/[#572](https://github.com/binwiederhier/ntfy/issues/572), thanks to [@cmj2002](https://github.com/cmj2002) for implementing, and [@rounakdatta](https://github.com/rounakdatta) for reporting)\n\n**Bug fixes + maintenance:**\n\n* ⚠️ Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))\n\n**Documentation:**\n\n* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))\n* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))\n* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))\n* PowerShell file upload example ([#1004](https://github.com/binwiederhier/ntfy/pull/1004), thanks to [@YMan84](https://github.com/YMan84))\n\n## ntfy iOS app v1.3\nReleased Nov 26, 2023\n\nThis release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well\nas notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs\nfor a long time, and I hope that they are finally fixed. \n\nMany thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.\n\n**Bug fixes:**\n\n* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))\n\n## ntfy server v2.8.0\nReleased November 19, 2023\n\nThis release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes\nfor languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the\n`Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally),\nweb app crash fixes \n\n**Bug fixes + maintenance:**\n\n* Support for HTML-only emails ([#690](https://github.com/binwiederhier/ntfy/issues/690)/[#693](https://github.com/binwiederhier/ntfy/pull/693), thanks to [@teastrainer](https://github.com/teastrainer) and [@CrazyWolf13](https://github.com/CrazyWolf13) for reporting)\n* Fix ACL issue with topic patterns containing underscores ([#840](https://github.com/binwiederhier/ntfy/issues/840), thanks to [@Joe-0237](https://github.com/Joe-0237) for reporting)\n* Fix ACL issue with order of read/write rules ([#914](https://github.com/binwiederhier/ntfy/issues/914)/[#917](https://github.com/binwiederhier/ntfy/pull/917), thanks to [@sandman7920](https://github.com/sandman7920))\n* Re-add `tzdata` to Docker images for amd64 image ([#894](https://github.com/binwiederhier/ntfy/issues/894), [#307](https://github.com/binwiederhier/ntfy/pull/307))\n* Add special logic to ignore `Priority` header if it resembles an RFC 9218 value ([#851](https://github.com/binwiederhier/ntfy/pull/851)/[#895](https://github.com/binwiederhier/ntfy/pull/895), thanks to [@gusdleon](https://github.com/gusdleon), see also [#351](https://github.com/binwiederhier/ntfy/issues/351), [#353](https://github.com/binwiederhier/ntfy/issues/353), [#461](https://github.com/binwiederhier/ntfy/issues/461))\n* PWA: hide install prompt on macOS 14 Safari ([#899](https://github.com/binwiederhier/ntfy/pull/899), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves))\n* Fix web app crash in Edge for languages with underline in locale ([#922](https://github.com/binwiederhier/ntfy/pull/922)/[#912](https://github.com/binwiederhier/ntfy/issues/912)/[#852](https://github.com/binwiederhier/ntfy/issues/852), thanks to [@imkero](https://github.com/imkero))\n\n**Additional languages:**\n\n* Finnish (thanks to [@Seppo](https://hosted.weblate.org/user/Seppo/))\n\n## ntfy server v2.7.0\nReleased August 17, 2023\n\nThis release ships Markdown support for the web app (not in the Android app yet), and adds support for \nright-to-left languages (RTL) in the web app. It also fixes a few issues around date/time formatting, \ninternationalization support, a CLI auth bug.\n\nFurthermore, it fixes a security issue around access tokens getting erroneously deleted for other users\nin a specific scenario. This was a denial-of-service-type security issue, since it **effectively allowed a\nsingle user to deny access to all other users of a ntfy instance**. Please note that while tokens were\nerroneously deleted, **nobody but the token owner ever had access to it.** Please refer to [the ticket](https://github.com/binwiederhier/ntfy/issues/838)\nfor details. **Please upgrade your ntfy instance if you run a multi-user system.**\n\n**Features:**\n\n* Add support for [Markdown formatting](publish.md#markdown-formatting) in web app ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves))\n* Add support for right-to-left languages (RTL) in the web app ([#663](https://github.com/binwiederhier/ntfy/issues/663), thanks to [@nimbleghost](https://github.com/nimbleghost))\n\n**Security:** ⚠️\n\n* Fixes issue with access tokens getting deleted ([#838](https://github.com/binwiederhier/ntfy/issues/838))\n\n**Bug fixes + maintenance:**\n\n* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost))\n* Re-init i18n on each service worker message to avoid missing translations ([#817](https://github.com/binwiederhier/ntfy/pull/817), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves))\n* You can now unset the default user:pass/token in `client.yml` for an individual subscription to remove the Authorization header ([#829](https://github.com/binwiederhier/ntfy/issues/829), thanks to [@tomeon](https://github.com/tomeon) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)\n\n**Documentation:**\n\n* Update docs for Apache config ([#819](https://github.com/binwiederhier/ntfy/pull/819), thanks to [@nisbet-hubbard](https://github.com/nisbet-hubbard))\n\n## ntfy server v2.6.2\nReleased June 30, 2023\n\nWith this release, the ntfy web app now contains a **[progressive web app](subscribe/pwa.md) (PWA)\nwith Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar \nto a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window, \npush notifications, and an app badge with the unread notification count. Note that for self-hosted servers, \n[Web Push](config.md#web-push) must be configured.\n\nOn top of that, this release also brings **dark mode** 🧛🌙 to the web app.\n\n🙏 A huge thanks for this release goes to [@nimbleghost](https://github.com/nimbleghost), for basically implementing the \nWeb Push / PWA and dark mode feature by himself. I'm really grateful for your contributions.\n\n❤️ If you like ntfy, **please consider sponsoring us** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app) (20% off\nif you use promo code `MYTOPIC`). ntfy will always remain open source.\n\n**Features:**\n\n* The web app now supports Web Push, and is installable as a [progressive web app (PWA)](https://docs.ntfy.sh/subscribe/pwa/) on Chrome, Edge, Android, and iOS ([#751](https://github.com/binwiederhier/ntfy/pull/751), thanks to [@nimbleghost](https://github.com/nimbleghost))\n* Support for dark mode in the web app ([#206](https://github.com/binwiederhier/ntfy/issues/206), thanks to [@nimbleghost](https://github.com/nimbleghost))\n\n**Bug fixes:**\n\n* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)\n* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)\n* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)\n* Newly created access tokens are now lowercase only to fully support `<topic>+<token>@<domain>` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting)\n* The .1 release fixes a few visual issues with dark mode, and other web app updates ([#791](https://github.com/binwiederhier/ntfy/pull/791), [#793](https://github.com/binwiederhier/ntfy/pull/793), [#792](https://github.com/binwiederhier/ntfy/pull/792), thanks to [@nimbleghost](https://github.com/nimbleghost)) \n* The .2 release fixes issues with the service worker in Firefox and adds automatic service worker updates ([#795](https://github.com/binwiederhier/ntfy/pull/795), thanks to [@nimbleghost](https://github.com/nimbleghost))\n\n**Maintenance:**\n\n* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))\n* Web: Add JS formatter \"prettier\" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))\n* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))\n* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))\n\n**Changes in tarball/zip naming:**   \nDue to a [change in GoReleaser](https://goreleaser.com/deprecations/#archivesreplacements), some of the binary release \narchives now have slightly different names. My apologies if this causes issues in the downstream projects that use ntfy:\n\n- `ntfy_v${VERSION}_windows_x86_64.zip` -> `ntfy_v${VERSION}_windows_amd64.zip`\n- `ntfy_v${VERSION}_linux_x86_64.tar.gz` -> `ntfy_v${VERSION}_linux_amd64.tar.gz`\n- `ntfy_v${VERSION}_macOS_all.tar.gz` -> `ntfy_v${VERSION}_darwin_all.tar.gz`\n\n## ntfy server v2.5.0\nReleased May 18, 2023\n\nThis release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls), \nan admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to \nupstream servers via the `upstream-access-token` config option.\n\n❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off\nif you use promo code `MYTOPIC`). ntfy will always remain open source.\n\n**Features:**\n\n* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)\n* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)\n* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket)\n\n**Bug fixes + maintenance:**\n\n* Removed old ntfy website from ntfy entirely (no ticket)\n* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike))\n* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing)\n* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion))\n* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)\n* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting)\n\n## ntfy server v2.4.0\nReleased Apr 26, 2023\n\nThis release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds support to encode the `X-Title`,\n`X-Message` and `X-Tags` header as RFC 2047. It's a pretty small release, and mainly enables the release of the new ntfy.sh website.\n\n❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy\nwill always remain open source.\n\n**Features:**\n\n* [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) can now be installed via Homebrew (thanks to [@Moulick](https://github.com/Moulick))\n* Added `v1/stats` endpoint to expose messages stats (no ticket)\n* Support [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2) encoded headers (no ticket, honorable mention to [mqttwarn](https://github.com/jpmens/mqttwarn/pull/638) and [@amotl](https://github.com/amotl))\n\n**Bug fixes + maintenance:**\n\n* Hide country flags on Windows ([#606](https://github.com/binwiederhier/ntfy/issues/606), thanks to [@cmeis](https://github.com/cmeis) for reporting, and to [@pokej6](https://github.com/pokej6) for fixing it)\n* `ntfy sub` now uses default auth credentials as defined in `client.yml` ([#698](https://github.com/binwiederhier/ntfy/issues/698), thanks to [@CrimsonFez](https://github.com/CrimsonFez) for reporting, and to [@wunter8](https://github.com/wunter8) for fixing it)\n\n**Documentation:**\n\n* Updated PowerShell examples ([#697](https://github.com/binwiederhier/ntfy/pull/697), thanks to [@Natfan](https://github.com/Natfan))\n\n**Additional languages:**\n\n* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/))\n\n## ntfy server v2.3.1 \nReleased March 30, 2023\n\nThis release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem\non ntfy.sh that we observe every 20 minutes. The polling was never strictly necessary, and has actually caused duplicate\ndelivery issues as well, so disabling it should not have any negative effects. iOS users, please reach out via Discord\nor Matrix if there are issues.\n\n**Bug fixes + maintenance:**\n\n* Disable iOS polling entirely ([#677](https://github.com/binwiederhier/ntfy/issues/677)/[#509](https://github.com/binwiederhier/ntfy/issues/509))\n\n## ntfy server v2.3.0\nReleased March 29, 2023\n\nThis release primarily fixes an issue with delayed messages, and it adds support for Go's profiler (if enabled), which\nwill allow investigating usage spikes in more detail. There will likely be a follow-up release this week to fix the\nactual spikes [caused by iOS devices](https://github.com/binwiederhier/ntfy/issues/677).\n\n**Features:**\n\n* ntfy now supports Go's `pprof` profiler, if enabled (relates to [#677](https://github.com/binwiederhier/ntfy/issues/677))\n\n**Bug fixes + maintenance:**\n\n* Fix delayed message sending from authenticated users ([#679](https://github.com/binwiederhier/ntfy/issues/679))\n* Fixed plural for Polish and other translations ([#678](https://github.com/binwiederhier/ntfy/pull/678), thanks to [@bmoczulski](https://github.com/bmoczulski))\n\n## ntfy server v2.2.0\nReleased March 17, 2023\n\nWith this release, ntfy is now able to expose metrics via a `/metrics` endpoint for [Prometheus](https://prometheus.io/), if enabled.\nThe endpoint exposes about 20 different counters and gauges, from the number of published messages and emails, to active subscribers,\nvisitors and topics. If you'd like more metrics, pop in the Discord/Matrix or file an issue on GitHub. \n\nOn top of this, you can now use access tokens in the ntfy CLI (defined in the `client.yml` file), fixed a bug in `ntfy subscribe`,\nremoved the dependency on Google Fonts, and more.\n\n🔥 Reminder: Purchase one of three **ntfy Pro plans** for **50% off** for a limited time (if you use promo code `MYTOPIC`). \nntfy Pro gives you higher rate limits and lets you reserve topic names. [Buy through web app](https://ntfy.sh/app).\n\n❤️ If you don't need ntfy Pro, please consider sponsoring ntfy via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/). ntfy will stay open source forever.\n\n**Features:**\n\n* Monitoring: ntfy now exposes a `/metrics` endpoint for [Prometheus](https://prometheus.io/) if [configured](config.md#monitoring) ([#210](https://github.com/binwiederhier/ntfy/issues/210), thanks to [@rogeliodh](https://github.com/rogeliodh) for reporting)\n* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8))\n\n**Bug fixes + maintenance:**\n\n* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8))\n* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing)\n* Increase allowed auth failure attempts per IP address to 30 (no ticket)\n* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket)\n\n**Documentation:**\n\n* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix)\n\n## ntfy server v2.1.2\nReleased March 4, 2023\n\nThis is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the\ncorresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses per day 🤦. This \nrelease solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic for 12 hours.\n\nThe release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces\na flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers.\n\n**Features:**\n\n* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting)\n\n**Bug fixes + maintenance:**\n\n* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder))\n* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)\n* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)  \n\n**Additional languages:**\n\n* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/))\n\n## ntfy server v2.1.1\nReleased March 1, 2023\n\nThis is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work, \n**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉. \n\nYou are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...), \nas well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the\nntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use\npromo code `MYTOPIC` for a **50% discount**, limited time only).\n\nAnd as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There\nare no closed-source features. So if you'd like to run your own server, you can!\n\n**Bug fixes + maintenance:**\n\n* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)\n* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha)) \n* Upgrade dialog: Disable submit button for free tier (no ticket)\n* Allow multiple `log-level-overrides` on the same field (no ticket)\n* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)\n* Added `billing-contact` config option (no ticket)\n\n## ntfy server v2.1.0\nReleased February 25, 2023\n\nThis release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based\nrate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits.\nHowever, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do\nno, clients will receive an HTTP 507 response from the server.\n\nWe also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers, \nwhich ntfy rejected with an HTTP 401. We now ignore unsupported header values. \n\nAs of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing\ncycles (not live yet).\n\nAs part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate \nlimits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits\na bit more. For 90% of users, you should not feel the difference.\n\n**Features:**\n\n* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))\n* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore))\n* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts))\n* Payments: Add support for annual billing intervals (no ticket)\n\n**Bug fixes + maintenance:**\n\n* Web: Do not disable \"Reserve topic\" checkbox for admins (no ticket, thanks to @xenrox for reporting)\n* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting)\n\n**Documentation:**\n\n* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore))\n\n**Additional languages:**\n\n* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/))\n\n## ntfy server v2.0.1\nReleased February 17, 2023\n\nThis is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set.\n\n**Bug fixes + maintenance:**\n\n* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))  \n* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)\n\n## ntfy server v2.0.0\nReleased February 16, 2023\n\nThis is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features. \n\n**Brand-new features:**\n\n* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to \n  the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as \n  opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS\n  app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and \n  `enable-login`.\n  <div id=\"account-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-signup.png\"><img src=\"../../static/img/web-signup.png\"/></a>\n    <a href=\"../../static/img/web-account.png\"><img src=\"../../static/img/web-account.png\"/></a>\n  </div>\n* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.\n  Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe\n  to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as\n  `deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you \n  can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).\n  Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and \n  only if the user has a [tier](config.md#tiers) that supports reservations.\n  <div id=\"reserve-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-reserve-topic.png\"><img src=\"../../static/img/web-reserve-topic.png\"/></a> \n    <a href=\"../../static/img/web-reserve-topic-dialog.png\"><img src=\"../../static/img/web-reserve-topic-dialog.png\"/></a>\n  </div>\n* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful\n  to avoid having to paste your password to various applications or scripts. For instance, you may want to use a \n  dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured\n  in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),\n  and [publishing using tokens](publish.md#access-tokens).\n  <div id=\"token-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-token-create.png\"><img src=\"../../static/img/web-token-create.png\"/></a> \n    <a href=\"../../static/img/web-token-list.png\"><img src=\"../../static/img/web-token-list.png\"/></a>\n  </div>\n* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and\n  troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields\n  that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields\n  match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.\n  See [logging & debugging](config.md#logging-debugging).\n* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as\n  daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have\n  a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another\n  tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).\n  Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).\n* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be\n  able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.\n  Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)\n  for details.\n\n**ntfy is forever open source!**   \nYes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything \nwill remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)\nand [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep\ngoing. It'll only make ntfy better.\n\n**Other tickets:**\n\n* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))\n* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)\n\n**Special thanks:**\n\nA big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,\nsuggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot.\n\n## ntfy server v1.31.0\nReleased February 14, 2023\n\nThis is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting \nthings in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal\nof `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a \nbreaking-change upgrade, which required some work to get working again.\n\n**Features:**\n\n* Preliminary `/v1/health` API endpoint for service monitoring (no ticket)\n* Add basic health check to `Dockerfile` ([#555](https://github.com/binwiederhier/ntfy/pull/555), thanks to [@bt90](https://github.com/bt90))\n\n**Bug fixes + maintenance:**\n\n* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))\n* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)\n* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))\n\n**Documentation:**\n\n* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))\n* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))\n* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))\n* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))\n* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))\n\n**Additional languages:**\n\n* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))\n\n## ntfy server v1.30.1\nReleased December 23, 2022 🎅\n\nThis is a special holiday edition version of ntfy, with all sorts of holiday fun and games, and hidden quests.\nNahh, just kidding. This release is an intermediate release mainly to eliminate warnings in the logs, so I can\nroll out the TLSv1.3, HTTP/2 and Unix mode changes on ntfy.sh (see [#552](https://github.com/binwiederhier/ntfy/issues/552)).\n\n**Features:**\n\n* Web: Generate random topic name button ([#453](https://github.com/binwiederhier/ntfy/issues/453), thanks to [@yardenshoham](https://github.com/yardenshoham))\n* Add [Gitpod config](https://github.com/binwiederhier/ntfy/blob/main/.gitpod.yml) ([#540](https://github.com/binwiederhier/ntfy/pull/540), thanks to [@yardenshoham](https://github.com/yardenshoham)) \n\n**Bug fixes + maintenance:**\n\n* Remove `--env-topic` option from `ntfy publish` as per [deprecation](deprecations.md) (no ticket)\n* Prepared statements for message cache writes ([#542](https://github.com/binwiederhier/ntfy/pull/542), thanks to [@nicois](https://github.com/nicois))\n* Do not warn about invalid IP address when behind proxy in unix socket mode (relates to [#552](https://github.com/binwiederhier/ntfy/issues/552))\n* Upgrade nginx/ntfy config on ntfy.sh to work with TLSv1.3, HTTP/2 ([#552](https://github.com/binwiederhier/ntfy/issues/552), thanks to [@bt90](https://github.com/bt90))\n\n## ntfy Android app v1.16.0\nReleased December 11, 2022\n\nThis is a feature and platform/dependency upgrade release. You can now have per-subscription notification settings\n(including sounds, DND, etc.), and you can make notifications continue ringing until they are dismissed. There's also\nsupport for thematic/adaptive launcher icon for Android 13.\n\nThere are a few more Android 13 specific things, as well as many bug fixes: No more crashes from large images, no more\nopening the wrong subscription, and we also fixed the icon color issue.\n\n**Features:**\n\n* Custom per-subscription notification settings incl. sounds, DND, etc. ([#6](https://github.com/binwiederhier/ntfy/issues/6), thanks to [@doits](https://github.com/doits))\n* Insistent notifications that ring until dismissed ([#417](https://github.com/binwiederhier/ntfy/issues/417), thanks to [@danmed](https://github.com/danmed) for reporting)\n* Add thematic/adaptive launcher icon ([#513](https://github.com/binwiederhier/ntfy/issues/513), thanks to [@daedric7](https://github.com/daedric7) for reporting)\n\n**Bug fixes + maintenance:**\n\n* Upgrade Android dependencies and build toolchain to SDK 33 (no ticket)\n* Simplify F-Droid build: Disable tasks for Google Services ([#516](https://github.com/binwiederhier/ntfy/issues/516), thanks to [@markosopcic](https://github.com/markosopcic))\n* Android 13: Ask for permission to post notifications ([#508](https://github.com/binwiederhier/ntfy/issues/508))\n* Android 13: Do not allow swiping away the foreground notification ([#521](https://github.com/binwiederhier/ntfy/issues/521), thanks to [@alexhorner](https://github.com/alexhorner) for reporting)\n* Android 5 (SDK 21): Fix crash on unsubscribing ([#528](https://github.com/binwiederhier/ntfy/issues/528), thanks to Roger M.)\n* Remove timestamp when copying message text ([#471](https://github.com/binwiederhier/ntfy/issues/471), thanks to [@wunter8](https://github.com/wunter8))\n* Fix auto-delete if some icons do not exist anymore ([#506](https://github.com/binwiederhier/ntfy/issues/506))\n* Fix notification icon color ([#480](https://github.com/binwiederhier/ntfy/issues/480), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)\n* Fix topics do not re-subscribe to Firebase after restoring from backup ([#511](https://github.com/binwiederhier/ntfy/issues/511))\n* Fix crashes from large images ([#474](https://github.com/binwiederhier/ntfy/issues/474), thanks to [@daedric7](https://github.com/daedric7) for reporting)\n* Fix notification click opens wrong subscription ([#261](https://github.com/binwiederhier/ntfy/issues/261), thanks to [@SMAW](https://github.com/SMAW) for reporting)\n* Fix Firebase-only \"link expired\" issue ([#529](https://github.com/binwiederhier/ntfy/issues/529))\n* Remove \"Install .apk\" feature in Google Play variant due to policy change ([#531](https://github.com/binwiederhier/ntfy/issues/531))\n* Add donate button (no ticket)\n\n**Additional translations:**\n\n* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/))\n* Portuguese (thanks to [@victormagalhaess](https://hosted.weblate.org/user/victormagalhaess/))\n\n## ntfy server v1.29.1\nReleased November 17, 2022\n\nThis is mostly a bugfix release to address the high load on ntfy.sh. There are now two new options that allow\nsynchronous batch-writing of messages to the cache. This avoids database locking, and subsequent pileups of waiting\nrequests.\n\n**Bug fixes:**\n\n* High-load servers: Allow asynchronous batch-writing of messages to cache via `cache-batch-*` options ([#498](https://github.com/binwiederhier/ntfy/issues/498)/[#502](https://github.com/binwiederhier/ntfy/pull/502))\n* Sender column in cache.db shows invalid IP ([#503](https://github.com/binwiederhier/ntfy/issues/503))\n\n**Documentation:**\n\n* GitHub Actions example ([#492](https://github.com/binwiederhier/ntfy/pull/492), thanks to [@ksurl](https://github.com/ksurl))\n* UnifiedPush ACL clarification ([#497](https://github.com/binwiederhier/ntfy/issues/497), thanks to [@bt90](https://github.com/bt90)) \n* Install instructions for Kustomize ([#463](https://github.com/binwiederhier/ntfy/pull/463), thanks to [@l-maciej](https://github.com/l-maciej))\n\n**Other things:**\n\n* Put ntfy.sh docs on GitHub pages to reduce AWS outbound traffic cost ([#491](https://github.com/binwiederhier/ntfy/issues/491))\n* The ntfy.sh server hardware was upgraded to a bigger box. If you'd like to help out carrying the server cost, **[sponsorships and donations](https://github.com/sponsors/binwiederhier)** 💸 would be very much appreciated\n\n## ntfy server v1.29.0\nReleased November 12, 2022\n\nThis release adds the ability to add rate limit exemptions for IP ranges instead of just specific IP addresses. It also fixes \na few bugs in the web app and the CLI and adds lots of new examples and install instructions.\n\nThanks to [some love on HN](https://news.ycombinator.com/item?id=33517944), we got so many new ntfy users trying out ntfy\nand joining the [chat rooms](https://github.com/binwiederhier/ntfy#chat--forum). **Welcome to the ntfy community to all of you!** \nWe also got a ton of new **[sponsors and donations](https://github.com/sponsors/binwiederhier)** 💸, which is amazing. I'd like to thank\nall of you for believing in the project, and for helping me pay the server cost. The HN spike increased the AWS cost quite a bit.\n\n**Features:**\n\n* Allow IP CIDRs in `visitor-request-limit-exempt-hosts` ([#423](https://github.com/binwiederhier/ntfy/issues/423), thanks to [@karmanyaahm](https://github.com/karmanyaahm))\n\n**Bug fixes + maintenance:**\n\n* Subscriptions can now have a display name ([#370](https://github.com/binwiederhier/ntfy/issues/370), thanks to [@tfheen](https://github.com/tfheen) for reporting)\n* Bump Go version to Go 18.x ([#422](https://github.com/binwiederhier/ntfy/issues/422))\n* Web: Strip trailing slash when subscribing ([#428](https://github.com/binwiederhier/ntfy/issues/428), thanks to [@raining1123](https://github.com/raining1123) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)\n* Web: Strip trailing slash after server URL in publish dialog ([#441](https://github.com/binwiederhier/ntfy/issues/441), thanks to [@wunter8](https://github.com/wunter8))\n* Allow empty passwords in `client.yml` ([#374](https://github.com/binwiederhier/ntfy/issues/374), thanks to [@cyqsimon](https://github.com/cyqsimon) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)\n* `ntfy pub` will now use default username and password from `client.yml` ([#431](https://github.com/binwiederhier/ntfy/issues/431), thanks to [@wunter8](https://github.com/wunter8) for fixing)\n* Make `ntfy sub` work with `NTFY_USER` env variable ([#447](https://github.com/binwiederhier/ntfy/pull/447), thanks to [SuperSandro2000](https://github.com/SuperSandro2000))\n* Web: Disallow GET/HEAD requests with body in actions ([#468](https://github.com/binwiederhier/ntfy/issues/468), thanks to [@ollien](https://github.com/ollien))\n\n**Documentation:**\n\n* Updated developer docs, bump nodejs and go version ([#414](https://github.com/binwiederhier/ntfy/issues/414), thanks to [@YJSoft](https://github.com/YJSoft) for reporting)\n* Officially document `?auth=..` query parameter ([#433](https://github.com/binwiederhier/ntfy/pull/433), thanks to [@wunter8](https://github.com/wunter8))\n* Added Rundeck example ([#427](https://github.com/binwiederhier/ntfy/pull/427), thanks to [@demogorgonz](https://github.com/demogorgonz))\n* Fix Debian installation instructions ([#237](https://github.com/binwiederhier/ntfy/issues/237), thanks to [@Joeharrison94](https://github.com/Joeharrison94) for reporting)\n* Updated [example](https://ntfy.sh/docs/examples/#gatus) with official [Gatus](https://github.com/TwiN/gatus) integration (thanks to [@TwiN](https://github.com/TwiN))\n* Added [Kubernetes install instructions](https://ntfy.sh/docs/install/#kubernetes) ([#452](https://github.com/binwiederhier/ntfy/pull/452), thanks to [@gmemstr](https://github.com/gmemstr))\n* Added [additional NixOS links for self-hosting](https://ntfy.sh/docs/install/#nixos-nix) ([#462](https://github.com/binwiederhier/ntfy/pull/462), thanks to [@wamserma](https://github.com/wamserma))\n* Added additional [more secure nginx config example](https://ntfy.sh/docs/config/#nginxapache2caddy) ([#451](https://github.com/binwiederhier/ntfy/pull/451), thanks to [SuperSandro2000](https://github.com/SuperSandro2000))\n* Minor fixes in the config table ([#470](https://github.com/binwiederhier/ntfy/pull/470), thanks to [snh](https://github.com/snh))\n* Fix broken link ([#476](https://github.com/binwiederhier/ntfy/pull/476), thanks to [@shuuji3](https://github.com/shuuji3))\n\n**Additional translations:**\n\n* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/))\n\n**Sponsorships:**:\n\nThank you to the amazing folks who decided to [sponsor ntfy](https://github.com/sponsors/binwiederhier). Thank you for \nhelping carry the cost of the public server and developer licenses, and more importantly: Thank you for believing in ntfy! \nYou guys rock! \n\nA list of all the sponsors can be found in the [README](https://github.com/binwiederhier/ntfy/blob/main/README.md).\n\n## ntfy Android app v1.14.0 \nReleased September 27, 2022\n\nThis release adds the ability to set a custom icon to each notification, as well as a display name to subscriptions. We\nalso moved the action buttons in the detail view to a more logical place, fixed a bunch of bugs, and added four more\nlanguages. Hurray!\n\n**Features:**\n\n* Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8))\n* Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8))\n* Polling is now done with `since=<id>` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165))\n* Turned JSON stream deprecation banner into \"Use WebSockets\" banner (no ticket)\n* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8))\n* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))\n\n**Bug fixes:**\n\n* Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8))\n* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8))\n* Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting)\n\n**Additional translations:**\n\n* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))\n* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))\n* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))\n* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))\n\nThank you to [@wunter8](https://github.com/wunter8) for proactively picking up some Android tickets, and fixing them! You rock!\n\n## ntfy server v1.28.0\nReleased September 27, 2022\n\nThis release primarily adds icon support for the Android app, and adds a display name to subscriptions in the web app.\nAside from that, we fixed a few random bugs, most importantly the `Priority` header bug that allows the use behind\nCloudflare. We also added a ton of documentation. Most prominently, an [integrations + projects page](https://ntfy.sh/docs/integrations/).\n\nAs of now, I also have started accepting **[donations and sponsorships](https://github.com/sponsors/binwiederhier)** 💸. \nI would be very humbled if you consider donating.\n\n**Features:**\n\n* Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348))\n* Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666))\n* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))\n* CLI: Allow default username/password in `client.yml` ([#372](https://github.com/binwiederhier/ntfy/pull/372), thanks to [@wunter8](https://github.com/wunter8))\n* Build support for other Unix systems ([#393](https://github.com/binwiederhier/ntfy/pull/393), thanks to [@la-ninpre](https://github.com/la-ninpre))\n\n**Bug fixes:**\n\n* `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting)\n* Ignore new draft HTTP `Priority` header  ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting)\n* Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket)\n* Better logging for Matrix push key errors ([#384](https://github.com/binwiederhier/ntfy/pull/384), thanks to [@christophehenry](https://github.com/christophehenry))\n* Web: Switched \"Pop\" and \"Pop Swoosh\" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting)\n\n**Documentation:**\n\n* Added [integrations + projects page](https://ntfy.sh/docs/integrations/) (**so many integrations, whoa!**)\n* Added example for [UptimeRobot](https://ntfy.sh/docs/examples/#uptimerobot)\n* Fix some PowerShell publish docs ([#345](https://github.com/binwiederhier/ntfy/pull/345), thanks to [@noahpeltier](https://github.com/noahpeltier))\n* Clarified Docker install instructions ([#361](https://github.com/binwiederhier/ntfy/issues/361), thanks to [@barart](https://github.com/barart) for reporting)\n* Mismatched quotation marks ([#392](https://github.com/binwiederhier/ntfy/pull/392)], thanks to [@connorlanigan](https://github.com/connorlanigan))\n\n**Additional translations:**\n\n* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))\n* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))\n\n## ntfy server v1.27.2\nReleased June 23, 2022\n\nThis release brings two new CLI options to wait for a command to finish, or for a PID to exit. It also adds more detail\nto trace debug output. Aside from other bugs, it fixes a performance issue that occurred in large installations every \nminute or so, due to competing stats gathering (personal installations will likely be unaffected by this). \n\n**Features:**\n\n* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#message-cache) (no ticket)\n* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea)\n* Trace: Log entire HTTP request to simplify debugging (no ticket)\n* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))\n\n**Bug fixes:**\n\n* Fix slow requests due to excessive locking ([#338](https://github.com/binwiederhier/ntfy/issues/338))\n* Return HTTP 500 for `GET /_matrix/push/v1/notify` when `base-url` is not configured (no ticket)\n* Disallow setting `upstream-base-url` to the same value as `base-url` ([#334](https://github.com/binwiederhier/ntfy/issues/334), thanks to [@oester](https://github.com/oester) for reporting)\n* Fix `since=<id>` implementation for multiple topics ([#336](https://github.com/binwiederhier/ntfy/issues/336), thanks to [@karmanyaahm](https://github.com/karmanyaahm) for reporting)\n* Simple parsing in `Actions` header now supports settings Android `intent=` key ([#341](https://github.com/binwiederhier/ntfy/pull/341), thanks to [@wunter8](https://github.com/wunter8))\n\n**Deprecations:**\n\n* The `ntfy publish --env-topic` option is deprecated as of now (see [deprecations](deprecations.md) for details)\n\n## ntfy server v1.26.0\nReleased June 16, 2022\n\nThis release adds a Matrix Push Gateway directly into ntfy, to make self-hosting a Matrix server easier. The Windows\nCLI is now available via Scoop, and ntfy is now natively supported in Uptime Kuma. \n\n**Features:**\n\n* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting)\n* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu))\n* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann))\n* Display ntfy version in `ntfy serve` command  ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs))\n\n**Bug fixes:**\n\n* Web app: Show \"notifications not supported\" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)\n* Use last address in `X-Forwarded-For` header as visitor address ([#328](https://github.com/binwiederhier/ntfy/issues/328))\n\n**Documentation**\n\n* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann))\n* Fix Docker install instructions  ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)\n* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)\n* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)\n\n## ntfy iOS app v1.2\nReleased June 16, 2022\n\nThis release adds support for authentication/authorization for self-hosted servers. It also allows you to\nset your server as the default server for new topics.\n\n**Features:**\n\n* Support for auth and user management ([#277](https://github.com/binwiederhier/ntfy/issues/277))\n* Ability to add default server ([#295](https://github.com/binwiederhier/ntfy/issues/295))\n\n**Bug fixes:**\n\n* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290))\n\n## ntfy server v1.25.2\nReleased June 2, 2022\n\nThis release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a \nproduction problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users). \nWe now block visitors from using Firebase if they trigger a quota exceeded response.\n\nOn top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two\nmore translations: Chinese/Simplified and Dutch.\n\n**Features:**\n\n* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))\n\n**Bugs**:\n\n* Respect Firebase \"quota exceeded\" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))\n* Fix documentation header blue header due to mkdocs-material theme update (no ticket) \n\n**Maintenance:**\n\n* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274))\n* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36))\n\n**Documentation**:\n\n* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket)\n* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))\n* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))\n* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)\n* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))\n\n**Additional translations:**\n\n* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/))\n* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))\n\n## ntfy iOS app v1.1\nReleased May 31, 2022\n\nIn this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis,\naction buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click\naction when the notification is tapped, and various other fixes.\n\nIt also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be\nconfigured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications)\nfor details).\n\n**Features:**\n\n* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket)\n* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket)\n* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket)\n* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket)\n* Open topic when notification clicked (no ticket)\n* Notification now makes a sound and vibrates (no ticket)\n* Cancel notifications when navigating to topic (no ticket)\n* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))\n\n**Bug fixes:**\n\n* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))\n\n## ntfy server v1.24.0\nReleased May 28, 2022\n\nThis release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it\nenables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive\nApple development environment.\n\n**Features:**\n\n* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)\n* Add subscribe filter to query exact messages by ID (no ticket)\n* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)\n\n**Bug fixes:**\n\n* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))\n\n**Additional translations:**\n\n* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))\n\n## ntfy iOS app v1.0\nReleased May 25, 2022\n\nThis is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title\n(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus\non self-hosted servers.\n\nThe app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).\n\n**Tickets:**\n\n* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150))\n\n**Thanks:**\n\n* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with\n  some known issues which will be addressed in follow-up releases).\n\n## ntfy server v1.23.0\nReleased May 21, 2022\n\nThis release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that, \nit adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app.\n\n**Features:**\n\n* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))\n* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))\n* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))\n\n**Bug fixes:**\n\n* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))\n* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))\n\n**Documentation:**\n\n* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama))\n* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC))\n* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel))\n\n**Additional translations:**\n\n* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/))\n\nThank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help.  \n\n## ntfy Android app v1.13.0\nReleased May 11, 2022\n\nThis release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily\ndistinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold\nand custom icons. Aside from that, we've got tons of bug fixes as usual.\n\n**Features:**\n\n* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)\n* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)\n\n**Bug fixes:**\n\n* Accurate naming of \"mute notifications\" from \"pause notifications\" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)\n* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)\n* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting)\n* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)\n* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)\n* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))\n* Prevent long topic names and icons from overlapping ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)\n\n**Additional translations:**\n\n* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/))\n\n**Thank you:**\n\nThanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and\nto [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly.\n\n## ntfy server v1.22.0\nReleased May 7, 2022\n\nThis release makes the web app more accessible to people with disabilities, and introduces a \"mark as read\" icon in the web app.\nIt also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.\n\nWe've also improved the documentation a little and added translations for three more languages.\n\n**Features:**\n\n* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))\n* Better parsing of the user actions, allowing quotes (no ticket)\n* Add \"mark as read\" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))\n\n**Bug fixes:**\n\n* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)\n* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))\n* Add \"private browsing\"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)\n\n**Documentation:**\n\n* Improved caddy configuration (no ticket, thanks to @Stnby)\n* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))\n* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))\n\n**Additional translations:**\n\n* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))\n* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))\n* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))\n\n**Thanks for testing:**\n\nThanks to [@wunter8](https://github.com/wunter8) for testing.\n\n## ntfy Android app v1.12.0\nReleased Apr 25, 2022\n\nThe main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature\nthat allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or\nsend a HTTP request. \n\nWe also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more \nlanguages and fixed a ton of bugs. \n\n**Features:**\n\n* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),\n  thanks to [@mrherman](https://github.com/mrherman) for reporting)\n* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks\n  to [@Copephobia](https://github.com/Copephobia) for reporting)\n* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198),\n  thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)\n* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))\n\n**Bug fixes:**\n\n* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),\n  thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)\n* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),\n  thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)\n* \"[x] Instant delivery in doze mode\" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211))\n* Disallow \"http\" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to\n  [@cmeis](https://github.com/cmeis) for reporting)\n* Action \"view\" with \"clear=true\" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to\n  [@cmeis](https://github.com/cmeis) for reporting)\n* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to\n  [@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)\n\n**Additional translations:**\n\n* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))\n* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))\n* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))\n* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))\n\n**Thanks for testing:**\n\nThanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis),\n@poblabs, and everyone I forgot for testing.\n\n## ntfy server v1.21.2\nReleased Apr 24, 2022\n\nIn this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎. \nIt also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons)\nis a feature that will be released in the Android app soon. It allows users to add actions to the notifications. \nLimited support is available in the web app.\n\n**Features:**\n\n* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),\n  thanks to [@mrherman](https://github.com/mrherman) for reporting)\n* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)\n* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))\n\n**Bug fixes:**\n\n* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))\n* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))\n* Web app: basic URL validation in user management ([#204](https://github.com/binwiederhier/ntfy/issues/204), thanks to [@cmeis](https://github.com/cmeis))\n* Disallow \"http\" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to\n  [@cmeis](https://github.com/cmeis) for reporting)\n\n**Translations (web app):**\n\n* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))\n* German (thanks to [@cmeis](https://github.com/cmeis))\n* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))\n* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))\n* Norwegian Bokmål (thanks to [@comradekingu](https://github.com/comradekingu))\n* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))\n* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))\n* Turkish (thanks to [@ersen](https://ersen.moe/))\n\n**Integrations:**\n\n[Apprise](https://github.com/caronc/apprise) support was fully released in [v0.9.8.2](https://github.com/caronc/apprise/releases/tag/v0.9.8.2)\nof Apprise. Thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work. \nYou can try it yourself like this (detailed usage in the [Apprise wiki](https://github.com/caronc/apprise/wiki/Notify_ntfy)):\n\n```\npip3 install apprise\napprise -b \"Hi there\" ntfys://mytopic\n```\n\n## ntfy Android app v1.11.0\nReleased Apr 7, 2022\n\n**Features:**\n\n* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))\n* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))\n* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to\n  [@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)\n\n**Bug fixes:**\n\n* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)\n* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))\n* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)\n* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))\n\n**Translations:**\n\n* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu))\n* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))\n* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))\n* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))\n* French (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/))\n* German (thanks to [@cmeis](https://github.com/cmeis))\n* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))\n* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))\n* Norwegian Bokmål (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu))\n* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/))\n* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))\n* Turkish (thanks to [@ersen](https://ersen.moe/))\n\n**Thanks:**\n\n* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),\n  and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release\n\n## ntfy server v1.20.0\nReleased Apr 6, 2022\n\n**Features:**:\n\n* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196)) \n\n**Bug fixes:**\n\n* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))\n\n**Documentation:**\n\n* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))\n\n**Integrations:**\n\n* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),\n  thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)\n\n## ntfy server v1.19.0\nReleased Mar 30, 2022\n\n**Bug fixes:**\n\n* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))\n* Do not allow comma in topic name in publish via GET endpoint (no ticket)\n* Add \"Access-Control-Allow-Origin: *\" for attachments (no ticket, thanks to @FrameXX)\n* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))\n* Added missing params `delay` and `email` to publish as JSON body (no ticket)\n\n**Documentation:**\n\n* Improved [e-mail publishing](config.md#e-mail-publishing) documentation\n\n## ntfy server v1.18.1\nReleased Mar 21, 2022   \n_This release ships no features or bug fixes. It's merely a documentation update._\n\n**Documentation:**\n\n* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/)\n* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94))\n* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz))\n* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)\n\n## ntfy Android app v1.10.0\nReleased Mar 21, 2022\n\n**Features:**\n\n* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))\n* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)\n* Open \"Click\" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting)\n* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164))\n\n**Bug fixes:**\n\n* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)\n\n## ntfy server v1.18.0\nReleased Mar 16, 2022\n\n**Features:**\n\n* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133), \n  thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and \n  [@Fallenbagel](https://github.com/Fallenbagel) for testing)\n\n**Bug fixes:**\n\n* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting)\n* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert))\n* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares))\n\n**Deprecations:**\n\n* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)\n\n## ntfy server v1.17.1\nReleased Mar 12, 2022\n\n**Bug fixes:**\n\n* Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus)\n\n## ntfy server v1.17.0\nReleased Mar 11, 2022\n\n**Features & bug fixes:**\n\n* Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111)\n* Web UI broken with auth (#132, thanks for reporting @arminus)\n* Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket)\n* Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket) \n\n## ntfy server v1.16.0\nReleased Feb 27, 2022\n\n**Features & Bug fixes:**\n\n* Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane)\n* Add support for [?since=<id>](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp)\n\n**Documentation:**\n\n* Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh)\n* Add [release notes](https://ntfy.sh/docs/releases/)\n\n**Technical notes:**\n\n* As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to \n  distinguish them from Unix timestamps for #151.\n\n## ntfy Android app v1.9.1\nReleased Feb 16, 2022\n\n**Features:**\n\n* Share to topic feature (#131, thanks u/emptymatrix for reporting)\n* Ability to pick a default server (#127, thanks to @poblabs for reporting and testing)\n* Automatically delete notifications (#71, thanks @arjan-s for reporting)\n* Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting)\n\n**Bug fixes:**\n\n* Do not attempt to download attachments if they are already expired (#135)\n* Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket)\n\n**Other thanks:**\n\n* Thanks to @rogeliodh, @cmeis and @poblabs for testing\n\n## ntfy server v1.15.0\nReleased Feb 14, 2022\n\n**Features & bug fixes:**\n\n* Compress binaries with `upx` (#137)\n* Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144)\n* Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket)\n* Convert `\\n` to new line for `X-Message` header as prep for sharing feature (see #136)\n* Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket)\n* Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket)\n\n## ntfy server v1.14.1\nReleased Feb 9, 2022\n\n**Bug fixes:**\n\n* Fix ARMv8 Docker build (#113, thanks to @djmaze)\n* No other significant changes\n\n## ntfy Android app v1.8.1\nReleased Feb 6, 2022\n\n**Features:**\n\n* Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs, \n  @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)\n* Export/upload log now allows censored/uncensored logs (no ticket)\n* Removed wake lock (except for notification dispatching, no ticket)\n* Swipe to remove notifications (#117)\n\n**Bug fixes:**\n\n* Fix download issues on SDK 29 \"Movement not allowed\" (#116, thanks Jakob)\n* Fix for Android 12 crashes (#124, thanks @eskilop)\n* Fix WebSocket retry logic bug with multiple servers (no ticket)\n* Fix race in refresh logic leading to duplicate connections (no ticket)\n* Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus)\n* Fix base URL text field color in dark mode, and size with large fonts (no ticket)\n* Fix action bar color in dark mode (make black, no ticket)\n\n**Notes:**\n\n* Foundational work for per-subscription settings\n\n## ntfy server v1.14.0\nReleased Feb 3, 2022\n\n**Features**:\n\n* Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)\n* Support `NTFY_TOPIC` env variable in `ntfy publish` (#103)\n\n**Bug fixes**:\n\n* Binary UnifiedPush messages should not be converted to attachments (part 1, #101)\n\n**Docs**:\n\n* Clarification regarding attachments (#118, thanks @xnumad)\n\n## ntfy Android app v1.7.1\nReleased Jan 21, 2022\n\n**New features:**\n\n* Battery improvements: wakelock disabled by default (#76)\n* Dark mode: Allow changing app appearance (#102)\n* Report logs: Copy/export logs to help troubleshooting (#94)\n* WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97)\n* Show battery optimization banner (#105)\n\n**Bug fixes:**\n\n* (Partial) support for binary UnifiedPush messages (#101)\n\n**Notes:**\n\n* The foreground wakelock is now disabled by default\n* The service restarter is now scheduled every 3h instead of every 6h\n\n## ntfy server v1.13.0\nReleased Jan 16, 2022\n\n**Features:**\n\n* [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint\n* Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix`\n\n## ntfy Android app v1.6.0\nReleased Jan 14, 2022\n\n**New features:**\n\n* Attachments: Send files to the phone (#25, #15)\n* Click action: Add a click action URL to notifications (#85)\n* Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul)\n* Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24)\n* Remove mentions of \"instant delivery\" from F-Droid to make it less confusing (no ticket)\n\n**Bug fixes:**\n\n* Subscription \"muted until\" was not always respected (#90)\n* Fix two stack traces reported by Play console vitals (no ticket)\n* Truncate FCM messages >4,000 bytes, prefer instant messages (#84)\n\n## ntfy server v1.12.1\nReleased Jan 14, 2022\n\n**Bug fixes:**\n\n* Fix security issue with attachment peaking (#93)\n\n## ntfy server v1.12.0\nReleased Jan 13, 2022\n\n**Features:**\n\n* [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15)\n* [Click action](https://ntfy.sh/docs/publish/#click-action) (#85)\n* Increase FCM priority for high/max priority messages (#70)\n\n**Bug fixes:**\n\n* Make postinst script work properly for rpm-based systems (#83, thanks @cmeis)\n* Truncate FCM messages longer than 4000 bytes (#84)\n* Fix `listen-https` port (no ticket)\n\n## ntfy Android app v1.5.2\nReleased Jan 3, 2022\n\n**New features:**\n\n* Allow using ntfy as UnifiedPush distributor (#9)\n* Support for longer message up to 4096 bytes (#77)\n* Minimum priority: show notifications only if priority X or higher (#79)\n* Allowing disabling broadcasts in global settings (#80)\n\n**Bug fixes:**\n\n* Allow int/long extras for SEND_MESSAGE intent (#57)\n* Various battery improvement fixes (#76)\n\n## ntfy server v1.11.2\nReleased Jan 1, 2022\n\n**Features & bug fixes:**\n\n* Increase message limit to 4096 bytes (4k) #77\n* Docs for [UnifiedPush](https://unifiedpush.org) #9\n* Increase keepalive interval to 55s #76\n* Increase Firebase keepalive to 3 hours #76\n\n## ntfy server v1.10.0\nReleased Dec 28, 2021\n\n**Features & bug fixes:**\n\n* [Publish messages via e-mail](publish.md#e-mail-publishing) #66\n* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64\n* Fixing the Santa bug #65\n\n## Older releases\nFor older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)\nand the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).\n\n## Not released yet\n\n### ntfy server v2.20.x (UNRELEASED)\n\n**Bug fixes + maintenance:**\n\n* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400\n"
  },
  {
    "path": "docs/static/css/config-generator.css",
    "content": "/* Config Generator */\n\n/* Hidden utility */\n.cg-hidden {\n    display: none !important;\n}\n\n/* Open button */\n.cg-open-btn {\n    display: inline-block;\n    padding: 8px 20px;\n    background: var(--md-primary-fg-color);\n    color: #fff;\n    border: none;\n    border-radius: 4px;\n    font-size: 0.85rem;\n    font-weight: 500;\n    cursor: pointer;\n    font-family: inherit;\n    transition: opacity 0.15s;\n}\n\n.cg-open-btn:hover {\n    opacity: 0.85;\n}\n\n/* Modal overlay */\n.cg-modal {\n    position: fixed;\n    inset: 0;\n    z-index: 1000;\n}\n\n.cg-modal-backdrop {\n    position: absolute;\n    inset: 0;\n    background: rgba(0, 0, 0, 0.5);\n}\n\n.cg-modal-dialog {\n    position: absolute;\n    inset: 24px;\n    display: flex;\n    flex-direction: column;\n    background: #fff;\n    border-radius: 10px;\n    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n    overflow: hidden;\n    font-size: 0.78rem;\n}\n\n.cg-modal-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 12px 20px;\n    border-bottom: 1px solid #ddd;\n    flex-shrink: 0;\n}\n\n.cg-modal-header-left {\n    display: flex;\n    align-items: baseline;\n    gap: 12px;\n    min-width: 0;\n}\n\n.cg-modal-title {\n    font-weight: 600;\n    font-size: 0.95rem;\n    white-space: nowrap;\n}\n\n.cg-badge-beta {\n    display: inline-block;\n    padding: 1px 8px;\n    margin-left: 8px;\n    background: var(--md-primary-fg-color);\n    color: #fff;\n    font-size: 0.6rem;\n    font-weight: 600;\n    border-radius: 10px;\n    letter-spacing: 0.5px;\n    vertical-align: middle;\n}\n\n.cg-modal-desc {\n    font-size: 0.75rem;\n    color: #888;\n}\n\n.cg-modal-header-actions {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.cg-modal-reset {\n    background: none;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    font-size: 0.75rem;\n    color: #777;\n    cursor: pointer;\n    padding: 4px 12px;\n    font-family: inherit;\n    transition: color 0.15s, border-color 0.15s;\n}\n\n.cg-modal-reset:hover {\n    color: #333;\n    border-color: #999;\n}\n\n.cg-modal-close {\n    background: none;\n    border: none;\n    font-size: 1.4rem;\n    color: #999;\n    cursor: pointer;\n    padding: 0 4px;\n    line-height: 1;\n}\n\n.cg-modal-close:hover {\n    color: #333;\n}\n\n/* Modal body: left + right */\n.cg-modal-body {\n    display: flex;\n    flex: 1;\n    min-height: 0;\n    overflow: hidden;\n}\n\n/* Left panel */\n#cg-left {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    border-right: 1px solid #ddd;\n    min-width: 0;\n}\n\n.cg-nav {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0;\n    border-bottom: 1px solid #ddd;\n    flex-shrink: 0;\n    padding: 0 16px;\n}\n\n.cg-nav-tab {\n    padding: 9px 14px;\n    cursor: pointer;\n    font-size: 0.78rem;\n    font-weight: 500;\n    color: #777;\n    border-bottom: 2px solid transparent;\n    margin-bottom: -1px;\n    user-select: none;\n    transition: color 0.15s, border-color 0.15s;\n    white-space: nowrap;\n}\n\n.cg-nav-tab:hover {\n    color: #444;\n}\n\n.cg-nav-tab.active {\n    color: var(--md-primary-fg-color);\n    border-bottom-color: var(--md-primary-fg-color);\n}\n\n.cg-panels {\n    flex: 1;\n    overflow-y: auto;\n    padding: 16px 20px;\n}\n\n.cg-panel {\n    display: none;\n}\n\n.cg-panel.active {\n    display: block;\n}\n\n.cg-panel-desc {\n    font-size: 0.75rem;\n    color: #888;\n    margin-bottom: 12px;\n    line-height: 1.5;\n}\n\n.cg-panel-desc a {\n    color: var(--md-primary-fg-color);\n}\n\n.cg-help {\n    color: var(--md-primary-fg-color);\n    text-decoration: none;\n    margin-left: 4px;\n    vertical-align: middle;\n    flex-shrink: 0;\n    transition: color 0.15s;\n}\n\n.cg-help:hover {\n    color: var(--md-primary-fg-color--dark, #2a6e5f);\n}\n\n/* Right panel */\n#cg-right {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    min-width: 0;\n}\n\n.cg-output-tabs {\n    display: flex;\n    border-bottom: 1px solid #ddd;\n    flex-shrink: 0;\n    padding: 0 16px;\n}\n\n.cg-output-tab {\n    padding: 9px 14px;\n    cursor: pointer;\n    font-size: 0.78rem;\n    font-weight: 500;\n    border-bottom: 2px solid transparent;\n    margin-bottom: -1px;\n    color: #777;\n    transition: color 0.15s, border-color 0.15s;\n    user-select: none;\n    white-space: nowrap;\n}\n\n.cg-output-tab:hover {\n    color: #444;\n}\n\n.cg-output-tab.active {\n    color: var(--md-primary-fg-color);\n    border-bottom-color: var(--md-primary-fg-color);\n}\n\n.cg-btn-copy {\n    margin-left: auto;\n    background: none;\n    color: #777;\n    border: none;\n    border-bottom: 2px solid transparent;\n    margin-bottom: -1px;\n    padding: 9px 10px;\n    cursor: pointer;\n    line-height: 1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    transition: color 0.15s;\n}\n\n.cg-btn-copy:hover {\n    color: #333;\n}\n\n.cg-output-wrap {\n    flex: 1;\n    overflow: auto;\n    padding: 16px 20px;\n    display: flex;\n    flex-direction: column;\n}\n\n.cg-output-wrap pre {\n    margin: 0;\n    padding: 8px 10px;\n    background: #f5f5f5;\n    color: #333;\n    border: 1px solid #ddd;\n    border-radius: 6px;\n    overflow-x: auto;\n    font-size: 0.76rem;\n    line-height: 1.5;\n    flex: 1;\n    white-space: pre;\n}\n\n.cg-empty-msg {\n    color: #888;\n    font-style: italic;\n}\n\n.cg-warning {\n    padding: 6px 10px;\n    margin-top: 8px;\n    background: #fff3cd;\n    color: #856404;\n    border: 1px solid #ffc107;\n    border-radius: 4px;\n    font-size: 0.76rem;\n}\n\n/* Form fields */\n.cg-field {\n    margin-bottom: 0;\n    padding: 8px 12px;\n}\n\n.cg-field:nth-child(odd) {\n    background: #f8f8f8;\n}\n\n.cg-field:nth-child(even) {\n    background: #fff;\n}\n\n.cg-field > label {\n    display: block;\n    font-weight: 500;\n    margin-bottom: 4px;\n    font-size: 0.78rem;\n    color: #555;\n}\n\n.cg-field input[type=\"text\"],\n.cg-field input[type=\"password\"],\n.cg-field select {\n    width: 100%;\n    padding: 6px 8px;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    font-size: 0.78rem;\n    font-family: inherit;\n    box-sizing: border-box;\n    background: #fff;\n}\n\n.cg-field input[type=\"text\"]:focus,\n.cg-field input[type=\"password\"]:focus,\n.cg-field select:focus {\n    border-color: var(--md-primary-fg-color);\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);\n}\n\n.cg-checkbox {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    margin-bottom: 10px;\n}\n\n.cg-checkbox input[type=\"checkbox\"] {\n    accent-color: var(--md-primary-fg-color);\n}\n\n.cg-checkbox label {\n    font-weight: 500;\n    font-size: 0.78rem;\n    margin: 0;\n    cursor: pointer;\n}\n\n.cg-radio-group {\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n}\n\n.cg-radio-group label {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    font-weight: 400;\n    font-size: 0.78rem;\n    cursor: pointer;\n}\n\n.cg-radio-group input[type=\"radio\"] {\n    accent-color: var(--md-primary-fg-color);\n}\n\n/* Inline field: label + control side by side */\n.cg-inline-field {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n}\n\n.cg-inline-field > label {\n    margin-bottom: 0;\n    width: 60%;\n    flex-shrink: 0;\n}\n\n.cg-panel:not(#cg-panel-general) .cg-inline-field > label {\n    width: 50%;\n}\n\n.cg-inline-field > input[type=\"text\"],\n.cg-inline-field > select {\n    padding: 4px 10px;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    font-size: 0.75rem;\n    font-family: inherit;\n    box-sizing: border-box;\n    line-height: 1.4;\n    background: #fff;\n}\n\n.cg-inline-field > input[type=\"text\"]:focus,\n.cg-inline-field > select:focus {\n    border-color: var(--md-primary-fg-color);\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);\n}\n\n#cg-email-in-section {\n    margin-top: 20px;\n}\n\n.cg-pg-label {\n    font-size: 0.75rem;\n    color: #888;\n    font-style: italic;\n}\n\n/* Button group toggle */\n.cg-btn-group {\n    display: flex;\n    flex-shrink: 0;\n}\n\n.cg-btn-group label {\n    cursor: pointer;\n    margin: 0;\n}\n\n.cg-btn-group input[type=\"radio\"] {\n    display: none;\n}\n\n.cg-btn-group span {\n    display: block;\n    padding: 4px 14px;\n    font-size: 0.75rem;\n    font-weight: 500;\n    line-height: 1.4;\n    border: 1px solid #ccc;\n    color: #555;\n    background: #fff;\n    transition: background 0.15s, color 0.15s, border-color 0.15s;\n    user-select: none;\n}\n\n.cg-btn-group label:first-child span {\n    border-radius: 4px 0 0 4px;\n}\n\n.cg-btn-group label:last-child span {\n    border-radius: 0 4px 4px 0;\n}\n\n.cg-btn-group label + label span {\n    margin-left: -1px;\n}\n\n.cg-btn-group input[type=\"radio\"]:checked + span {\n    background: var(--md-primary-fg-color);\n    color: #fff;\n    border-color: var(--md-primary-fg-color);\n    position: relative;\n    z-index: 1;\n}\n\n.cg-feature-grid {\n    display: flex;\n    flex-direction: column;\n    gap: 5px;\n}\n\n.cg-feature-row {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.cg-feature-grid label {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    font-size: 0.78rem;\n    cursor: pointer;\n}\n\n.cg-feature-grid input[type=\"checkbox\"] {\n    accent-color: var(--md-primary-fg-color);\n}\n\n.cg-btn-configure {\n    background: none;\n    border: 1px solid var(--md-primary-fg-color);\n    border-radius: 10px;\n    color: var(--md-primary-fg-color);\n    font-size: 0.68rem;\n    font-family: inherit;\n    padding: 1px 10px;\n    cursor: pointer;\n    white-space: nowrap;\n    transition: background 0.15s, color 0.15s;\n}\n\n.cg-btn-configure:hover {\n    background: var(--md-primary-fg-color);\n    color: #fff;\n}\n\n/* Repeatable rows */\n.cg-repeatable-row {\n    display: flex;\n    gap: 6px;\n    align-items: center;\n    margin-bottom: 6px;\n    flex-wrap: wrap;\n}\n\n.cg-repeatable-row input,\n.cg-repeatable-row select {\n    flex: 1;\n    min-width: 80px;\n    padding: 5px 6px;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    font-size: 0.75rem;\n    font-family: inherit;\n    box-sizing: border-box;\n}\n\n.cg-repeatable-row input:focus,\n.cg-repeatable-row select:focus {\n    border-color: var(--md-primary-fg-color);\n    outline: none;\n}\n\n.cg-repeatable-row input:disabled,\n.cg-repeatable-row select:disabled {\n    background: #eee;\n    color: #999;\n    cursor: not-allowed;\n}\n\n.cg-btn-remove {\n    background: none;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    cursor: pointer;\n    font-size: 0.9rem;\n    padding: 5px 8px;\n    color: #999;\n    line-height: 1;\n}\n\n.cg-btn-remove:hover {\n    background: #fee;\n    border-color: #c66;\n    color: #c33;\n}\n\n.cg-btn-add {\n    background: none;\n    border: 1px dashed #bbb;\n    border-radius: 4px;\n    cursor: pointer;\n    padding: 5px 10px;\n    font-size: 0.75rem;\n    color: #777;\n    margin-top: 2px;\n}\n\n.cg-btn-add:hover {\n    border-color: var(--md-primary-fg-color);\n    color: var(--md-primary-fg-color);\n}\n\n/* Dark mode */\nbody[data-md-color-scheme=\"slate\"] .cg-modal-dialog {\n    background: #1e1e2e;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-header {\n    border-bottom-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-title {\n    color: #ddd;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-desc {\n    color: #777;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-reset {\n    border-color: #555;\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-reset:hover {\n    border-color: #888;\n    color: #ddd;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-close {\n    color: #777;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-modal-close:hover {\n    color: #ddd;\n}\n\nbody[data-md-color-scheme=\"slate\"] #cg-left {\n    border-right-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-nav {\n    border-bottom-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-nav-tab {\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-nav-tab:hover {\n    color: #bbb;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-output-tabs {\n    border-bottom-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-output-tab {\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-output-tab:hover {\n    color: #bbb;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-copy {\n    color: #777;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-copy:hover {\n    color: #bbb;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-output-wrap pre {\n    background: #161620;\n    color: #ddd;\n    border-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-field:nth-child(odd) {\n    background: #232334;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-field:nth-child(even) {\n    background: #1e1e2e;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-panel-desc {\n    color: #777;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-field > label {\n    color: #aaa;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-field input[type=\"text\"],\nbody[data-md-color-scheme=\"slate\"] .cg-field input[type=\"password\"],\nbody[data-md-color-scheme=\"slate\"] .cg-field select {\n    background: #2a2a3a;\n    border-color: #555;\n    color: #ddd;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-group span {\n    background: #2a2a3a;\n    border-color: #555;\n    color: #aaa;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-group input[type=\"radio\"]:checked + span {\n    background: var(--md-primary-fg-color);\n    color: #fff;\n    border-color: var(--md-primary-fg-color);\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-checkbox label {\n    color: #ccc;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-radio-group label {\n    color: #ccc;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-feature-grid label {\n    color: #ccc;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-repeatable-row input,\nbody[data-md-color-scheme=\"slate\"] .cg-repeatable-row select {\n    background: #2a2a3a;\n    border-color: #555;\n    color: #ddd;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-repeatable-row input:disabled,\nbody[data-md-color-scheme=\"slate\"] .cg-repeatable-row select:disabled {\n    background: #1a1a28;\n    color: #666;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-remove {\n    border-color: #555;\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-btn-add {\n    border-color: #555;\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-warning {\n    background: #3a2e00;\n    color: #ffc107;\n    border-color: #665200;\n}\n\n/* Mobile toggle bar (hidden on desktop) */\n.cg-mobile-toggle {\n    display: none;\n}\n\n/* Responsive */\n@media (max-width: 900px) {\n    .cg-modal-dialog {\n        inset: 0;\n        border-radius: 0;\n    }\n\n    .cg-modal-header {\n        padding: 8px 16px;\n    }\n\n    .cg-modal-title {\n        font-size: 0.85rem;\n    }\n\n    .cg-modal-desc {\n        display: none;\n    }\n\n    .cg-modal-body {\n        flex-direction: column;\n    }\n\n    .cg-mobile-toggle {\n        display: flex;\n        flex-shrink: 0;\n        border-bottom: 1px solid #ddd;\n    }\n\n    .cg-mobile-toggle-btn {\n        flex: 1;\n        padding: 8px 0;\n        border: none;\n        background: #f5f5f5;\n        font-size: 0.78rem;\n        font-weight: 500;\n        font-family: inherit;\n        color: #777;\n        cursor: pointer;\n        transition: background 0.15s, color 0.15s;\n    }\n\n    .cg-mobile-toggle-btn.active {\n        background: #fff;\n        color: var(--md-primary-fg-color);\n        box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);\n    }\n\n    #cg-left {\n        border-right: none;\n        flex: 1;\n        min-height: 0;\n    }\n\n    #cg-right {\n        flex: 1;\n        display: none;\n        min-height: 0;\n    }\n\n    #cg-right.cg-mobile-active {\n        display: flex;\n    }\n\n    #cg-left.cg-mobile-hidden {\n        display: none;\n    }\n\n    .cg-nav {\n        overflow-x: auto;\n        flex-wrap: nowrap;\n        -webkit-overflow-scrolling: touch;\n    }\n\n    .cg-inline-field {\n        flex-direction: column;\n        align-items: stretch;\n        gap: 4px;\n    }\n\n    .cg-inline-field > label {\n        width: 100%;\n    }\n\n    .cg-panel:not(#cg-panel-general) .cg-inline-field > label {\n        width: 100%;\n    }\n}\n\n/* Dark mode mobile toggle */\nbody[data-md-color-scheme=\"slate\"] .cg-mobile-toggle {\n    border-bottom-color: #444;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-mobile-toggle-btn {\n    background: #2a2a3a;\n    color: #888;\n}\n\nbody[data-md-color-scheme=\"slate\"] .cg-mobile-toggle-btn.active {\n    background: #1e1e2e;\n    color: var(--md-primary-fg-color);\n}\n"
  },
  {
    "path": "docs/static/css/extra.css",
    "content": ":root > * {\n    --md-primary-fg-color: #338574;\n    --md-primary-fg-color--light: #338574;\n    --md-primary-fg-color--dark: #338574;\n    --md-footer-bg-color: #353744;\n    --md-text-font: \"Roboto\";\n    --md-code-font: \"Roboto Mono\";\n}\n\n.md-header__button.md-logo :is(img, svg) {\n    width: unset !important;\n}\n\n.md-header__topic:first-child {\n    font-weight: 400;\n}\n\n.md-typeset h4 {\n    font-weight: 500 !important;\n    margin: 0 !important;\n    font-size: 1.1em !important;\n}\n\n.admonition {\n    font-size: .74rem !important;\n}\n\narticle {\n    padding-bottom: 50px;\n}\n\nfigure img, figure video {\n    border-radius: 7px;\n}\n\nheader {\n    background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);\n}\n\nbody[data-md-color-scheme=\"default\"] header {\n    filter: drop-shadow(0 5px 10px #ccc);\n}\n\nbody[data-md-color-scheme=\"slate\"] header {\n    filter: drop-shadow(0 5px 10px #333);\n}\n\nbody[data-md-color-scheme=\"default\"] figure img,\nbody[data-md-color-scheme=\"default\"] figure video,\nbody[data-md-color-scheme=\"default\"] .screenshots img,\nbody[data-md-color-scheme=\"default\"] .screenshots video {\n    filter: drop-shadow(3px 3px 3px #ccc);\n}\n\nbody[data-md-color-scheme=\"slate\"] figure img,\nbody[data-md-color-scheme=\"slate\"] figure video,\nbody[data-md-color-scheme=\"slate\"] .screenshots img,\nbody[data-md-color-scheme=\"slate\"] .screenshots video {\n    filter: drop-shadow(3px 3px 3px #353744);\n}\n\nfigure video {\n    width: 100%;\n    max-height: 450px;\n}\n\n.remove-md-box {\n    background: none;\n    border: none;\n    margin: 0 auto;\n}\n\n.remove-md-box td {\n    padding: 0 10px;\n}\n\n.emoji-table .c {\n    vertical-align: middle !important;\n}\n\n.emoji-table .e {\n    font-size: 2.5em;\n    padding: 0 2px !important;\n    text-align: center !important;\n    vertical-align: middle !important;\n}\n\n/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */\n\n.screenshots {\n    text-align: center;\n}\n\n.screenshots img {\n    max-height: 350px;\n    max-width: 350px;\n    margin: 3px;\n    border-radius: 5px;\n    filter: drop-shadow(2px 2px 2px #ddd);\n}\n\n.screenshots .nowrap {\n    white-space: nowrap;\n}\n\n.lightbox {\n    opacity: 0;\n    visibility: hidden;\n    position: fixed;\n    left: 0;\n    right: 0;\n    top: 0;\n    bottom: 0;\n    z-index: -1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    transition: all 0.15s ease-in;\n}\n\n.lightbox.show {\n    background-color: rgba(0, 0, 0, 0.75);\n    opacity: 1;\n    visibility: visible;\n    z-index: 1000;\n}\n\n.lightbox img {\n    max-width: 90%;\n    max-height: 90%;\n    filter: drop-shadow(5px 5px 10px #222);\n    border-radius: 5px;\n}\n\n.lightbox .close-lightbox {\n    cursor: pointer;\n    position: absolute;\n    top: 30px;\n    right: 30px;\n    width: 20px;\n    height: 20px;\n}\n\n.lightbox .close-lightbox::after,\n.lightbox .close-lightbox::before {\n    content: '';\n    width: 3px;\n    height: 20px;\n    background-color: #ddd;\n    position: absolute;\n    border-radius: 5px;\n    transform: rotate(45deg);\n}\n\n.lightbox .close-lightbox::before {\n    transform: rotate(-45deg);\n}\n\n.lightbox .close-lightbox:hover::after,\n.lightbox .close-lightbox:hover::before {\n    background-color: #fff;\n}\n\n/* roboto-300 - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 300;\n    src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2');\n}\n\n/* roboto-regular - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 400;\n    src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2');\n}\n\n/* roboto-italic - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto';\n    font-style: italic;\n    font-weight: 400;\n    src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2');\n}\n\n/* roboto-500 - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 500;\n    src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2');\n}\n\n/* roboto-700 - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 700;\n    src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2');\n}\n\n/* roboto-mono - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Roboto Mono';\n    font-style: normal;\n    font-weight: 400;\n    src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');\n}\n\n/* Community maintained badge */\n.community-badge {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.35em;\n    background-color: rgba(51, 133, 116, 0.1);\n    border: 1px solid rgba(51, 133, 116, 0.3);\n    border-radius: 0.7em;\n    padding: 0.1em 0.7em;\n    font-size: 0.75rem;\n    color: #338574;\n    margin-top: 0;\n    margin-bottom: 0.5em;\n}\n\n.community-badge svg {\n    width: 1em;\n    height: 1em;\n    fill: currentColor;\n}\n\nbody[data-md-color-scheme=\"slate\"] .community-badge {\n    background-color: rgba(86, 189, 168, 0.15);\n    border-color: rgba(86, 189, 168, 0.4);\n    color: #56bda8;\n}\n"
  },
  {
    "path": "docs/static/js/bcrypt.js",
    "content": "// GENERATED FILE. DO NOT EDIT.\n(function (global, factory) {\n  function preferDefault(exports) {\n    return exports.default || exports;\n  }\n  if (typeof define === \"function\" && define.amd) {\n    define([\"crypto\"], function (_crypto) {\n      var exports = {};\n      factory(exports, _crypto);\n      return preferDefault(exports);\n    });\n  } else if (typeof exports === \"object\") {\n    factory(exports, require(\"crypto\"));\n    if (typeof module === \"object\") module.exports = preferDefault(exports);\n  } else {\n    (function () {\n      var exports = {};\n      factory(exports, global.crypto);\n      global.bcrypt = preferDefault(exports);\n    })();\n  }\n})(\n  typeof globalThis !== \"undefined\"\n    ? globalThis\n    : typeof self !== \"undefined\"\n      ? self\n      : this,\n  function (_exports, _crypto) {\n    \"use strict\";\n\n    Object.defineProperty(_exports, \"__esModule\", {\n      value: true,\n    });\n    _exports.compare = compare;\n    _exports.compareSync = compareSync;\n    _exports.decodeBase64 = decodeBase64;\n    _exports.default = void 0;\n    _exports.encodeBase64 = encodeBase64;\n    _exports.genSalt = genSalt;\n    _exports.genSaltSync = genSaltSync;\n    _exports.getRounds = getRounds;\n    _exports.getSalt = getSalt;\n    _exports.hash = hash;\n    _exports.hashSync = hashSync;\n    _exports.setRandomFallback = setRandomFallback;\n    _exports.truncates = truncates;\n    _crypto = _interopRequireDefault(_crypto);\n    function _interopRequireDefault(e) {\n      return e && e.__esModule ? e : { default: e };\n    }\n    /*\n   Copyright (c) 2012 Nevins Bartolomeo <nevins.bartolomeo@gmail.com>\n   Copyright (c) 2012 Shane Girish <shaneGirish@gmail.com>\n   Copyright (c) 2025 Daniel Wirtz <dcode@dcode.io>\n  \n   Redistribution and use in source and binary forms, with or without\n   modification, are permitted provided that the following conditions\n   are met:\n   1. Redistributions of source code must retain the above copyright\n   notice, this list of conditions and the following disclaimer.\n   2. Redistributions in binary form must reproduce the above copyright\n   notice, this list of conditions and the following disclaimer in the\n   documentation and/or other materials provided with the distribution.\n   3. The name of the author may not be used to endorse or promote products\n   derived from this software without specific prior written permission.\n  \n   THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR\n   IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\n   OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.\n   IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,\n   INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT\n   NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n   DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n   THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF\n   THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n   */\n\n    // The Node.js crypto module is used as a fallback for the Web Crypto API. When\n    // building for the browser, inclusion of the crypto module should be disabled,\n    // which the package hints at in its package.json for bundlers that support it.\n\n    /**\n     * The random implementation to use as a fallback.\n     * @type {?function(number):!Array.<number>}\n     * @inner\n     */\n    var randomFallback = null;\n\n    /**\n     * Generates cryptographically secure random bytes.\n     * @function\n     * @param {number} len Bytes length\n     * @returns {!Array.<number>} Random bytes\n     * @throws {Error} If no random implementation is available\n     * @inner\n     */\n    function randomBytes(len) {\n      // Web Crypto API. Globally available in the browser and in Node.js >=23.\n      try {\n        return crypto.getRandomValues(new Uint8Array(len));\n      } catch {}\n      // Node.js crypto module for non-browser environments.\n      try {\n        return _crypto.default.randomBytes(len);\n      } catch {}\n      // Custom fallback specified with `setRandomFallback`.\n      if (!randomFallback) {\n        throw Error(\n          \"Neither WebCryptoAPI nor a crypto module is available. Use bcrypt.setRandomFallback to set an alternative\",\n        );\n      }\n      return randomFallback(len);\n    }\n\n    /**\n     * Sets the pseudo random number generator to use as a fallback if neither node's `crypto` module nor the Web Crypto\n     *  API is available. Please note: It is highly important that the PRNG used is cryptographically secure and that it\n     *  is seeded properly!\n     * @param {?function(number):!Array.<number>} random Function taking the number of bytes to generate as its\n     *  sole argument, returning the corresponding array of cryptographically secure random byte values.\n     * @see http://nodejs.org/api/crypto.html\n     * @see http://www.w3.org/TR/WebCryptoAPI/\n     */\n    function setRandomFallback(random) {\n      randomFallback = random;\n    }\n\n    /**\n     * Synchronously generates a salt.\n     * @param {number=} rounds Number of rounds to use, defaults to 10 if omitted\n     * @param {number=} seed_length Not supported.\n     * @returns {string} Resulting salt\n     * @throws {Error} If a random fallback is required but not set\n     */\n    function genSaltSync(rounds, seed_length) {\n      rounds = rounds || GENSALT_DEFAULT_LOG2_ROUNDS;\n      if (typeof rounds !== \"number\")\n        throw Error(\n          \"Illegal arguments: \" + typeof rounds + \", \" + typeof seed_length,\n        );\n      if (rounds < 4) rounds = 4;\n      else if (rounds > 31) rounds = 31;\n      var salt = [];\n      salt.push(\"$2b$\");\n      if (rounds < 10) salt.push(\"0\");\n      salt.push(rounds.toString());\n      salt.push(\"$\");\n      salt.push(base64_encode(randomBytes(BCRYPT_SALT_LEN), BCRYPT_SALT_LEN)); // May throw\n      return salt.join(\"\");\n    }\n\n    /**\n     * Asynchronously generates a salt.\n     * @param {(number|function(Error, string=))=} rounds Number of rounds to use, defaults to 10 if omitted\n     * @param {(number|function(Error, string=))=} seed_length Not supported.\n     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting salt\n     * @returns {!Promise} If `callback` has been omitted\n     * @throws {Error} If `callback` is present but not a function\n     */\n    function genSalt(rounds, seed_length, callback) {\n      if (typeof seed_length === \"function\")\n        (callback = seed_length), (seed_length = undefined); // Not supported.\n      if (typeof rounds === \"function\")\n        (callback = rounds), (rounds = undefined);\n      if (typeof rounds === \"undefined\") rounds = GENSALT_DEFAULT_LOG2_ROUNDS;\n      else if (typeof rounds !== \"number\")\n        throw Error(\"illegal arguments: \" + typeof rounds);\n      function _async(callback) {\n        nextTick(function () {\n          // Pretty thin, but salting is fast enough\n          try {\n            callback(null, genSaltSync(rounds));\n          } catch (err) {\n            callback(err);\n          }\n        });\n      }\n      if (callback) {\n        if (typeof callback !== \"function\")\n          throw Error(\"Illegal callback: \" + typeof callback);\n        _async(callback);\n      } else\n        return new Promise(function (resolve, reject) {\n          _async(function (err, res) {\n            if (err) {\n              reject(err);\n              return;\n            }\n            resolve(res);\n          });\n        });\n    }\n\n    /**\n     * Synchronously generates a hash for the given password.\n     * @param {string} password Password to hash\n     * @param {(number|string)=} salt Salt length to generate or salt to use, default to 10\n     * @returns {string} Resulting hash\n     */\n    function hashSync(password, salt) {\n      if (typeof salt === \"undefined\") salt = GENSALT_DEFAULT_LOG2_ROUNDS;\n      if (typeof salt === \"number\") salt = genSaltSync(salt);\n      if (typeof password !== \"string\" || typeof salt !== \"string\")\n        throw Error(\n          \"Illegal arguments: \" + typeof password + \", \" + typeof salt,\n        );\n      return _hash(password, salt);\n    }\n\n    /**\n     * Asynchronously generates a hash for the given password.\n     * @param {string} password Password to hash\n     * @param {number|string} salt Salt length to generate or salt to use\n     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash\n     * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed\n     *  (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms.\n     * @returns {!Promise} If `callback` has been omitted\n     * @throws {Error} If `callback` is present but not a function\n     */\n    function hash(password, salt, callback, progressCallback) {\n      function _async(callback) {\n        if (typeof password === \"string\" && typeof salt === \"number\")\n          genSalt(salt, function (err, salt) {\n            _hash(password, salt, callback, progressCallback);\n          });\n        else if (typeof password === \"string\" && typeof salt === \"string\")\n          _hash(password, salt, callback, progressCallback);\n        else\n          nextTick(\n            callback.bind(\n              this,\n              Error(\n                \"Illegal arguments: \" + typeof password + \", \" + typeof salt,\n              ),\n            ),\n          );\n      }\n      if (callback) {\n        if (typeof callback !== \"function\")\n          throw Error(\"Illegal callback: \" + typeof callback);\n        _async(callback);\n      } else\n        return new Promise(function (resolve, reject) {\n          _async(function (err, res) {\n            if (err) {\n              reject(err);\n              return;\n            }\n            resolve(res);\n          });\n        });\n    }\n\n    /**\n     * Compares two strings of the same length in constant time.\n     * @param {string} known Must be of the correct length\n     * @param {string} unknown Must be the same length as `known`\n     * @returns {boolean}\n     * @inner\n     */\n    function safeStringCompare(known, unknown) {\n      var diff = known.length ^ unknown.length;\n      for (var i = 0; i < known.length; ++i) {\n        diff |= known.charCodeAt(i) ^ unknown.charCodeAt(i);\n      }\n      return diff === 0;\n    }\n\n    /**\n     * Synchronously tests a password against a hash.\n     * @param {string} password Password to compare\n     * @param {string} hash Hash to test against\n     * @returns {boolean} true if matching, otherwise false\n     * @throws {Error} If an argument is illegal\n     */\n    function compareSync(password, hash) {\n      if (typeof password !== \"string\" || typeof hash !== \"string\")\n        throw Error(\n          \"Illegal arguments: \" + typeof password + \", \" + typeof hash,\n        );\n      if (hash.length !== 60) return false;\n      return safeStringCompare(\n        hashSync(password, hash.substring(0, hash.length - 31)),\n        hash,\n      );\n    }\n\n    /**\n     * Asynchronously tests a password against a hash.\n     * @param {string} password Password to compare\n     * @param {string} hashValue Hash to test against\n     * @param {function(Error, boolean)=} callback Callback receiving the error, if any, otherwise the result\n     * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed\n     *  (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms.\n     * @returns {!Promise} If `callback` has been omitted\n     * @throws {Error} If `callback` is present but not a function\n     */\n    function compare(password, hashValue, callback, progressCallback) {\n      function _async(callback) {\n        if (typeof password !== \"string\" || typeof hashValue !== \"string\") {\n          nextTick(\n            callback.bind(\n              this,\n              Error(\n                \"Illegal arguments: \" +\n                  typeof password +\n                  \", \" +\n                  typeof hashValue,\n              ),\n            ),\n          );\n          return;\n        }\n        if (hashValue.length !== 60) {\n          nextTick(callback.bind(this, null, false));\n          return;\n        }\n        hash(\n          password,\n          hashValue.substring(0, 29),\n          function (err, comp) {\n            if (err) callback(err);\n            else callback(null, safeStringCompare(comp, hashValue));\n          },\n          progressCallback,\n        );\n      }\n      if (callback) {\n        if (typeof callback !== \"function\")\n          throw Error(\"Illegal callback: \" + typeof callback);\n        _async(callback);\n      } else\n        return new Promise(function (resolve, reject) {\n          _async(function (err, res) {\n            if (err) {\n              reject(err);\n              return;\n            }\n            resolve(res);\n          });\n        });\n    }\n\n    /**\n     * Gets the number of rounds used to encrypt the specified hash.\n     * @param {string} hash Hash to extract the used number of rounds from\n     * @returns {number} Number of rounds used\n     * @throws {Error} If `hash` is not a string\n     */\n    function getRounds(hash) {\n      if (typeof hash !== \"string\")\n        throw Error(\"Illegal arguments: \" + typeof hash);\n      return parseInt(hash.split(\"$\")[2], 10);\n    }\n\n    /**\n     * Gets the salt portion from a hash. Does not validate the hash.\n     * @param {string} hash Hash to extract the salt from\n     * @returns {string} Extracted salt part\n     * @throws {Error} If `hash` is not a string or otherwise invalid\n     */\n    function getSalt(hash) {\n      if (typeof hash !== \"string\")\n        throw Error(\"Illegal arguments: \" + typeof hash);\n      if (hash.length !== 60)\n        throw Error(\"Illegal hash length: \" + hash.length + \" != 60\");\n      return hash.substring(0, 29);\n    }\n\n    /**\n     * Tests if a password will be truncated when hashed, that is its length is\n     * greater than 72 bytes when converted to UTF-8.\n     * @param {string} password The password to test\n     * @returns {boolean} `true` if truncated, otherwise `false`\n     */\n    function truncates(password) {\n      if (typeof password !== \"string\")\n        throw Error(\"Illegal arguments: \" + typeof password);\n      return utf8Length(password) > 72;\n    }\n\n    /**\n     * Continues with the callback after yielding to the event loop.\n     * @function\n     * @param {function(...[*])} callback Callback to execute\n     * @inner\n     */\n    var nextTick =\n      typeof setImmediate === \"function\"\n        ? setImmediate\n        : typeof scheduler === \"object\" &&\n            typeof scheduler.postTask === \"function\"\n          ? scheduler.postTask.bind(scheduler)\n          : setTimeout;\n\n    /** Calculates the byte length of a string encoded as UTF8. */\n    function utf8Length(string) {\n      var len = 0,\n        c = 0;\n      for (var i = 0; i < string.length; ++i) {\n        c = string.charCodeAt(i);\n        if (c < 128) len += 1;\n        else if (c < 2048) len += 2;\n        else if (\n          (c & 0xfc00) === 0xd800 &&\n          (string.charCodeAt(i + 1) & 0xfc00) === 0xdc00\n        ) {\n          ++i;\n          len += 4;\n        } else len += 3;\n      }\n      return len;\n    }\n\n    /** Converts a string to an array of UTF8 bytes. */\n    function utf8Array(string) {\n      var offset = 0,\n        c1,\n        c2;\n      var buffer = new Array(utf8Length(string));\n      for (var i = 0, k = string.length; i < k; ++i) {\n        c1 = string.charCodeAt(i);\n        if (c1 < 128) {\n          buffer[offset++] = c1;\n        } else if (c1 < 2048) {\n          buffer[offset++] = (c1 >> 6) | 192;\n          buffer[offset++] = (c1 & 63) | 128;\n        } else if (\n          (c1 & 0xfc00) === 0xd800 &&\n          ((c2 = string.charCodeAt(i + 1)) & 0xfc00) === 0xdc00\n        ) {\n          c1 = 0x10000 + ((c1 & 0x03ff) << 10) + (c2 & 0x03ff);\n          ++i;\n          buffer[offset++] = (c1 >> 18) | 240;\n          buffer[offset++] = ((c1 >> 12) & 63) | 128;\n          buffer[offset++] = ((c1 >> 6) & 63) | 128;\n          buffer[offset++] = (c1 & 63) | 128;\n        } else {\n          buffer[offset++] = (c1 >> 12) | 224;\n          buffer[offset++] = ((c1 >> 6) & 63) | 128;\n          buffer[offset++] = (c1 & 63) | 128;\n        }\n      }\n      return buffer;\n    }\n\n    // A base64 implementation for the bcrypt algorithm. This is partly non-standard.\n\n    /**\n     * bcrypt's own non-standard base64 dictionary.\n     * @type {!Array.<string>}\n     * @const\n     * @inner\n     **/\n    var BASE64_CODE =\n      \"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\".split(\n        \"\",\n      );\n\n    /**\n     * @type {!Array.<number>}\n     * @const\n     * @inner\n     **/\n    var BASE64_INDEX = [\n      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 54, 55, 56, 57, 58, 59, 60,\n      61, 62, 63, -1, -1, -1, -1, -1, -1, -1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,\n      12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, -1, -1,\n      -1, -1, -1, -1, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,\n      42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, -1, -1, -1, -1, -1,\n    ];\n\n    /**\n     * Encodes a byte array to base64 with up to len bytes of input.\n     * @param {!Array.<number>} b Byte array\n     * @param {number} len Maximum input length\n     * @returns {string}\n     * @inner\n     */\n    function base64_encode(b, len) {\n      var off = 0,\n        rs = [],\n        c1,\n        c2;\n      if (len <= 0 || len > b.length) throw Error(\"Illegal len: \" + len);\n      while (off < len) {\n        c1 = b[off++] & 0xff;\n        rs.push(BASE64_CODE[(c1 >> 2) & 0x3f]);\n        c1 = (c1 & 0x03) << 4;\n        if (off >= len) {\n          rs.push(BASE64_CODE[c1 & 0x3f]);\n          break;\n        }\n        c2 = b[off++] & 0xff;\n        c1 |= (c2 >> 4) & 0x0f;\n        rs.push(BASE64_CODE[c1 & 0x3f]);\n        c1 = (c2 & 0x0f) << 2;\n        if (off >= len) {\n          rs.push(BASE64_CODE[c1 & 0x3f]);\n          break;\n        }\n        c2 = b[off++] & 0xff;\n        c1 |= (c2 >> 6) & 0x03;\n        rs.push(BASE64_CODE[c1 & 0x3f]);\n        rs.push(BASE64_CODE[c2 & 0x3f]);\n      }\n      return rs.join(\"\");\n    }\n\n    /**\n     * Decodes a base64 encoded string to up to len bytes of output.\n     * @param {string} s String to decode\n     * @param {number} len Maximum output length\n     * @returns {!Array.<number>}\n     * @inner\n     */\n    function base64_decode(s, len) {\n      var off = 0,\n        slen = s.length,\n        olen = 0,\n        rs = [],\n        c1,\n        c2,\n        c3,\n        c4,\n        o,\n        code;\n      if (len <= 0) throw Error(\"Illegal len: \" + len);\n      while (off < slen - 1 && olen < len) {\n        code = s.charCodeAt(off++);\n        c1 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;\n        code = s.charCodeAt(off++);\n        c2 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;\n        if (c1 == -1 || c2 == -1) break;\n        o = (c1 << 2) >>> 0;\n        o |= (c2 & 0x30) >> 4;\n        rs.push(String.fromCharCode(o));\n        if (++olen >= len || off >= slen) break;\n        code = s.charCodeAt(off++);\n        c3 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;\n        if (c3 == -1) break;\n        o = ((c2 & 0x0f) << 4) >>> 0;\n        o |= (c3 & 0x3c) >> 2;\n        rs.push(String.fromCharCode(o));\n        if (++olen >= len || off >= slen) break;\n        code = s.charCodeAt(off++);\n        c4 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;\n        o = ((c3 & 0x03) << 6) >>> 0;\n        o |= c4;\n        rs.push(String.fromCharCode(o));\n        ++olen;\n      }\n      var res = [];\n      for (off = 0; off < olen; off++) res.push(rs[off].charCodeAt(0));\n      return res;\n    }\n\n    /**\n     * @type {number}\n     * @const\n     * @inner\n     */\n    var BCRYPT_SALT_LEN = 16;\n\n    /**\n     * @type {number}\n     * @const\n     * @inner\n     */\n    var GENSALT_DEFAULT_LOG2_ROUNDS = 10;\n\n    /**\n     * @type {number}\n     * @const\n     * @inner\n     */\n    var BLOWFISH_NUM_ROUNDS = 16;\n\n    /**\n     * @type {number}\n     * @const\n     * @inner\n     */\n    var MAX_EXECUTION_TIME = 100;\n\n    /**\n     * @type {Array.<number>}\n     * @const\n     * @inner\n     */\n    var P_ORIG = [\n      0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0,\n      0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,\n      0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b,\n    ];\n\n    /**\n     * @type {Array.<number>}\n     * @const\n     * @inner\n     */\n    var S_ORIG = [\n      0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96,\n      0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16,\n      0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658,\n      0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013,\n      0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e,\n      0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60,\n      0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6,\n      0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a,\n      0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c,\n      0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193,\n      0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1,\n      0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239,\n      0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a,\n      0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3,\n      0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176,\n      0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe,\n      0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706,\n      0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b,\n      0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b,\n      0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463,\n      0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c,\n      0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3,\n      0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a,\n      0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8,\n      0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760,\n      0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db,\n      0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8,\n      0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b,\n      0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33,\n      0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4,\n      0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0,\n      0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c,\n      0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777,\n      0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299,\n      0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705,\n      0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf,\n      0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e,\n      0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa,\n      0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9,\n      0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915,\n      0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f,\n      0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664,\n      0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a, 0x4b7a70e9, 0xb5b32944,\n      0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, 0x9cee60b8, 0x8fedb266,\n      0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, 0x193602a5, 0x75094c29,\n      0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, 0x6b8fe4d6, 0x99f73fd6,\n      0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, 0x4cdd2086, 0x8470eb26,\n      0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, 0x3c971814, 0x6b6a70a1,\n      0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, 0x3e07841c, 0x7fdeae5c,\n      0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, 0xf01c1f04, 0x0200b3ff,\n      0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, 0xd19113f9, 0x7ca92ff6,\n      0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, 0xc8b57634, 0x9af3dda7,\n      0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, 0xe238cd99, 0x3bea0e2f,\n      0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, 0x6f420d03, 0xf60a04bf,\n      0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, 0xde9a771f, 0xd9930810,\n      0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, 0x501adde6, 0x9f84cd87,\n      0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, 0xec7aec3a, 0xdb851dfa,\n      0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, 0xdd433b37, 0x24c2ba16,\n      0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, 0x71dff89e, 0x10314e55,\n      0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, 0x3c11183b, 0x5924a509,\n      0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, 0x86e34570, 0xeae96fb1,\n      0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, 0x2965dcb9, 0x99e71d0f,\n      0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, 0xc6150eba, 0x94e2ea78,\n      0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, 0x1939260f, 0x19c27960,\n      0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, 0xe3bc4595, 0xa67bc883,\n      0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, 0x65582185, 0x68ab9802,\n      0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, 0x1521b628, 0x29076170,\n      0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, 0x0334fe1e, 0xaa0363cf,\n      0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, 0xeecc86bc, 0x60622ca7,\n      0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, 0xa02369b9, 0x655abb50,\n      0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, 0x9b540b19, 0x875fa099,\n      0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, 0x11ed935f, 0x16681281,\n      0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, 0x57f584a5, 0x1b227263,\n      0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, 0x8fd948e4, 0x6dbc3128,\n      0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, 0x5d4a14d9, 0xe864b7e3,\n      0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, 0xdb6c4f15, 0xfacb4fd0,\n      0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, 0xd81e799e, 0x86854dc7,\n      0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, 0xfc8883a0, 0xc1c7b6a3,\n      0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, 0x095bbf00, 0xad19489d,\n      0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, 0x1dadf43e, 0x233f7061,\n      0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, 0x7cde3759, 0xcbee7460,\n      0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, 0xe8efd855, 0x61d99735,\n      0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, 0x9e447a2e, 0xc3453484,\n      0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, 0x675fda79, 0xe3674340,\n      0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, 0x153e21e7, 0x8fb03d4a,\n      0xe6e39f2b, 0xdb83adf7, 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934,\n      0x411520f7, 0x7602d4f7, 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a,\n      0x43b7d4b7, 0x500061af, 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840,\n      0x4d95fc1d, 0x96b591af, 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785,\n      0x7fac6dd0, 0x31cb8504, 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a,\n      0x28507825, 0x530429f4, 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900,\n      0x680ec0a4, 0x27a18dee, 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6,\n      0xaace1e7c, 0xd3375fec, 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9,\n      0xee39d7ab, 0x3b124e8b, 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2,\n      0x3a6efa74, 0xdd5b4332, 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397,\n      0x454056ac, 0xba489527, 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b,\n      0x55a867bc, 0xa1159a58, 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9,\n      0x5ef47e1c, 0x9029317c, 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3,\n      0x95c11548, 0xe4c66d22, 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f,\n      0x404779a4, 0x5d886e17, 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564,\n      0x257b7834, 0x602a9c60, 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e,\n      0xaf664fd1, 0xcad18115, 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922,\n      0x85b2a20e, 0xe6ba0d99, 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd,\n      0x647d0862, 0xe7ccf5f0, 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e,\n      0x0a476341, 0x992eff74, 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8,\n      0x991be14c, 0xdb6e6b0d, 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804,\n      0xf1290dc7, 0xcc00ffa3, 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c,\n      0xa091cf0b, 0xd9155ea3, 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb,\n      0x37392eb3, 0xcc115979, 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b,\n      0x12754ccc, 0x782ef11c, 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350,\n      0x1a6b1018, 0x11caedfa, 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386,\n      0xd90cec6e, 0xd5abea2a, 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe,\n      0x9dbc8057, 0xf0f7c086, 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0,\n      0x7745ae04, 0xd736fccc, 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f,\n      0x77a057be, 0xbde8ae24, 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2,\n      0xf474ef38, 0x8789bdc2, 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9,\n      0x7aeb2661, 0x8b1ddf84, 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770,\n      0x8cd55591, 0xc902de4c, 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e,\n      0xb77f19b6, 0xe0a9dc09, 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c,\n      0x4a99a025, 0x1d6efe10, 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169,\n      0xdcb7da83, 0x573906fe, 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa,\n      0xa002b5c4, 0x0de6d027, 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5,\n      0xf0177a28, 0xc0f586e0, 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63,\n      0x53c2dd94, 0xc2c21634, 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76,\n      0x6f05e409, 0x4b7c0188, 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9,\n      0x1ac15bb4, 0xd39eb8fc, 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4,\n      0x1e50ef5e, 0xb161e6f8, 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4,\n      0x362abfce, 0xddc6c837, 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0,\n      0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742,\n      0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b,\n      0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79,\n      0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6,\n      0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a,\n      0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4,\n      0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1,\n      0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59,\n      0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797,\n      0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28,\n      0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6,\n      0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28,\n      0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba,\n      0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a,\n      0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5,\n      0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f,\n      0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce,\n      0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680,\n      0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd,\n      0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb,\n      0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb,\n      0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370,\n      0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc,\n      0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048,\n      0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc,\n      0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9,\n      0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a,\n      0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f,\n      0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a,\n      0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1,\n      0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b,\n      0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e,\n      0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e,\n      0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f,\n      0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623,\n      0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc,\n      0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a,\n      0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6,\n      0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3,\n      0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060,\n      0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c,\n      0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f,\n      0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6,\n    ];\n\n    /**\n     * @type {Array.<number>}\n     * @const\n     * @inner\n     */\n    var C_ORIG = [\n      0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274,\n    ];\n\n    /**\n     * @param {Array.<number>} lr\n     * @param {number} off\n     * @param {Array.<number>} P\n     * @param {Array.<number>} S\n     * @returns {Array.<number>}\n     * @inner\n     */\n    function _encipher(lr, off, P, S) {\n      // This is our bottleneck: 1714/1905 ticks / 90% - see profile.txt\n      var n,\n        l = lr[off],\n        r = lr[off + 1];\n      l ^= P[0];\n\n      /*\n      for (var i=0, k=BLOWFISH_NUM_ROUNDS-2; i<=k;)\n          // Feistel substitution on left word\n          n  = S[l >>> 24],\n          n += S[0x100 | ((l >> 16) & 0xff)],\n          n ^= S[0x200 | ((l >> 8) & 0xff)],\n          n += S[0x300 | (l & 0xff)],\n          r ^= n ^ P[++i],\n          // Feistel substitution on right word\n          n  = S[r >>> 24],\n          n += S[0x100 | ((r >> 16) & 0xff)],\n          n ^= S[0x200 | ((r >> 8) & 0xff)],\n          n += S[0x300 | (r & 0xff)],\n          l ^= n ^ P[++i];\n      */\n\n      //The following is an unrolled version of the above loop.\n      //Iteration 0\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[1];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[2];\n      //Iteration 1\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[3];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[4];\n      //Iteration 2\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[5];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[6];\n      //Iteration 3\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[7];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[8];\n      //Iteration 4\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[9];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[10];\n      //Iteration 5\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[11];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[12];\n      //Iteration 6\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[13];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[14];\n      //Iteration 7\n      n = S[l >>> 24];\n      n += S[0x100 | ((l >> 16) & 0xff)];\n      n ^= S[0x200 | ((l >> 8) & 0xff)];\n      n += S[0x300 | (l & 0xff)];\n      r ^= n ^ P[15];\n      n = S[r >>> 24];\n      n += S[0x100 | ((r >> 16) & 0xff)];\n      n ^= S[0x200 | ((r >> 8) & 0xff)];\n      n += S[0x300 | (r & 0xff)];\n      l ^= n ^ P[16];\n      lr[off] = r ^ P[BLOWFISH_NUM_ROUNDS + 1];\n      lr[off + 1] = l;\n      return lr;\n    }\n\n    /**\n     * @param {Array.<number>} data\n     * @param {number} offp\n     * @returns {{key: number, offp: number}}\n     * @inner\n     */\n    function _streamtoword(data, offp) {\n      for (var i = 0, word = 0; i < 4; ++i)\n        (word = (word << 8) | (data[offp] & 0xff)),\n          (offp = (offp + 1) % data.length);\n      return {\n        key: word,\n        offp: offp,\n      };\n    }\n\n    /**\n     * @param {Array.<number>} key\n     * @param {Array.<number>} P\n     * @param {Array.<number>} S\n     * @inner\n     */\n    function _key(key, P, S) {\n      var offset = 0,\n        lr = [0, 0],\n        plen = P.length,\n        slen = S.length,\n        sw;\n      for (var i = 0; i < plen; i++)\n        (sw = _streamtoword(key, offset)),\n          (offset = sw.offp),\n          (P[i] = P[i] ^ sw.key);\n      for (i = 0; i < plen; i += 2)\n        (lr = _encipher(lr, 0, P, S)), (P[i] = lr[0]), (P[i + 1] = lr[1]);\n      for (i = 0; i < slen; i += 2)\n        (lr = _encipher(lr, 0, P, S)), (S[i] = lr[0]), (S[i + 1] = lr[1]);\n    }\n\n    /**\n     * Expensive key schedule Blowfish.\n     * @param {Array.<number>} data\n     * @param {Array.<number>} key\n     * @param {Array.<number>} P\n     * @param {Array.<number>} S\n     * @inner\n     */\n    function _ekskey(data, key, P, S) {\n      var offp = 0,\n        lr = [0, 0],\n        plen = P.length,\n        slen = S.length,\n        sw;\n      for (var i = 0; i < plen; i++)\n        (sw = _streamtoword(key, offp)),\n          (offp = sw.offp),\n          (P[i] = P[i] ^ sw.key);\n      offp = 0;\n      for (i = 0; i < plen; i += 2)\n        (sw = _streamtoword(data, offp)),\n          (offp = sw.offp),\n          (lr[0] ^= sw.key),\n          (sw = _streamtoword(data, offp)),\n          (offp = sw.offp),\n          (lr[1] ^= sw.key),\n          (lr = _encipher(lr, 0, P, S)),\n          (P[i] = lr[0]),\n          (P[i + 1] = lr[1]);\n      for (i = 0; i < slen; i += 2)\n        (sw = _streamtoword(data, offp)),\n          (offp = sw.offp),\n          (lr[0] ^= sw.key),\n          (sw = _streamtoword(data, offp)),\n          (offp = sw.offp),\n          (lr[1] ^= sw.key),\n          (lr = _encipher(lr, 0, P, S)),\n          (S[i] = lr[0]),\n          (S[i + 1] = lr[1]);\n    }\n\n    /**\n     * Internaly crypts a string.\n     * @param {Array.<number>} b Bytes to crypt\n     * @param {Array.<number>} salt Salt bytes to use\n     * @param {number} rounds Number of rounds\n     * @param {function(Error, Array.<number>=)=} callback Callback receiving the error, if any, and the resulting bytes. If\n     *  omitted, the operation will be performed synchronously.\n     *  @param {function(number)=} progressCallback Callback called with the current progress\n     * @returns {!Array.<number>|undefined} Resulting bytes if callback has been omitted, otherwise `undefined`\n     * @inner\n     */\n    function _crypt(b, salt, rounds, callback, progressCallback) {\n      var cdata = C_ORIG.slice(),\n        clen = cdata.length,\n        err;\n\n      // Validate\n      if (rounds < 4 || rounds > 31) {\n        err = Error(\"Illegal number of rounds (4-31): \" + rounds);\n        if (callback) {\n          nextTick(callback.bind(this, err));\n          return;\n        } else throw err;\n      }\n      if (salt.length !== BCRYPT_SALT_LEN) {\n        err = Error(\n          \"Illegal salt length: \" + salt.length + \" != \" + BCRYPT_SALT_LEN,\n        );\n        if (callback) {\n          nextTick(callback.bind(this, err));\n          return;\n        } else throw err;\n      }\n      rounds = (1 << rounds) >>> 0;\n      var P,\n        S,\n        i = 0,\n        j;\n\n      //Use typed arrays when available - huge speedup!\n      if (typeof Int32Array === \"function\") {\n        P = new Int32Array(P_ORIG);\n        S = new Int32Array(S_ORIG);\n      } else {\n        P = P_ORIG.slice();\n        S = S_ORIG.slice();\n      }\n      _ekskey(salt, b, P, S);\n\n      /**\n       * Calcualtes the next round.\n       * @returns {Array.<number>|undefined} Resulting array if callback has been omitted, otherwise `undefined`\n       * @inner\n       */\n      function next() {\n        if (progressCallback) progressCallback(i / rounds);\n        if (i < rounds) {\n          var start = Date.now();\n          for (; i < rounds; ) {\n            i = i + 1;\n            _key(b, P, S);\n            _key(salt, P, S);\n            if (Date.now() - start > MAX_EXECUTION_TIME) break;\n          }\n        } else {\n          for (i = 0; i < 64; i++)\n            for (j = 0; j < clen >> 1; j++) _encipher(cdata, j << 1, P, S);\n          var ret = [];\n          for (i = 0; i < clen; i++)\n            ret.push(((cdata[i] >> 24) & 0xff) >>> 0),\n              ret.push(((cdata[i] >> 16) & 0xff) >>> 0),\n              ret.push(((cdata[i] >> 8) & 0xff) >>> 0),\n              ret.push((cdata[i] & 0xff) >>> 0);\n          if (callback) {\n            callback(null, ret);\n            return;\n          } else return ret;\n        }\n        if (callback) nextTick(next);\n      }\n\n      // Async\n      if (typeof callback !== \"undefined\") {\n        next();\n\n        // Sync\n      } else {\n        var res;\n        while (true)\n          if (typeof (res = next()) !== \"undefined\") return res || [];\n      }\n    }\n\n    /**\n     * Internally hashes a password.\n     * @param {string} password Password to hash\n     * @param {?string} salt Salt to use, actually never null\n     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash. If omitted,\n     *  hashing is performed synchronously.\n     *  @param {function(number)=} progressCallback Callback called with the current progress\n     * @returns {string|undefined} Resulting hash if callback has been omitted, otherwise `undefined`\n     * @inner\n     */\n    function _hash(password, salt, callback, progressCallback) {\n      var err;\n      if (typeof password !== \"string\" || typeof salt !== \"string\") {\n        err = Error(\"Invalid string / salt: Not a string\");\n        if (callback) {\n          nextTick(callback.bind(this, err));\n          return;\n        } else throw err;\n      }\n\n      // Validate the salt\n      var minor, offset;\n      if (salt.charAt(0) !== \"$\" || salt.charAt(1) !== \"2\") {\n        err = Error(\"Invalid salt version: \" + salt.substring(0, 2));\n        if (callback) {\n          nextTick(callback.bind(this, err));\n          return;\n        } else throw err;\n      }\n      if (salt.charAt(2) === \"$\")\n        (minor = String.fromCharCode(0)), (offset = 3);\n      else {\n        minor = salt.charAt(2);\n        if (\n          (minor !== \"a\" && minor !== \"b\" && minor !== \"y\") ||\n          salt.charAt(3) !== \"$\"\n        ) {\n          err = Error(\"Invalid salt revision: \" + salt.substring(2, 4));\n          if (callback) {\n            nextTick(callback.bind(this, err));\n            return;\n          } else throw err;\n        }\n        offset = 4;\n      }\n\n      // Extract number of rounds\n      if (salt.charAt(offset + 2) > \"$\") {\n        err = Error(\"Missing salt rounds\");\n        if (callback) {\n          nextTick(callback.bind(this, err));\n          return;\n        } else throw err;\n      }\n      var r1 = parseInt(salt.substring(offset, offset + 1), 10) * 10,\n        r2 = parseInt(salt.substring(offset + 1, offset + 2), 10),\n        rounds = r1 + r2,\n        real_salt = salt.substring(offset + 3, offset + 25);\n      password += minor >= \"a\" ? \"\\x00\" : \"\";\n      var passwordb = utf8Array(password),\n        saltb = base64_decode(real_salt, BCRYPT_SALT_LEN);\n\n      /**\n       * Finishes hashing.\n       * @param {Array.<number>} bytes Byte array\n       * @returns {string}\n       * @inner\n       */\n      function finish(bytes) {\n        var res = [];\n        res.push(\"$2\");\n        if (minor >= \"a\") res.push(minor);\n        res.push(\"$\");\n        if (rounds < 10) res.push(\"0\");\n        res.push(rounds.toString());\n        res.push(\"$\");\n        res.push(base64_encode(saltb, saltb.length));\n        res.push(base64_encode(bytes, C_ORIG.length * 4 - 1));\n        return res.join(\"\");\n      }\n\n      // Sync\n      if (typeof callback == \"undefined\")\n        return finish(_crypt(passwordb, saltb, rounds));\n      // Async\n      else {\n        _crypt(\n          passwordb,\n          saltb,\n          rounds,\n          function (err, bytes) {\n            if (err) callback(err, null);\n            else callback(null, finish(bytes));\n          },\n          progressCallback,\n        );\n      }\n    }\n\n    /**\n     * Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet.\n     * @function\n     * @param {!Array.<number>} bytes Byte array\n     * @param {number} length Maximum input length\n     * @returns {string}\n     */\n    function encodeBase64(bytes, length) {\n      return base64_encode(bytes, length);\n    }\n\n    /**\n     * Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet.\n     * @function\n     * @param {string} string String to decode\n     * @param {number} length Maximum output length\n     * @returns {!Array.<number>}\n     */\n    function decodeBase64(string, length) {\n      return base64_decode(string, length);\n    }\n    var _default = (_exports.default = {\n      setRandomFallback,\n      genSaltSync,\n      genSalt,\n      hashSync,\n      hash,\n      compareSync,\n      compare,\n      getRounds,\n      getSalt,\n      truncates,\n      encodeBase64,\n      decodeBase64,\n    });\n  },\n);\n"
  },
  {
    "path": "docs/static/js/config-generator.js",
    "content": "// Config Generator for ntfy\n//\n// Warning, AI code\n// ----------------\n// This code is entirely AI generated, but this very comment is not. Phil wrote this. Hi!\n// I felt like the Config Generator was a great feature to have, but it would have taken me forever\n// to write this code without AI. I reviewed the code manually, and it doesn't do anything dangerous.\n// It's not the greatest code, but it works well enough to deliver value, and that's what it's all about.\n//\n// End of human comment. ;)\n//\n// How it works\n// ------------\n// The generator is a modal with a left panel (form inputs) and a right panel (live output).\n// On every input change, the update cycle runs: updateVisibility() syncs the UI state, then\n// updateOutput() collects values from the form and renders them as server.yml, docker-compose.yml,\n// or env vars.\n//\n// The CONFIG array is the source of truth for which config keys exist, their env var names,\n// which section they belong to, and optional defaults. collectValues() walks CONFIG, reads each\n// matching DOM element, skips anything in a hidden panel or section, and returns a plain\n// {key: value} object. The three generators (generateServerYml, generateDockerCompose,\n// generateEnvVars) each iterate CONFIG in order and format the collected values. Provisioned\n// users, ACLs, and tokens are collected separately from repeatable rows and stored as arrays\n// under \"_auth-users\", \"_auth-acls\", \"_auth-tokens\". The formatAuthUsers/Acls/Tokens() helpers\n// turn those arrays into \"user:pass:role\" strings shared by all three generators.\n//\n// Visibility is managed by updateVisibility(), which delegates to five helpers:\n// syncRadiosToHiddenInputs() copies user-facing radios and selects to hidden inputs that CONFIG\n// knows about (e.g. login mode radio → enable-login + require-login checkboxes).\n// updateFeatureVisibility() shows/hides nav tabs, configure buttons, and email sections based\n// on which feature checkboxes are checked. updatePostgresFields() swaps file-path inputs for\n// \"Using PostgreSQL\" labels when PostgreSQL is selected. prefillDefaults() sets sensible values\n// (file paths, addresses) when a feature is first enabled, tracked via a data-cleared attribute\n// so user edits are respected. autoDetectServerType() flips the server-type radio to \"custom\"\n// if the user's access/login settings no longer match \"open\" or \"private\".\n//\n// Event listeners are grouped into setup functions (setupModalEvents, setupAuthEvents,\n// setupServerTypeEvents, setupUnifiedPushEvents, setupFormListeners, setupWebPushEvents)\n// called from initGenerator().\n// A general listener on all inputs calls the update cycle. Specific listeners handle cleanup\n// logic, e.g. unchecking auth resets all auth-related fields and provisioned rows.\n//\n// Frequently-used DOM elements are queried once in cacheElements() and passed around as an\n// `els` object, avoiding repeated querySelector calls.\n//\n// Field inter-dependencies\n// ------------------------\n// Several UI fields don't map 1:1 to config keys. Instead, user-friendly controls drive\n// hidden inputs that CONFIG knows about. The sync happens in syncRadiosToHiddenInputs(),\n// called on every change via updateVisibility().\n//\n//   Server type (Open / Private / Custom)\n//     \"Open\"    → unchecks auth, sets default-access to read-write, login to disabled\n//     \"Private\" → checks auth, sets default-access to deny-all, login to required\n//     \"Custom\"  → no automatic changes; also auto-selected when the user manually\n//                  changes access/login to values that don't match Open or Private\n//\n//   Auth checkbox (#cg-feat-auth)\n//     When unchecked → resets: default-access to read-write, login to disabled,\n//       signup to no, UnifiedPush to no, removes all provisioned users/ACLs/tokens,\n//       clears auth-file, switches server type back to Open.\n//       Also explicitly unchecks hidden enable-login, require-login, enable-signup.\n//     When checked by PostgreSQL auto-enable → no reset, just enables the tab.\n//\n//   Login mode (Disabled / Enabled / Required) — three-way radio\n//     Maps to two hidden checkboxes:\n//       enable-login  = checked when Enabled OR Required\n//       require-login = checked when Required only\n//\n//   Signup (Yes / No) — radio pair\n//     Maps to hidden enable-signup checkbox.\n//\n//   Proxy (Yes / No) — radio pair\n//     Maps to hidden behind-proxy checkbox.\n//\n//   iOS support (Yes / No) — radio pair\n//     Sets upstream-base-url to \"https://ntfy.sh\" when Yes, clears when No.\n//\n//   UnifiedPush (Yes / No) — radio pair\n//     When Yes, enables auth (if not already on) and adds a disabled \"*:up*:write-only\"\n//     ACL row to the Users tab. The row's fields are grayed out and non-editable. It is\n//     collected like any other ACL row. Clicking its [x] removes the row and toggles\n//     UnifiedPush back to No.\n//\n//   Database type (SQLite / PostgreSQL)\n//     When PostgreSQL is selected:\n//       - Auto-enables auth if not already on\n//       - Hides file-path fields (auth-file, cache-file, web-push-file) and shows\n//         \"Using PostgreSQL\" labels instead\n//       - Shows the Database nav tab for the database-url field\n//       - Prefills database-url with a postgres:// template\n//     The database question itself only appears when a DB-dependent feature\n//     (auth, cache, or web push) is enabled.\n//\n//   Feature checkboxes (auth, cache, attachments, web push, email out, email in)\n//     Each shows/hides its nav tab and \"Configure\" button.\n//     When first enabled, prefillDefaults() fills in sensible paths/values.\n//     The prefill is skipped if the user has already typed (or cleared) the field\n//     (tracked via data-cleared attribute).\n//\n(function() {\n  \"use strict\";\n\n  const CONFIG = [\n    { key: \"base-url\", env: \"NTFY_BASE_URL\", section: \"basic\" },\n    { key: \"behind-proxy\", env: \"NTFY_BEHIND_PROXY\", section: \"basic\", type: \"bool\" },\n    { key: \"database-url\", env: \"NTFY_DATABASE_URL\", section: \"database\" },\n    { key: \"auth-file\", env: \"NTFY_AUTH_FILE\", section: \"auth\" },\n    { key: \"auth-default-access\", env: \"NTFY_AUTH_DEFAULT_ACCESS\", section: \"auth\", def: \"read-write\" },\n    { key: \"enable-login\", env: \"NTFY_ENABLE_LOGIN\", section: \"auth\", type: \"bool\" },\n    { key: \"require-login\", env: \"NTFY_REQUIRE_LOGIN\", section: \"auth\", type: \"bool\" },\n    { key: \"enable-signup\", env: \"NTFY_ENABLE_SIGNUP\", section: \"auth\", type: \"bool\" },\n    { key: \"attachment-cache-dir\", env: \"NTFY_ATTACHMENT_CACHE_DIR\", section: \"attach\" },\n    { key: \"attachment-file-size-limit\", env: \"NTFY_ATTACHMENT_FILE_SIZE_LIMIT\", section: \"attach\", def: \"15M\" },\n    { key: \"attachment-total-size-limit\", env: \"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT\", section: \"attach\", def: \"5G\" },\n    { key: \"attachment-expiry-duration\", env: \"NTFY_ATTACHMENT_EXPIRY_DURATION\", section: \"attach\", def: \"3h\" },\n    { key: \"cache-file\", env: \"NTFY_CACHE_FILE\", section: \"cache\" },\n    { key: \"cache-duration\", env: \"NTFY_CACHE_DURATION\", section: \"cache\", def: \"12h\" },\n    { key: \"web-push-public-key\", env: \"NTFY_WEB_PUSH_PUBLIC_KEY\", section: \"webpush\" },\n    { key: \"web-push-private-key\", env: \"NTFY_WEB_PUSH_PRIVATE_KEY\", section: \"webpush\" },\n    { key: \"web-push-file\", env: \"NTFY_WEB_PUSH_FILE\", section: \"webpush\" },\n    { key: \"web-push-email-address\", env: \"NTFY_WEB_PUSH_EMAIL_ADDRESS\", section: \"webpush\" },\n    { key: \"smtp-sender-addr\", env: \"NTFY_SMTP_SENDER_ADDR\", section: \"smtp-out\" },\n    { key: \"smtp-sender-from\", env: \"NTFY_SMTP_SENDER_FROM\", section: \"smtp-out\" },\n    { key: \"smtp-sender-user\", env: \"NTFY_SMTP_SENDER_USER\", section: \"smtp-out\" },\n    { key: \"smtp-sender-pass\", env: \"NTFY_SMTP_SENDER_PASS\", section: \"smtp-out\" },\n    { key: \"smtp-server-listen\", env: \"NTFY_SMTP_SERVER_LISTEN\", section: \"smtp-in\" },\n    { key: \"smtp-server-domain\", env: \"NTFY_SMTP_SERVER_DOMAIN\", section: \"smtp-in\" },\n    { key: \"smtp-server-addr-prefix\", env: \"NTFY_SMTP_SERVER_ADDR_PREFIX\", section: \"smtp-in\" },\n    { key: \"upstream-base-url\", env: \"NTFY_UPSTREAM_BASE_URL\", section: \"upstream\" }\n  ];\n\n  // Feature checkbox → nav tab ID\n  const NAV_MAP = {\n    \"cg-feat-auth\": \"cg-nav-auth\",\n    \"cg-feat-cache\": \"cg-nav-cache\",\n    \"cg-feat-attach\": \"cg-nav-attach\",\n    \"cg-feat-webpush\": \"cg-nav-webpush\"\n  };\n\n  const SECTION_COMMENTS = {\n    basic: \"# Server\",\n    database: \"# Database\",\n    auth: \"# Access control\",\n    attach: \"# Attachments\",\n    cache: \"# Message cache\",\n    webpush: \"# Web push\",\n    \"smtp-out\": \"# Email notifications (outgoing)\",\n    \"smtp-in\": \"# Email publishing (incoming)\",\n    upstream: \"# Upstream\"\n  };\n\n  const durationRegex = /^(\\d+)\\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$/i;\n  const sizeRegex = /^(\\d+)([tgmkb])?$/i;\n\n  // --- DOM cache ---\n\n  function cacheElements(modal) {\n    return {\n      modal,\n      authCheckbox: modal.querySelector(\"#cg-feat-auth\"),\n      cacheCheckbox: modal.querySelector(\"#cg-feat-cache\"),\n      attachCheckbox: modal.querySelector(\"#cg-feat-attach\"),\n      webpushCheckbox: modal.querySelector(\"#cg-feat-webpush\"),\n      smtpOutCheckbox: modal.querySelector(\"#cg-feat-smtp-out\"),\n      smtpInCheckbox: modal.querySelector(\"#cg-feat-smtp-in\"),\n      accessSelect: modal.querySelector(\"#cg-default-access-select\"),\n      accessHidden: modal.querySelector(\"input[type=\\\"hidden\\\"][data-key=\\\"auth-default-access\\\"]\"),\n      loginHidden: modal.querySelector(\"#cg-enable-login-hidden\"),\n      requireLoginHidden: modal.querySelector(\"#cg-require-login-hidden\"),\n      signupHidden: modal.querySelector(\"#cg-enable-signup-hidden\"),\n      proxyCheckbox: modal.querySelector(\"#cg-behind-proxy\"),\n      dbStep: modal.querySelector(\"#cg-wizard-db\"),\n      navDb: modal.querySelector(\"#cg-nav-database\"),\n      navEmail: modal.querySelector(\"#cg-nav-email\"),\n      emailOutSection: modal.querySelector(\"#cg-email-out-section\"),\n      emailInSection: modal.querySelector(\"#cg-email-in-section\"),\n      codeEl: modal.querySelector(\"#cg-code\"),\n      warningsEl: modal.querySelector(\"#cg-warnings\")\n    };\n  }\n\n  // --- Collect values ---\n\n  function collectValues(els) {\n    const { modal } = els;\n    const values = {};\n\n    CONFIG.forEach((c) => {\n      const el = modal.querySelector(`[data-key=\"${c.key}\"]`);\n      if (!el) return;\n\n      // Skip fields in hidden panels (feature not enabled)\n      const panel = el.closest(\".cg-panel\");\n      if (panel) {\n        // Panel hidden directly\n        if (panel.style.display === \"none\" || panel.classList.contains(\"cg-hidden\")) return;\n        // Panel with a nav tab that is hidden (feature not enabled)\n        if (!panel.classList.contains(\"active\")) {\n          const panelId = panel.id;\n          const navTab = modal.querySelector(`[data-panel=\"${panelId}\"]`);\n          if (!navTab || navTab.classList.contains(\"cg-hidden\")) return;\n        }\n      }\n\n      // Skip file inputs replaced by PostgreSQL\n      if (el.dataset.pgDisabled) return;\n\n      // Skip hidden individual fields or sections\n      let ancestor = el.parentElement;\n      while (ancestor && ancestor !== modal) {\n        if (ancestor.style.display === \"none\" || ancestor.classList.contains(\"cg-hidden\")) return;\n        ancestor = ancestor.parentElement;\n      }\n\n      let val;\n      if (c.type === \"bool\") {\n        if (el.checked) val = \"true\";\n      } else {\n        val = el.value.trim();\n        if (!val) return;\n      }\n      if (val && c.def && val === c.def) return;\n      if (val) values[c.key] = val;\n    });\n\n    // Provisioned users\n    const users = collectRepeatableRows(modal, \".cg-auth-user-row\", (row) => {\n      const u = row.querySelector(\"[data-field=\\\"username\\\"]\");\n      const p = row.querySelector(\"[data-field=\\\"password\\\"]\");\n      const r = row.querySelector(\"[data-field=\\\"role\\\"]\");\n      if (u && p && u.value.trim() && p.value.trim()) {\n        return { username: u.value.trim(), password: p.value.trim(), role: r ? r.value : \"user\" };\n      }\n      return null;\n    });\n    if (users.length) values[\"_auth-users\"] = users;\n\n    // Provisioned ACLs\n    const acls = collectRepeatableRows(modal, \".cg-auth-acl-row\", (row) => {\n      const u = row.querySelector(\"[data-field=\\\"username\\\"]\");\n      const t = row.querySelector(\"[data-field=\\\"topic\\\"]\");\n      const p = row.querySelector(\"[data-field=\\\"permission\\\"]\");\n      if (u && t && t.value.trim()) {\n        return { user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : \"read-write\" };\n      }\n      return null;\n    });\n    if (acls.length) values[\"_auth-acls\"] = acls;\n\n    // Provisioned tokens\n    const tokens = collectRepeatableRows(modal, \".cg-auth-token-row\", (row) => {\n      const u = row.querySelector(\"[data-field=\\\"username\\\"]\");\n      const t = row.querySelector(\"[data-field=\\\"token\\\"]\");\n      const l = row.querySelector(\"[data-field=\\\"label\\\"]\");\n      if (u && t && u.value.trim() && t.value.trim()) {\n        return { user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : \"\" };\n      }\n      return null;\n    });\n    if (tokens.length) values[\"_auth-tokens\"] = tokens;\n\n    return values;\n  }\n\n  function collectRepeatableRows(modal, selector, extractor) {\n    const results = [];\n    modal.querySelectorAll(selector).forEach((row) => {\n      const item = extractor(row);\n      if (item) results.push(item);\n    });\n    return results;\n  }\n\n  // --- Shared auth formatting ---\n\n  const bcryptCache = {};\n\n  function hashPassword(username, password) {\n    if (password.startsWith(\"$2\")) return password; // already a bcrypt hash\n    const cacheKey = username + \"\\0\" + password;\n    if (bcryptCache[cacheKey]) return bcryptCache[cacheKey];\n    const hash = (typeof bcrypt !== \"undefined\") ? bcrypt.hashSync(password, 10) : password;\n    bcryptCache[cacheKey] = hash;\n    return hash;\n  }\n\n  function formatAuthUsers(values) {\n    if (!values[\"_auth-users\"]) return null;\n    return values[\"_auth-users\"].map((u) => `${u.username}:${hashPassword(u.username, u.password)}:${u.role}`);\n  }\n\n  function formatAuthAcls(values) {\n    if (!values[\"_auth-acls\"]) return null;\n    return values[\"_auth-acls\"].map((a) => `${a.user || \"*\"}:${a.topic}:${a.permission}`);\n  }\n\n  function formatAuthTokens(values) {\n    if (!values[\"_auth-tokens\"]) return null;\n    return values[\"_auth-tokens\"].map((t) => t.label ? `${t.user}:${t.token}:${t.label}` : `${t.user}:${t.token}`);\n  }\n\n  // --- Output generators ---\n\n  function generateServerYml(values) {\n    const lines = [];\n    let lastSection = \"\";\n    let hadAuth = false;\n\n    CONFIG.forEach((c) => {\n      if (!(c.key in values)) return;\n      if (c.section !== lastSection) {\n        if (lines.length) lines.push(\"\");\n        if (SECTION_COMMENTS[c.section]) lines.push(SECTION_COMMENTS[c.section]);\n        lastSection = c.section;\n      }\n      if (c.section === \"auth\") hadAuth = true;\n      const val = values[c.key];\n      lines.push(c.type === \"bool\" ? `${c.key}: true` : `${c.key}: \"${escapeYamlValue(val)}\"`);\n    });\n\n    // Find insertion point for auth-users/auth-access/auth-tokens:\n    // right after the last \"auth-\" prefixed line, before enable-*/require-* lines\n    let authInsertIdx = lines.length;\n    if (hadAuth) {\n      for (let i = 0; i < lines.length; i++) {\n        if (lines[i] === \"# Access control\") {\n          // Find the last auth-* prefixed key in this section\n          let lastAuthKey = i;\n          for (let j = i + 1; j < lines.length; j++) {\n            if (lines[j].startsWith(\"# \")) break;\n            if (lines[j].startsWith(\"auth-\")) lastAuthKey = j;\n          }\n          authInsertIdx = lastAuthKey + 1;\n          break;\n        }\n      }\n    }\n\n    const authExtra = [];\n    const users = formatAuthUsers(values);\n    if (users) {\n      if (!hadAuth) {\n        authExtra.push(\"\");\n        authExtra.push(\"# Access control\");\n        hadAuth = true;\n      }\n      authExtra.push(\"auth-users:\");\n      users.forEach((entry) => authExtra.push(`  - \"${escapeYamlValue(entry)}\"`));\n    }\n\n    const acls = formatAuthAcls(values);\n    if (acls) {\n      if (!hadAuth) {\n        authExtra.push(\"\");\n        authExtra.push(\"# Access control\");\n        hadAuth = true;\n      }\n      authExtra.push(\"auth-access:\");\n      acls.forEach((entry) => authExtra.push(`  - \"${escapeYamlValue(entry)}\"`));\n    }\n\n    const tokens = formatAuthTokens(values);\n    if (tokens) {\n      if (!hadAuth) {\n        authExtra.push(\"\");\n        authExtra.push(\"# Access control\");\n        hadAuth = true;\n      }\n      authExtra.push(\"auth-tokens:\");\n      tokens.forEach((entry) => authExtra.push(`  - \"${escapeYamlValue(entry)}\"`));\n    }\n\n    // Splice auth extras into the right position\n    if (authExtra.length) {\n      lines.splice(authInsertIdx, 0, ...authExtra);\n    }\n\n    return lines.join(\"\\n\");\n  }\n\n  function generateDockerCompose(values) {\n    const lines = [\n      \"services:\",\n      \"  ntfy:\",\n      \"    image: binwiederhier/ntfy\",\n      \"    command: serve\",\n      \"    environment:\"\n    ];\n\n    let hasDollarNote = false;\n    CONFIG.forEach((c) => {\n      if (!(c.key in values)) return;\n      let val = c.type === \"bool\" ? \"true\" : values[c.key];\n      if (val.includes(\"$\")) {\n        val = val.replace(/\\$/g, \"$$$$\");\n        hasDollarNote = true;\n      }\n      lines.push(`      ${c.env}: \"${escapeYamlValue(val)}\"`);\n    });\n\n    const users = formatAuthUsers(values);\n    if (users) {\n      let usersVal = users.join(\",\");\n      usersVal = usersVal.replace(/\\$/g, \"$$$$\");\n      hasDollarNote = true;\n      lines.push(`      NTFY_AUTH_USERS: \"${escapeYamlValue(usersVal)}\"`);\n    }\n\n    const acls = formatAuthAcls(values);\n    if (acls) {\n      lines.push(`      NTFY_AUTH_ACCESS: \"${escapeYamlValue(acls.join(\",\"))}\"`);\n    }\n\n    const tokens = formatAuthTokens(values);\n    if (tokens) {\n      lines.push(`      NTFY_AUTH_TOKENS: \"${escapeYamlValue(tokens.join(\",\"))}\"`);\n    }\n\n    if (hasDollarNote) {\n      // Insert note after \"environment:\" line\n      const envIdx = lines.indexOf(\"    environment:\");\n      if (envIdx !== -1) {\n        lines.splice(envIdx + 1, 0, \"      # Note: $ is doubled to $$ for docker-compose\");\n      }\n    }\n\n    // Derive volumes from configured file/directory paths\n    const dirs = new Set();\n    [\"auth-file\", \"cache-file\", \"web-push-file\"].forEach((key) => {\n      if (values[key]) {\n        const dir = values[key].substring(0, values[key].lastIndexOf(\"/\"));\n        if (dir) dirs.add(dir);\n      }\n    });\n    if (values[\"attachment-cache-dir\"]) {\n      dirs.add(values[\"attachment-cache-dir\"]);\n    }\n\n    if (dirs.size) {\n      lines.push(\"    volumes:\");\n      [...dirs].sort().forEach((dir) => {\n        lines.push(`      - ${dir}:${dir}`);\n      });\n    }\n\n    lines.push(\n      \"    ports:\",\n      \"      - \\\"80:80\\\"\",\n      \"    restart: unless-stopped\"\n    );\n\n    return lines.join(\"\\n\");\n  }\n\n  function generateEnvVars(values) {\n    const lines = [];\n\n    CONFIG.forEach((c) => {\n      if (!(c.key in values)) return;\n      const val = c.type === \"bool\" ? \"true\" : values[c.key];\n      lines.push(`${c.env}=${escapeShellValue(val)}`);\n    });\n\n    const users = formatAuthUsers(values);\n    if (users) {\n      lines.push(`NTFY_AUTH_USERS=${escapeShellValue(users.join(\",\"))}`);\n    }\n\n    const acls = formatAuthAcls(values);\n    if (acls) {\n      lines.push(`NTFY_AUTH_ACCESS=${escapeShellValue(acls.join(\",\"))}`);\n    }\n\n    const tokens = formatAuthTokens(values);\n    if (tokens) {\n      lines.push(`NTFY_AUTH_TOKENS=${escapeShellValue(tokens.join(\",\"))}`);\n    }\n\n    return lines.join(\"\\n\");\n  }\n\n  // --- Web Push VAPID key generation (P-256 ECDH) ---\n\n  function generateVAPIDKeys() {\n    return crypto.subtle.generateKey(\n      { name: \"ECDH\", namedCurve: \"P-256\" },\n      true,\n      [\"deriveBits\"]\n    ).then((keyPair) => {\n      return Promise.all([\n        crypto.subtle.exportKey(\"raw\", keyPair.publicKey),\n        crypto.subtle.exportKey(\"pkcs8\", keyPair.privateKey)\n      ]);\n    }).then((keys) => {\n      const pubBytes = new Uint8Array(keys[0]);\n      const privPkcs8 = new Uint8Array(keys[1]);\n      // Extract raw 32-byte private key from PKCS#8 (last 32 bytes of the DER)\n      const privBytes = privPkcs8.slice(privPkcs8.length - 32);\n      return {\n        publicKey: arrayToBase64Url(pubBytes),\n        privateKey: arrayToBase64Url(privBytes)\n      };\n    });\n  }\n\n  function arrayToBase64Url(arr) {\n    let str = \"\";\n    for (let i = 0; i < arr.length; i++) {\n      str += String.fromCharCode(arr[i]);\n    }\n    return btoa(str).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n  }\n\n  // --- Output + validation ---\n\n  function updateOutput(els) {\n    const { modal, codeEl, warningsEl } = els;\n    if (!codeEl) return;\n\n    const values = collectValues(els);\n    const activeTab = modal.querySelector(\".cg-output-tab.active\");\n    const format = activeTab ? activeTab.getAttribute(\"data-format\") : \"server-yml\";\n\n    const hasValues = Object.keys(values).length > 0;\n    if (!hasValues) {\n      codeEl.innerHTML = \"<span class=\\\"cg-empty-msg\\\">Configure options on the left to generate your config...</span>\";\n      setHidden(warningsEl, true);\n      return;\n    }\n\n    let output;\n    if (format === \"docker-compose\") {\n      output = generateDockerCompose(values);\n    } else if (format === \"env-vars\") {\n      output = generateEnvVars(values);\n    } else {\n      output = generateServerYml(values);\n    }\n\n    codeEl.textContent = output;\n\n    // Validation warnings\n    const warnings = validate(values);\n    if (warningsEl) {\n      if (warnings.length) {\n        warningsEl.innerHTML = warnings.map((w) => `<div class=\"cg-warning\">${w}</div>`).join(\"\");\n      }\n      setHidden(warningsEl, !warnings.length);\n    }\n  }\n\n  function validate(values) {\n    const warnings = [];\n    const baseUrl = values[\"base-url\"] || \"\";\n\n    // base-url format\n    if (baseUrl) {\n      if (!baseUrl.startsWith(\"http://\") && !baseUrl.startsWith(\"https://\")) {\n        warnings.push(\"base-url must start with http:// or https://\");\n      } else {\n        try {\n          const u = new URL(baseUrl);\n          if (u.pathname !== \"/\" && u.pathname !== \"\") {\n            warnings.push(\"base-url must not have a path, ntfy does not support sub-paths\");\n          }\n        } catch (e) {\n          warnings.push(\"base-url is not a valid URL\");\n        }\n      }\n    }\n\n    // database-url must start with postgres://\n    if (values[\"database-url\"] && !values[\"database-url\"].startsWith(\"postgres://\")) {\n      warnings.push(\"database-url must start with postgres://\");\n    }\n\n    // Web push requires all fields + base-url\n    const wpPublic = values[\"web-push-public-key\"];\n    const wpPrivate = values[\"web-push-private-key\"];\n    const wpEmail = values[\"web-push-email-address\"];\n    const wpFile = values[\"web-push-file\"];\n    const dbUrl = values[\"database-url\"];\n    if (wpPublic || wpPrivate || wpEmail) {\n      const missing = [];\n      if (!wpPublic) missing.push(\"web-push-public-key\");\n      if (!wpPrivate) missing.push(\"web-push-private-key\");\n      if (!wpFile && !dbUrl) missing.push(\"web-push-file or database-url\");\n      if (!wpEmail) missing.push(\"web-push-email-address\");\n      if (!baseUrl) missing.push(\"base-url\");\n      if (missing.length) {\n        warnings.push(`Web push requires: ${missing.join(\", \")}`);\n      }\n    }\n\n    // SMTP sender requires base-url and smtp-sender-from\n    if (values[\"smtp-sender-addr\"]) {\n      const smtpMissing = [];\n      if (!baseUrl) smtpMissing.push(\"base-url\");\n      if (!values[\"smtp-sender-from\"]) smtpMissing.push(\"smtp-sender-from\");\n      if (smtpMissing.length) {\n        warnings.push(`Email sending requires: ${smtpMissing.join(\", \")}`);\n      }\n    }\n\n    // SMTP server requires domain\n    if (values[\"smtp-server-listen\"] && !values[\"smtp-server-domain\"]) {\n      warnings.push(\"Email publishing requires smtp-server-domain\");\n    }\n\n    // Attachments require base-url\n    if (values[\"attachment-cache-dir\"] && !baseUrl) {\n      warnings.push(\"Attachments require base-url to be set\");\n    }\n\n    // Upstream requires base-url and can't equal it\n    if (values[\"upstream-base-url\"]) {\n      if (!baseUrl) {\n        warnings.push(\"Upstream server requires base-url to be set\");\n      } else if (baseUrl === values[\"upstream-base-url\"]) {\n        warnings.push(\"base-url and upstream-base-url cannot be the same\");\n      }\n    }\n\n    // enable-signup requires enable-login\n    if (values[\"enable-signup\"] && !values[\"enable-login\"]) {\n      warnings.push(\"Enable signup requires enable-login to also be set\");\n    }\n\n    // Duration field validation\n    [\n      { key: \"cache-duration\", label: \"Cache duration\" },\n      { key: \"attachment-expiry-duration\", label: \"Attachment expiry duration\" }\n    ].forEach((f) => {\n      if (values[f.key] && !durationRegex.test(values[f.key])) {\n        warnings.push(`${f.label} must be a valid duration (e.g. 12h, 3d, 30m, 60s)`);\n      }\n    });\n\n    // Size field validation\n    [\n      { key: \"attachment-file-size-limit\", label: \"Attachment file size limit\" },\n      { key: \"attachment-total-size-limit\", label: \"Attachment total size limit\" }\n    ].forEach((f) => {\n      if (values[f.key] && !sizeRegex.test(values[f.key])) {\n        warnings.push(`${f.label} must be a valid size (e.g. 15M, 5G, 100K)`);\n      }\n    });\n\n    return warnings;\n  }\n\n  // --- Helpers ---\n\n  function secureRandomInt(max) {\n    const arr = new Uint32Array(1);\n    crypto.getRandomValues(arr);\n    return arr[0] % max;\n  }\n\n  function generateToken() {\n    const chars = \"abcdefghijklmnopqrstuvwxyz0123456789\";\n    let token = \"tk_\";\n    for (let i = 0; i < 29; i++) {\n      token += chars.charAt(secureRandomInt(chars.length));\n    }\n    return token;\n  }\n\n  function generatePassword() {\n    const chars = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n    let password = \"\";\n    for (let i = 0; i < 16; i++) {\n      password += chars.charAt(secureRandomInt(chars.length));\n    }\n    return password;\n  }\n\n  function escapeHtml(str) {\n    return str.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n  }\n\n  function escapeYamlValue(str) {\n    return str.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n  }\n\n  function escapeShellValue(val) {\n    // Use single quotes for values with $, double quotes otherwise\n    // Escape the chosen quote character within the value\n    if (val.includes(\"$\")) {\n      return \"'\" + val.replace(/'/g, \"'\\\\''\") + \"'\";\n    }\n    return '\"' + val.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"') + '\"';\n  }\n\n  function prefill(modal, key, value) {\n    const el = modal.querySelector(`[data-key=\"${key}\"]`);\n    if (el && !el.value.trim() && !el.dataset.cleared) el.value = value;\n  }\n\n  function switchPanel(modal, panelId) {\n    modal.querySelectorAll(\".cg-nav-tab\").forEach((t) => t.classList.remove(\"active\"));\n    modal.querySelectorAll(\".cg-panel\").forEach((p) => p.classList.remove(\"active\"));\n\n    const navTab = modal.querySelector(`[data-panel=\"${panelId}\"]`);\n    const panel = modal.querySelector(`#${panelId}`);\n    if (navTab) navTab.classList.add(\"active\");\n    if (panel) panel.classList.add(\"active\");\n  }\n\n  function setHidden(el, hidden) {\n    if (!el) return;\n    if (hidden) {\n      el.classList.add(\"cg-hidden\");\n    } else {\n      el.classList.remove(\"cg-hidden\");\n    }\n  }\n\n  // --- Visibility: broken into focused helpers ---\n\n  function syncRadiosToHiddenInputs(els) {\n    const { modal, accessSelect, accessHidden, loginHidden, requireLoginHidden, signupHidden, proxyCheckbox } = els;\n\n    // Proxy radio → hidden checkbox\n    const proxyYes = modal.querySelector(\"input[name=\\\"cg-proxy\\\"][value=\\\"yes\\\"]\");\n    if (proxyYes && proxyCheckbox) {\n      proxyCheckbox.checked = proxyYes.checked;\n    }\n\n    // Default access select → hidden input\n    if (accessSelect && accessHidden) {\n      accessHidden.value = accessSelect.value;\n    }\n\n    // Login mode three-way toggle → hidden checkboxes\n    const loginMode = modal.querySelector(\"input[name=\\\"cg-login-mode\\\"]:checked\");\n    const loginModeVal = loginMode ? loginMode.value : \"disabled\";\n    if (loginHidden) loginHidden.checked = (loginModeVal === \"enabled\" || loginModeVal === \"required\");\n    if (requireLoginHidden) requireLoginHidden.checked = (loginModeVal === \"required\");\n\n    const signupYes = modal.querySelector(\"input[name=\\\"cg-enable-signup\\\"][value=\\\"yes\\\"]\");\n    if (signupYes && signupHidden) signupHidden.checked = signupYes.checked;\n\n    return loginModeVal;\n  }\n\n  function updateFeatureVisibility(els, flags) {\n    const { modal, dbStep, navDb, navEmail, emailOutSection, emailInSection } = els;\n    const { authEnabled, cacheEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb, isPostgres } = flags;\n\n    // Show database question only if a DB-dependent feature is selected\n    setHidden(dbStep, !needsDb);\n\n    // Nav tabs for features\n    for (const featId in NAV_MAP) {\n      const checkbox = modal.querySelector(`#${featId}`);\n      const navTab = modal.querySelector(`#${NAV_MAP[featId]}`);\n      if (checkbox && navTab) {\n        setHidden(navTab, !checkbox.checked);\n      }\n    }\n\n    // Email tab — show if either outgoing or incoming is enabled\n    setHidden(navEmail, !smtpOutEnabled && !smtpInEnabled);\n    setHidden(emailOutSection, !smtpOutEnabled);\n    setHidden(emailInSection, !smtpInEnabled);\n\n    // Show/hide configure buttons next to feature checkboxes\n    modal.querySelectorAll(\".cg-btn-configure\").forEach((btn) => {\n      const row = btn.closest(\".cg-feature-row\");\n      if (!row) return;\n      const cb = row.querySelector(\"input[type=\\\"checkbox\\\"]\");\n      setHidden(btn, !(cb && cb.checked));\n    });\n\n    // If active nav tab got hidden, switch to General\n    const activeNav = modal.querySelector(\".cg-nav-tab.active\");\n    if (activeNav && activeNav.classList.contains(\"cg-hidden\")) {\n      switchPanel(modal, \"cg-panel-general\");\n    }\n\n    // Database tab — show only when PostgreSQL is selected and a DB-dependent feature is on\n    setHidden(navDb, !(needsDb && isPostgres));\n  }\n\n  function updatePostgresFields(modal, isPostgres) {\n    // Show \"Using PostgreSQL\" instead of file inputs when PostgreSQL is selected\n    [\"auth-file\", \"web-push-file\", \"cache-file\"].forEach((key) => {\n      const input = modal.querySelector(`[data-key=\"${key}\"]`);\n      if (!input) return;\n      const field = input.closest(\".cg-field\");\n      if (!field) return;\n      input.style.display = isPostgres ? \"none\" : \"\";\n      if (isPostgres) {\n        input.dataset.pgDisabled = \"1\";\n      } else {\n        delete input.dataset.pgDisabled;\n      }\n      let pgLabel = field.querySelector(\".cg-pg-label\");\n      if (isPostgres) {\n        if (!pgLabel) {\n          pgLabel = document.createElement(\"span\");\n          pgLabel.className = \"cg-pg-label\";\n          pgLabel.textContent = \"Using PostgreSQL\";\n          input.parentNode.insertBefore(pgLabel, input.nextSibling);\n        }\n        pgLabel.style.display = \"\";\n      } else if (pgLabel) {\n        pgLabel.style.display = \"none\";\n      }\n    });\n\n    // iOS question → upstream-base-url\n    const iosYes = modal.querySelector(\"input[name=\\\"cg-ios\\\"][value=\\\"yes\\\"]\");\n    const upstreamInput = modal.querySelector(\"[data-key=\\\"upstream-base-url\\\"]\");\n    if (iosYes && upstreamInput) {\n      upstreamInput.value = iosYes.checked ? \"https://ntfy.sh\" : \"\";\n    }\n  }\n\n  function prefillDefaults(modal, flags) {\n    const {\n      isPostgres,\n      authEnabled,\n      cacheEnabled,\n      attachEnabled,\n      webpushEnabled,\n      smtpOutEnabled,\n      smtpInEnabled\n    } = flags;\n\n    if (isPostgres) {\n      prefill(modal, \"database-url\", \"postgres://user:pass@host:5432/ntfy\");\n    }\n\n    if (authEnabled) {\n      if (!isPostgres) prefill(modal, \"auth-file\", \"/var/lib/ntfy/auth.db\");\n    }\n\n    if (cacheEnabled) {\n      if (!isPostgres) prefill(modal, \"cache-file\", \"/var/cache/ntfy/cache.db\");\n    }\n\n    if (attachEnabled) {\n      prefill(modal, \"attachment-cache-dir\", \"/var/cache/ntfy/attachments\");\n    }\n\n    if (webpushEnabled) {\n      if (!isPostgres) prefill(modal, \"web-push-file\", \"/var/lib/ntfy/webpush.db\");\n      prefill(modal, \"web-push-email-address\", \"admin@example.com\");\n    }\n\n    if (smtpOutEnabled) {\n      prefill(modal, \"smtp-sender-addr\", \"smtp.example.com:587\");\n      prefill(modal, \"smtp-sender-from\", \"ntfy@example.com\");\n      prefill(modal, \"smtp-sender-user\", \"yoursmtpuser\");\n      prefill(modal, \"smtp-sender-pass\", \"yoursmtppass\");\n    }\n\n    if (smtpInEnabled) {\n      prefill(modal, \"smtp-server-listen\", \":25\");\n      prefill(modal, \"smtp-server-domain\", \"ntfy.example.com\");\n    }\n  }\n\n  function autoDetectServerType(els, loginModeVal) {\n    const { modal, accessSelect } = els;\n    const serverTypeRadio = modal.querySelector(\"input[name=\\\"cg-server-type\\\"]:checked\");\n    const serverType = serverTypeRadio ? serverTypeRadio.value : \"open\";\n\n    if (serverType !== \"custom\") {\n      const currentAccess = accessSelect ? accessSelect.value : \"read-write\";\n      const currentLoginEnabled = loginModeVal !== \"disabled\";\n      const matchesOpen = currentAccess === \"read-write\" && !currentLoginEnabled;\n      const matchesPrivate = currentAccess === \"deny-all\" && currentLoginEnabled;\n      if (!matchesOpen && !matchesPrivate) {\n        const customRadio = modal.querySelector(\"input[name=\\\"cg-server-type\\\"][value=\\\"custom\\\"]\");\n        if (customRadio) customRadio.checked = true;\n      }\n    }\n  }\n\n  function updateVisibility(els) {\n    const {\n      modal,\n      authCheckbox,\n      cacheCheckbox,\n      attachCheckbox,\n      webpushCheckbox,\n      smtpOutCheckbox,\n      smtpInCheckbox\n    } = els;\n\n    const isPostgresRadio = modal.querySelector(\"input[name=\\\"cg-db-type\\\"][value=\\\"postgres\\\"]\");\n    const isPostgres = isPostgresRadio && isPostgresRadio.checked;\n\n    // Auto-enable auth when PostgreSQL is selected\n    if (isPostgres && authCheckbox && !authCheckbox.checked) {\n      authCheckbox.checked = true;\n    }\n\n    const authEnabled = authCheckbox && authCheckbox.checked;\n    const cacheEnabled = cacheCheckbox && cacheCheckbox.checked;\n    const attachEnabled = attachCheckbox && attachCheckbox.checked;\n    const webpushEnabled = webpushCheckbox && webpushCheckbox.checked;\n    const smtpOutEnabled = smtpOutCheckbox && smtpOutCheckbox.checked;\n    const smtpInEnabled = smtpInCheckbox && smtpInCheckbox.checked;\n    const needsDb = authEnabled || cacheEnabled || webpushEnabled;\n\n    const flags = {\n      isPostgres,\n      authEnabled,\n      cacheEnabled,\n      attachEnabled,\n      webpushEnabled,\n      smtpOutEnabled,\n      smtpInEnabled,\n      needsDb\n    };\n\n    const loginModeVal = syncRadiosToHiddenInputs(els);\n    updateFeatureVisibility(els, flags);\n    updatePostgresFields(modal, isPostgres);\n    prefillDefaults(modal, flags);\n    autoDetectServerType(els, loginModeVal);\n  }\n\n  // --- Repeatable rows ---\n\n  function addRepeatableRow(container, type, onUpdate) {\n    const row = document.createElement(\"div\");\n    row.className = `cg-repeatable-row cg-auth-${type}-row`;\n\n    if (type === \"user\") {\n      const username = `newuser${secureRandomInt(100) + 1}`;\n      row.innerHTML =\n        `<input type=\"text\" data-field=\"username\" placeholder=\"Username\" value=\"${escapeHtml(username)}\">` +\n        `<input type=\"text\" data-field=\"password\" placeholder=\"Password\" value=\"${escapeHtml(generatePassword())}\">` +\n        \"<select data-field=\\\"role\\\"><option value=\\\"user\\\">User</option><option value=\\\"admin\\\">Admin</option></select>\" +\n        \"<button type=\\\"button\\\" class=\\\"cg-btn-remove\\\" title=\\\"Remove\\\">&times;</button>\";\n    } else if (type === \"acl\") {\n      let aclUser = `someuser${secureRandomInt(100) + 1}`;\n      const modal = container.closest(\".cg-modal\");\n      if (modal) {\n        const userRows = modal.querySelectorAll(\".cg-auth-user-row\");\n        for (const ur of userRows) {\n          const role = ur.querySelector(\"[data-field=\\\"role\\\"]\");\n          const name = ur.querySelector(\"[data-field=\\\"username\\\"]\");\n          if (role && role.value !== \"admin\" && name && name.value.trim()) {\n            aclUser = name.value.trim();\n            break;\n          }\n        }\n      }\n      row.innerHTML =\n        `<input type=\"text\" data-field=\"username\" placeholder=\"Username (* for everyone)\" value=\"${escapeHtml(aclUser)}\">` +\n        \"<input type=\\\"text\\\" data-field=\\\"topic\\\" placeholder=\\\"Topic pattern\\\" value=\\\"sometopic*\\\">\" +\n        \"<select data-field=\\\"permission\\\"><option value=\\\"read-write\\\">Read &amp; Write</option><option value=\\\"read-only\\\">Read Only</option><option value=\\\"write-only\\\">Write Only</option><option value=\\\"deny\\\">Deny</option></select>\" +\n        \"<button type=\\\"button\\\" class=\\\"cg-btn-remove\\\" title=\\\"Remove\\\">&times;</button>\";\n    } else if (type === \"token\") {\n      let tokenUser = \"\";\n      const modal = container.closest(\".cg-modal\");\n      if (modal) {\n        const firstRow = modal.querySelector(\".cg-auth-user-row\");\n        const name = firstRow ? firstRow.querySelector(\"[data-field=\\\"username\\\"]\") : null;\n        if (name && name.value.trim()) tokenUser = name.value.trim();\n      }\n      row.innerHTML =\n        `<input type=\"text\" data-field=\"username\" placeholder=\"Username\" value=\"${escapeHtml(tokenUser)}\">` +\n        `<input type=\"text\" data-field=\"token\" placeholder=\"Token\" value=\"${escapeHtml(generateToken())}\">` +\n        \"<input type=\\\"text\\\" data-field=\\\"label\\\" placeholder=\\\"Label (optional)\\\">\" +\n        \"<button type=\\\"button\\\" class=\\\"cg-btn-remove\\\" title=\\\"Remove\\\">&times;</button>\";\n    }\n\n    row.querySelector(\".cg-btn-remove\").addEventListener(\"click\", () => {\n      row.remove();\n      onUpdate();\n    });\n    row.querySelectorAll(\"input, select\").forEach((el) => {\n      el.addEventListener(\"input\", onUpdate);\n    });\n\n    container.appendChild(row);\n  }\n\n  // --- Modal functions (module-level) ---\n\n  function openModal(els) {\n    els.modal.style.display = \"\";\n    document.body.style.overflow = \"hidden\";\n    updateVisibility(els);\n    updateOutput(els);\n  }\n\n  function closeModal(els) {\n    els.modal.style.display = \"none\";\n    document.body.style.overflow = \"\";\n  }\n\n  function resetAll(els) {\n    const { modal } = els;\n\n    // Reset all text/password inputs and clear flags\n    modal.querySelectorAll(\"input[type=\\\"text\\\"], input[type=\\\"password\\\"]\").forEach((el) => {\n      el.value = \"\";\n      delete el.dataset.cleared;\n    });\n    // Uncheck all checkboxes\n    modal.querySelectorAll(\"input[type=\\\"checkbox\\\"]\").forEach((el) => {\n      el.checked = false;\n      el.disabled = false;\n    });\n    // Reset radio buttons to first option\n    const radioGroups = {};\n    modal.querySelectorAll(\"input[type=\\\"radio\\\"]\").forEach((el) => {\n      if (!radioGroups[el.name]) {\n        radioGroups[el.name] = true;\n        const first = modal.querySelector(`input[type=\"radio\"][name=\"${el.name}\"]`);\n        if (first) first.checked = true;\n      } else {\n        el.checked = false;\n      }\n    });\n    // Reset selects to first option\n    modal.querySelectorAll(\"select\").forEach((el) => {\n      el.selectedIndex = 0;\n    });\n    // Remove all repeatable rows\n    modal.querySelectorAll(\".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row\").forEach((row) => {\n      row.remove();\n    });\n    // Re-prefill base-url\n    const baseUrlInput = modal.querySelector(\"[data-key=\\\"base-url\\\"]\");\n    if (baseUrlInput) {\n      baseUrlInput.value = \"https://ntfy.example.com\";\n    }\n    // Reset to General tab\n    switchPanel(modal, \"cg-panel-general\");\n    updateVisibility(els);\n    updateOutput(els);\n  }\n\n  function fillVAPIDKeys(els) {\n    const { modal } = els;\n    generateVAPIDKeys().then((keys) => {\n      const pubInput = modal.querySelector(\"[data-key=\\\"web-push-public-key\\\"]\");\n      const privInput = modal.querySelector(\"[data-key=\\\"web-push-private-key\\\"]\");\n      if (pubInput) pubInput.value = keys.publicKey;\n      if (privInput) privInput.value = keys.privateKey;\n      updateOutput(els);\n    });\n  }\n\n  // --- Event setup (grouped) ---\n\n  function setupModalEvents(els) {\n    const { modal } = els;\n    const openBtn = document.getElementById(\"cg-open-btn\");\n    const closeBtn = document.getElementById(\"cg-close-btn\");\n    const backdrop = modal.querySelector(\".cg-modal-backdrop\");\n    const resetBtn = document.getElementById(\"cg-reset-btn\");\n\n    if (openBtn) openBtn.addEventListener(\"click\", () => openModal(els));\n    if (closeBtn) closeBtn.addEventListener(\"click\", () => closeModal(els));\n    if (resetBtn) resetBtn.addEventListener(\"click\", () => resetAll(els));\n    if (backdrop) backdrop.addEventListener(\"click\", () => closeModal(els));\n\n    document.addEventListener(\"keydown\", (e) => {\n      if (e.key === \"Escape\" && modal.style.display !== \"none\") {\n        closeModal(els);\n      }\n    });\n\n    // Mobile toggle between Edit and Preview panels\n    const toggleBtns = modal.querySelectorAll(\".cg-mobile-toggle-btn\");\n    const leftPanel = document.getElementById(\"cg-left\");\n    const rightPanel = document.getElementById(\"cg-right\");\n    toggleBtns.forEach((btn) => {\n      btn.addEventListener(\"click\", () => {\n        toggleBtns.forEach((b) => b.classList.remove(\"active\"));\n        btn.classList.add(\"active\");\n        if (btn.dataset.show === \"right\") {\n          leftPanel.classList.add(\"cg-mobile-hidden\");\n          rightPanel.classList.add(\"cg-mobile-active\");\n        } else {\n          leftPanel.classList.remove(\"cg-mobile-hidden\");\n          rightPanel.classList.remove(\"cg-mobile-active\");\n        }\n      });\n    });\n  }\n\n  function setupAuthEvents(els) {\n    const { modal, authCheckbox, accessSelect } = els;\n    if (!authCheckbox) return;\n\n    // Auth checkbox: clean up when unchecked\n    authCheckbox.addEventListener(\"change\", () => {\n      if (!authCheckbox.checked) {\n        // Clear auth-file\n        const authFile = modal.querySelector(\"[data-key=\\\"auth-file\\\"]\");\n        if (authFile) {\n          authFile.value = \"\";\n          delete authFile.dataset.cleared;\n        }\n        // Reset default access\n        if (accessSelect) accessSelect.value = \"read-write\";\n        // Reset login mode to Disabled and unset hidden checkboxes\n        const loginDisabled = modal.querySelector(\"input[name=\\\"cg-login-mode\\\"][value=\\\"disabled\\\"]\");\n        if (loginDisabled) loginDisabled.checked = true;\n        if (els.loginHidden) els.loginHidden.checked = false;\n        if (els.requireLoginHidden) els.requireLoginHidden.checked = false;\n        const signupNo = modal.querySelector(\"input[name=\\\"cg-enable-signup\\\"][value=\\\"no\\\"]\");\n        if (signupNo) signupNo.checked = true;\n        if (els.signupHidden) els.signupHidden.checked = false;\n        // Reset UnifiedPush to No\n        const upNo = modal.querySelector(\"input[name=\\\"cg-unifiedpush\\\"][value=\\\"no\\\"]\");\n        if (upNo) upNo.checked = true;\n        // Remove provisioned users/ACLs/tokens\n        modal.querySelectorAll(\".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row\").forEach((row) => {\n          row.remove();\n        });\n        // Switch server type to Open\n        const openRadio = modal.querySelector(\"input[name=\\\"cg-server-type\\\"][value=\\\"open\\\"]\");\n        if (openRadio) openRadio.checked = true;\n      }\n    });\n  }\n\n  function setupServerTypeEvents(els) {\n    const { modal, authCheckbox, accessSelect } = els;\n\n    modal.querySelectorAll(\"input[name=\\\"cg-server-type\\\"]\").forEach((radio) => {\n      radio.addEventListener(\"change\", () => {\n        const loginDisabledRadio = modal.querySelector(\"input[name=\\\"cg-login-mode\\\"][value=\\\"disabled\\\"]\");\n        const loginRequiredRadio = modal.querySelector(\"input[name=\\\"cg-login-mode\\\"][value=\\\"required\\\"]\");\n        if (radio.value === \"open\") {\n          if (accessSelect) accessSelect.value = \"read-write\";\n          if (loginDisabledRadio) loginDisabledRadio.checked = true;\n          if (authCheckbox) authCheckbox.checked = false;\n          // Trigger the auth cleanup\n          authCheckbox.dispatchEvent(new Event(\"change\"));\n        } else if (radio.value === \"private\") {\n          // Enable auth with required login\n          if (authCheckbox) authCheckbox.checked = true;\n          if (accessSelect) accessSelect.value = \"deny-all\";\n          if (loginRequiredRadio) loginRequiredRadio.checked = true;\n          if (els.loginHidden) els.loginHidden.checked = true;\n          if (els.requireLoginHidden) els.requireLoginHidden.checked = true;\n          // Add default admin user if no users exist\n          const usersContainer = modal.querySelector(\"#cg-auth-users-container\");\n          if (usersContainer && !usersContainer.querySelector(\".cg-auth-user-row\")) {\n            const onUpdate = () => {\n              updateVisibility(els);\n              updateOutput(els);\n            };\n            addRepeatableRow(usersContainer, \"user\", onUpdate);\n            const adminRow = usersContainer.querySelector(\".cg-auth-user-row:last-child\");\n            if (adminRow) {\n              const u = adminRow.querySelector(\"[data-field=\\\"username\\\"]\");\n              const p = adminRow.querySelector(\"[data-field=\\\"password\\\"]\");\n              const r = adminRow.querySelector(\"[data-field=\\\"role\\\"]\");\n              if (u) u.value = \"ntfyadmin\";\n              if (p) p.value = generatePassword();\n              if (r) r.value = \"admin\";\n            }\n            addRepeatableRow(usersContainer, \"user\", onUpdate);\n            const userRow = usersContainer.querySelector(\".cg-auth-user-row:last-child\");\n            if (userRow) {\n              const u = userRow.querySelector(\"[data-field=\\\"username\\\"]\");\n              const p = userRow.querySelector(\"[data-field=\\\"password\\\"]\");\n              if (u) u.value = \"ntfyuser\";\n              if (p) p.value = generatePassword();\n            }\n          }\n        }\n        // \"custom\" doesn't change anything\n      });\n    });\n  }\n\n  function setupUnifiedPushEvents(els) {\n    const { modal } = els;\n    const onUpdate = () => {\n      updateVisibility(els);\n      updateOutput(els);\n    };\n\n    modal.querySelectorAll(\"input[name=\\\"cg-unifiedpush\\\"]\").forEach((radio) => {\n      radio.addEventListener(\"change\", () => {\n        const aclsContainer = modal.querySelector(\"#cg-auth-acls-container\");\n        if (!aclsContainer) return;\n        const existing = aclsContainer.querySelector(\".cg-auth-acl-row-up\");\n        if (radio.value === \"yes\" && radio.checked && !existing) {\n          // Enable auth if not already enabled\n          if (els.authCheckbox && !els.authCheckbox.checked) {\n            els.authCheckbox.checked = true;\n          }\n          // Add a disabled UnifiedPush ACL row\n          const row = document.createElement(\"div\");\n          row.className = \"cg-repeatable-row cg-auth-acl-row cg-auth-acl-row-up\";\n          row.innerHTML =\n            \"<input type=\\\"text\\\" data-field=\\\"username\\\" value=\\\"*\\\" disabled>\" +\n            \"<input type=\\\"text\\\" data-field=\\\"topic\\\" value=\\\"up*\\\" disabled>\" +\n            \"<select data-field=\\\"permission\\\" disabled><option value=\\\"write-only\\\">Write Only</option></select>\" +\n            \"<button type=\\\"button\\\" class=\\\"cg-btn-remove\\\" title=\\\"Removing this ACL entry will disable UnifiedPush support\\\">&times;</button>\";\n          row.querySelector(\".cg-btn-remove\").addEventListener(\"click\", () => {\n            row.remove();\n            const upNo = modal.querySelector(\"input[name=\\\"cg-unifiedpush\\\"][value=\\\"no\\\"]\");\n            if (upNo) upNo.checked = true;\n            onUpdate();\n          });\n          // Insert at the beginning\n          aclsContainer.insertBefore(row, aclsContainer.firstChild);\n          onUpdate();\n        } else if (radio.value === \"no\" && radio.checked && existing) {\n          existing.remove();\n          onUpdate();\n        }\n      });\n    });\n  }\n\n  function setupFormListeners(els) {\n    const { modal } = els;\n    const onUpdate = () => {\n      updateVisibility(els);\n      updateOutput(els);\n    };\n\n    // Left nav tab switching\n    modal.querySelectorAll(\".cg-nav-tab\").forEach((tab) => {\n      tab.addEventListener(\"click\", () => {\n        const panelId = tab.getAttribute(\"data-panel\");\n        switchPanel(modal, panelId);\n      });\n    });\n\n    // Configure buttons in feature grid\n    modal.querySelectorAll(\".cg-btn-configure\").forEach((btn) => {\n      btn.addEventListener(\"click\", () => {\n        const panelId = btn.getAttribute(\"data-panel\");\n        if (panelId) switchPanel(modal, panelId);\n      });\n    });\n\n    // Output format tab switching\n    modal.querySelectorAll(\".cg-output-tab\").forEach((tab) => {\n      tab.addEventListener(\"click\", () => {\n        modal.querySelectorAll(\".cg-output-tab\").forEach((t) => t.classList.remove(\"active\"));\n        tab.classList.add(\"active\");\n        updateOutput(els);\n      });\n    });\n\n    // All form inputs trigger update\n    modal.querySelectorAll(\"input, select\").forEach((el) => {\n      const evt = (el.type === \"checkbox\" || el.type === \"radio\") ? \"change\" : \"input\";\n      el.addEventListener(evt, () => {\n        // Mark text fields as cleared when user empties them\n        if ((el.type === \"text\" || el.type === \"password\") && el.dataset.key && !el.value.trim()) {\n          el.dataset.cleared = \"1\";\n        } else if ((el.type === \"text\" || el.type === \"password\") && el.dataset.key && el.value.trim()) {\n          delete el.dataset.cleared;\n        }\n        onUpdate();\n      });\n    });\n\n    // Add buttons for repeatable rows\n    modal.querySelectorAll(\".cg-btn-add[data-add-type]\").forEach((btn) => {\n      btn.addEventListener(\"click\", () => {\n        const type = btn.getAttribute(\"data-add-type\");\n        let container = btn.previousElementSibling;\n        if (!container) container = btn.parentElement.querySelector(\".cg-repeatable-container\");\n        addRepeatableRow(container, type, onUpdate);\n        onUpdate();\n      });\n    });\n\n    // Copy button\n    const copyBtn = modal.querySelector(\"#cg-copy-btn\");\n    if (copyBtn) {\n      const copyIcon = \"<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"14\\\" height=\\\"14\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"none\\\" stroke=\\\"currentColor\\\" stroke-width=\\\"2\\\" stroke-linecap=\\\"round\\\" stroke-linejoin=\\\"round\\\"><rect x=\\\"9\\\" y=\\\"9\\\" width=\\\"13\\\" height=\\\"13\\\" rx=\\\"2\\\" ry=\\\"2\\\"></rect><path d=\\\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\\\"></path></svg>\";\n      const checkIcon = \"<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"14\\\" height=\\\"14\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"none\\\" stroke=\\\"currentColor\\\" stroke-width=\\\"2\\\" stroke-linecap=\\\"round\\\" stroke-linejoin=\\\"round\\\"><polyline points=\\\"20 6 9 17 4 12\\\"></polyline></svg>\";\n      copyBtn.addEventListener(\"click\", () => {\n        const code = modal.querySelector(\"#cg-code\");\n        if (code && code.textContent) {\n          navigator.clipboard.writeText(code.textContent).then(() => {\n            copyBtn.innerHTML = checkIcon;\n            copyBtn.style.color = \"var(--md-primary-fg-color)\";\n            setTimeout(() => {\n              copyBtn.innerHTML = copyIcon;\n              copyBtn.style.color = \"\";\n            }, 2000);\n          });\n        }\n      });\n    }\n  }\n\n  function setupWebPushEvents(els) {\n    const { modal } = els;\n    let vapidKeysGenerated = false;\n    const regenBtn = modal.querySelector(\"#cg-regen-keys\");\n\n    if (regenBtn) {\n      regenBtn.addEventListener(\"click\", () => fillVAPIDKeys(els));\n    }\n\n    // Auto-generate keys when web push is first enabled\n    const webpushFeat = modal.querySelector(\"#cg-feat-webpush\");\n    if (webpushFeat) {\n      webpushFeat.addEventListener(\"change\", () => {\n        if (webpushFeat.checked && !vapidKeysGenerated) {\n          vapidKeysGenerated = true;\n          fillVAPIDKeys(els);\n        }\n      });\n    }\n  }\n\n  // --- Init ---\n\n  function initGenerator() {\n    const modal = document.getElementById(\"cg-modal\");\n    if (!modal) return;\n\n    const els = cacheElements(modal);\n\n    setupModalEvents(els);\n    setupAuthEvents(els);\n    setupServerTypeEvents(els);\n    setupUnifiedPushEvents(els);\n    setupFormListeners(els);\n    setupWebPushEvents(els);\n\n    // Pre-fill base-url\n    const baseUrlInput = modal.querySelector(\"[data-key=\\\"base-url\\\"]\");\n    if (baseUrlInput && !baseUrlInput.value.trim()) {\n      baseUrlInput.value = \"https://ntfy.example.com\";\n    }\n\n    // Auto-open if URL hash points to config generator\n    if (window.location.hash === \"#config-generator\") {\n      openModal(els);\n    }\n  }\n\n  if (document.readyState === \"loading\") {\n    document.addEventListener(\"DOMContentLoaded\", initGenerator);\n  } else {\n    initGenerator();\n  }\n})();\n"
  },
  {
    "path": "docs/static/js/extra.js",
    "content": "// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs\n\nconst savedCodeTab = localStorage.getItem(\"savedTab\");\nconst codeTabs = document.querySelectorAll(\".tabbed-set > input\");\nfor (const tab of codeTabs) {\n  tab.addEventListener(\"click\", () => {\n    const current = document.querySelector(`label[for=${tab.id}]`);\n    const pos = current.getBoundingClientRect().top;\n    const labelContent = current.innerHTML;\n    const labels = document.querySelectorAll(\".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label\");\n    for (const label of labels) {\n      if (label.innerHTML === labelContent) {\n        document.querySelector(`input[id=${label.getAttribute(\"for\")}]`).checked = true;\n      }\n    }\n    \n    // Preserve scroll position\n    const delta = (current.getBoundingClientRect().top) - pos;\n    window.scrollBy(0, delta);\n\n    // Save\n    localStorage.setItem(\"savedTab\", labelContent);\n  });\n\n  // Select saved tab\n  const current = document.querySelector(`label[for=${tab.id}]`);\n  const labelContent = current.innerHTML;\n  if (savedCodeTab === labelContent) {\n    tab.checked = true;\n  }\n}\n\n// Lightbox for screenshot\n\nconst lightbox = document.createElement(\"div\");\nlightbox.classList.add(\"lightbox\");\ndocument.body.appendChild(lightbox);\n\nconst showScreenshotOverlay = (e, el, group, index) => {\n  lightbox.classList.add(\"show\");\n  document.addEventListener(\"keydown\", nextScreenshotKeyboardListener);\n  return showScreenshot(e, group, index);\n};\n\nconst showScreenshot = (e, group, index) => {\n  const actualIndex = resolveScreenshotIndex(group, index);\n  lightbox.innerHTML = \"<div class=\\\"close-lightbox\\\"></div>\" + screenshots[group][actualIndex].innerHTML;\n  lightbox.querySelector(\"img\").onclick = (e) => {\n    return showScreenshot(e, group, actualIndex + 1);\n  };\n  currentScreenshotGroup = group;\n  currentScreenshotIndex = actualIndex;\n  e.stopPropagation();\n  return false;\n};\n\nconst nextScreenshot = (e) => {\n  return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);\n};\n\nconst previousScreenshot = (e) => {\n  return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);\n};\n\nconst resolveScreenshotIndex = (group, index) => {\n  if (index < 0) {\n    return screenshots[group].length - 1;\n  } else if (index > screenshots[group].length - 1) {\n    return 0;\n  }\n  return index;\n};\n\nconst hideScreenshotOverlay = (e) => {\n  lightbox.classList.remove(\"show\");\n  document.removeEventListener(\"keydown\", nextScreenshotKeyboardListener);\n};\n\nconst nextScreenshotKeyboardListener = (e) => {\n  switch (e.keyCode) {\n    case 37:\n      previousScreenshot(e);\n      break;\n    case 39:\n      nextScreenshot(e);\n      break;\n  }\n};\n\nlet currentScreenshotGroup = \"\";\nlet currentScreenshotIndex = 0;\nlet screenshots = {};\nArray.from(document.getElementsByClassName(\"screenshots\")).forEach((sg) => {\n  const group = sg.id;\n  screenshots[group] = [...sg.querySelectorAll(\"a\")];\n  screenshots[group].forEach((el, index) => {\n    el.onclick = (e) => {\n      return showScreenshotOverlay(e, el, group, index);\n    };\n  });\n});\n\nlightbox.onclick = hideScreenshotOverlay;\n"
  },
  {
    "path": "docs/subscribe/api.md",
    "content": "# Subscribe via API\nYou can create and subscribe to a topic in the [web UI](web.md), via the [phone app](phone.md), via the [ntfy CLI](cli.md),\nor in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to \ncheck out the page that describes how to [publish messages](../publish.md).\n\nYou can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or \n**[via WebSockets](#websockets)**. Both are incredibly simple to use.\n\n## HTTP stream\nThe HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and\nthe connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which \nonly differ in the response format:\n\n* [JSON stream](#subscribe-as-json-stream): `<topic>/json` returns a JSON stream, with one JSON message object per line\n* [SSE stream](#subscribe-as-sse-stream): `<topic>/sse` returns messages as [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events), which\n  can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)\n* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message\n\n### Subscribe as JSON stream\nHere are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the \nrecommended way to subscribe to a topic**. The notable exception is JavaScript, for which the \n[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.\n\n=== \"Command line (curl)\"\n    ```\n    $ curl -s ntfy.sh/disk-alerts/json\n    {\"id\":\"SLiKI64DOt\",\"time\":1635528757,\"event\":\"open\",\"topic\":\"mytopic\"}\n    {\"id\":\"hwQ2YpKdmg\",\"time\":1635528741,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Disk full\"}\n    {\"id\":\"DGUDShMCsc\",\"time\":1635528787,\"event\":\"keepalive\",\"topic\":\"mytopic\"}\n    ...\n    ```\n\n=== \"ntfy CLI\"\n    ```\n    $ ntfy subcribe disk-alerts\n    {\"id\":\"hwQ2YpKdmg\",\"time\":1635528741,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Disk full\"}\n    ...\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /disk-alerts/json HTTP/1.1\n    Host: ntfy.sh\n\n    HTTP/1.1 200 OK\n    Content-Type: application/x-ndjson; charset=utf-8\n    Transfer-Encoding: chunked\n    \n    {\"id\":\"SLiKI64DOt\",\"time\":1635528757,\"event\":\"open\",\"topic\":\"mytopic\"}\n    {\"id\":\"hwQ2YpKdmg\",\"time\":1635528741,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Disk full\"}\n    {\"id\":\"DGUDShMCsc\",\"time\":1635528787,\"event\":\"keepalive\",\"topic\":\"mytopic\"}\n    ...\n    ```\n\n=== \"Go\"\n    ``` go\n    resp, err := http.Get(\"https://ntfy.sh/disk-alerts/json\")\n    if err != nil {\n        log.Fatal(err)\n    }\n    defer resp.Body.Close()\n    scanner := bufio.NewScanner(resp.Body)\n    for scanner.Scan() {\n        println(scanner.Text())\n    }\n    ```\n\n=== \"Python\"\n    ``` python\n    resp = requests.get(\"https://ntfy.sh/disk-alerts/json\", stream=True)\n    for line in resp.iter_lines():\n      if line:\n        print(line)\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    $fp = fopen('https://ntfy.sh/disk-alerts/json', 'r');\n    if (!$fp) die('cannot open stream');\n    while (!feof($fp)) {\n        echo fgets($fp, 2048);\n        flush();\n    }\n    fclose($fp);\n    ```\n\n### Subscribe as SSE stream\nUsing [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume\nnotifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly \neasy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource).\n\n=== \"Command line (curl)\"\n    ```\n    $ curl -s ntfy.sh/mytopic/sse\n    event: open\n    data: {\"id\":\"weSj9RtNkj\",\"time\":1635528898,\"event\":\"open\",\"topic\":\"mytopic\"}\n    \n    data: {\"id\":\"p0M5y6gcCY\",\"time\":1635528909,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Hi!\"}\n    \n    event: keepalive\n    data: {\"id\":\"VNxNIg5fpt\",\"time\":1635528928,\"event\":\"keepalive\",\"topic\":\"test\"}\n    ...\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /mytopic/sse HTTP/1.1\n    Host: ntfy.sh\n\n    HTTP/1.1 200 OK\n    Content-Type: text/event-stream; charset=utf-8\n    Transfer-Encoding: chunked\n\n    event: open\n    data: {\"id\":\"weSj9RtNkj\",\"time\":1635528898,\"event\":\"open\",\"topic\":\"mytopic\"}\n    \n    data: {\"id\":\"p0M5y6gcCY\",\"time\":1635528909,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"Hi!\"}\n    \n    event: keepalive\n    data: {\"id\":\"VNxNIg5fpt\",\"time\":1635528928,\"event\":\"keepalive\",\"topic\":\"test\"}\n    ...\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');\n    eventSource.onmessage = (e) => {\n      console.log(e.data);\n    };\n    ```\n\n### Subscribe as raw stream\nThe `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely\nsimple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority), \n[tags](../publish.md#tags-emojis) or [message title](../publish.md#message-title) are not included in this output \nformat. Keepalive messages are sent as empty lines.\n\n=== \"Command line (curl)\"\n    ```\n    $ curl -s ntfy.sh/disk-alerts/raw\n    \n    Disk full\n    ...\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /disk-alerts/raw HTTP/1.1\n    Host: ntfy.sh\n\n    HTTP/1.1 200 OK\n    Content-Type: text/plain; charset=utf-8\n    Transfer-Encoding: chunked\n\n    Disk full\n    ...\n    ```\n\n=== \"Go\"\n    ``` go\n    resp, err := http.Get(\"https://ntfy.sh/disk-alerts/raw\")\n    if err != nil {\n        log.Fatal(err)\n    }\n    defer resp.Body.Close()\n    scanner := bufio.NewScanner(resp.Body)\n    for scanner.Scan() {\n        println(scanner.Text())\n    }\n    ```\n\n=== \"Python\"\n    ``` python \n    resp = requests.get(\"https://ntfy.sh/disk-alerts/raw\", stream=True)\n    for line in resp.iter_lines():\n      if line:\n        print(line)\n    ```\n\n=== \"PHP\"\n    ``` php-inline\n    $fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r');\n    if (!$fp) die('cannot open stream');\n    while (!feof($fp)) {\n        echo fgets($fp, 2048);\n        flush();\n    }\n    fclose($fp);\n    ```\n\n## WebSockets\nYou may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely \nsupported in many languages. Most notably, WebSockets are natively supported in JavaScript. You may also want to \ncheck out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-websocket).\nOn the command line, I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` \nor `curl`, but specifically for WebSockets.\n\nThe WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the \n[JSON stream endpoint](#subscribe-as-json-stream). \n\n=== \"Command line (websocat)\"\n    ```\n    $ websocat wss://ntfy.sh/mytopic/ws\n    {\"id\":\"qRHUCCvjj8\",\"time\":1642307388,\"event\":\"open\",\"topic\":\"mytopic\"}\n    {\"id\":\"eOWoUBJ14x\",\"time\":1642307754,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"hi there\"}\n    ```\n\n=== \"HTTP\"\n    ``` http\n    GET /disk-alerts/ws HTTP/1.1\n    Host: ntfy.sh\n    Upgrade: websocket\n    Connection: Upgrade\n\n    HTTP/1.1 101 Switching Protocols\n    Upgrade: websocket\n    Connection: Upgrade\n    ...\n    ```\n\n=== \"Go\"\n    ``` go\n    import \"github.com/gorilla/websocket\"\n\tws, _, _ := websocket.DefaultDialer.Dial(\"wss://ntfy.sh/mytopic/ws\", nil)\n\tmessageType, data, err := ws.ReadMessage()\n    ...\n    ```\n\n=== \"JavaScript\"\n    ``` javascript\n    const socket = new WebSocket('wss://ntfy.sh/mytopic/ws');\n    socket.addEventListener('message', function (event) {\n        console.log(event.data);\n    });\n    ```\n\n## Advanced features\n\n### Poll for messages\nYou can also just poll for messages if you don't like the long-standing connection using the `poll=1`\nquery parameter. The connection will end after all available messages have been read. This parameter can be\ncombined with `since=` (defaults to `since=all`).\n\n```\ncurl -s \"ntfy.sh/mytopic/json?poll=1\"\n```\n\n### Fetch cached messages\nMessages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network\ninterruptions of subscribers. If the server has configured message caching, you can read back what you missed by using \nthe `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`),\na message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages).\n\n```\ncurl -s \"ntfy.sh/mytopic/json?since=10m\"\ncurl -s \"ntfy.sh/mytopic/json?since=1645970742\"\ncurl -s \"ntfy.sh/mytopic/json?since=nFS3knfcQ1xe\"\n```\n\n### Fetch latest message\nIf you only want the most recent message sent to a topic and do not have a message ID or timestamp to use with\n`since=`, you can use `since=latest` to grab the most recent message from the cache for a particular topic.\n\n```\ncurl -s \"ntfy.sh/mytopic/json?poll=1&since=latest\"\n```\n\n### Fetch scheduled messages\nMessages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically \nreturned when subscribing via the API, which makes sense, because after all, the messages have technically not been \ndelivered yet. To also return scheduled messages from the API, you can use the `scheduled=1` (alias: `sched=1`) \nparameter (makes most sense with the `poll=1` parameter):\n\n```\ncurl -s \"ntfy.sh/mytopic/json?poll=1&sched=1\"\n```\n\n### Filter messages\nYou can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and\n`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags \n\"zfs-error\" and \"error\". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND. \n\n```\n$ curl \"ntfy.sh/alerts/json?priority=high&tags=zfs-error\"\n{\"id\":\"0TIkJpBcxR\",\"time\":1640122627,\"event\":\"open\",\"topic\":\"alerts\"}\n{\"id\":\"X3Uzz9O1sM\",\"time\":1640122674,\"event\":\"message\",\"topic\":\"alerts\",\"priority\":4,\n  \"tags\":[\"error\", \"zfs-error\"], \"message\":\"ZFS pool corruption detected\"}\n```\n\nAvailable filters (all case-insensitive):\n\n| Filter variable | Alias                     | Example                                       | Description                                                             |\n|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------|\n| `id`            | `X-ID`                    | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID                   |\n| `message`       | `X-Message`, `m`          | `ntfy.sh/mytopic/json?message=lalala`         | Only return messages that match this exact message string               |\n| `title`         | `X-Title`, `t`            | `ntfy.sh/mytopic/json?title=some+title`       | Only return messages that match this exact title string                 |\n| `priority`      | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent`          | Only return messages that match *any priority listed* (comma-separated) |\n| `tags`          | `X-Tags`, `tag`, `ta`     | `ntfy.sh/mytopic/json?tags=error,alert`       | Only return messages that match *all listed tags* (comma-separated)     |\n\n### Subscribe to multiple topics\nIt's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics \nin the URL. This allows you to reduce the number of connections you have to maintain:\n\n```\n$ curl -s ntfy.sh/mytopic1,mytopic2/json\n{\"id\":\"0OkXIryH3H\",\"time\":1637182619,\"event\":\"open\",\"topic\":\"mytopic1,mytopic2,mytopic3\"}\n{\"id\":\"dzJJm7BCWs\",\"time\":1637182634,\"event\":\"message\",\"topic\":\"mytopic1\",\"message\":\"for topic 1\"}\n{\"id\":\"Cm02DsxUHb\",\"time\":1637182643,\"event\":\"message\",\"topic\":\"mytopic2\",\"message\":\"for topic 2\"}\n```\n\n### Authentication\nDepending on whether the server is configured to support [access control](../config.md#access-control), some topics\nmay be read/write protected so that only users with the correct credentials can subscribe or publish to them.\nTo publish/subscribe to protected topics, you can:\n\n* Use [basic auth](../publish.md#authentication), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`\n* or use the [`auth` query parameter](../publish.md#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`\n\nPlease refer to the [publishing documentation](../publish.md#authentication) for additional details.\n\n## JSON message format\nBoth the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON\nformat of the message. It's very straight forward:\n\n**Message**:\n\n| Field         | Required | Type                                                                            | Example                                               | Description                                                                                                                          |\n|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|\n| `id`          | ✔️       | *string*                                                                        | `hwQ2YpKdmg`                                          | Randomly chosen message identifier                                                                                                   |\n| `time`        | ✔️       | *number*                                                                        | `1635528741`                                          | Message date time, as Unix time stamp                                                                                                |  \n| `expires`     | (✔)️     | *number*                                                                        | `1673542291`                                          | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent                                          |  \n| `event`       | ✔️       | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message`                                             | Message type, typically you'd be only interested in `message`                                                                        |\n| `topic`       | ✔️       | *string*                                                                        | `topic1,topic2`                                       | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |\n| `sequence_id` | -        | *string*                                                                        | `my-sequence-123`                                     | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications)                                 |\n| `message`     | -        | *string*                                                                        | `Some message`                                        | Message body; always present in `message` events                                                                                     |\n| `title`       | -        | *string*                                                                        | `Some title`                                          | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>`                                               |\n| `tags`        | -        | *string array*                                                                  | `[\"tag1\",\"tag2\"]`                                     | List of [tags](../publish.md#tags-emojis) that may or not map to emojis                                                              |\n| `priority`    | -        | *1, 2, 3, 4, or 5*                                                              | `4`                                                   | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max                                                   |\n| `click`       | -        | *URL*                                                                           | `https://example.com`                                 | Website opened when notification is [clicked](../publish.md#click-action)                                                            |\n| `actions`     | -        | *JSON array*                                                                    | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification                                             |\n| `attachment`  | -        | *JSON object*                                                                   | *see below*                                           | Details about an attachment (name, URL, size, ...)                                                                                   |\n\n**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):\n\n| Field     | Required | Type        | Example                        | Description                                                                                               |\n|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------|\n| `name`    | ✔️       | *string*    | `attachment.jpg`               | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) |\n| `url`     | ✔️       | *URL*       | `https://example.com/file.jpg` | URL of the attachment                                                                                     |  \n| `type`    | -️       | *mime type* | `image/jpeg`                   | Mime type of the attachment, only defined if attachment was uploaded to ntfy server                       |\n| `size`    | -️       | *number*    | `33848`                        | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server                   |\n| `expires` | -️       | *number*    | `1635528741`                   | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server         |\n\nHere's an example for each message type:\n\n=== \"Notification message\"\n    ``` json\n    {\n        \"id\": \"sPs71M8A2T\",\n        \"time\": 1643935928,\n        \"expires\": 1643936928,\n        \"event\": \"message\",\n        \"topic\": \"mytopic\",\n        \"priority\": 5,\n        \"tags\": [\n            \"warning\",\n            \"skull\"\n        ],\n        \"click\": \"https://homecam.mynet.lan/incident/1234\",\n        \"attachment\": {\n            \"name\": \"camera.jpg\",\n            \"type\": \"image/png\",\n            \"size\": 33848,\n            \"expires\": 1643946728,\n            \"url\": \"https://ntfy.sh/file/sPs71M8A2T.png\"\n        },\n        \"title\": \"Unauthorized access detected\",\n        \"message\": \"Movement detected in the yard. You better go check\"\n    }\n    ```\n\n\n=== \"Notification message (minimal)\"\n    ``` json\n    {\n        \"id\": \"wze9zgqK41\",\n        \"time\": 1638542110,\n        \"expires\": 1638543112,\n        \"event\": \"message\",\n        \"topic\": \"phil_alerts\",\n        \"message\": \"Remote access to phils-laptop detected. Act right away.\"\n    }\n    ```\n\n=== \"Open message\"\n    ``` json\n    {\n        \"id\": \"2pgIAaGrQ8\",\n        \"time\": 1638542215,\n        \"event\": \"open\",\n        \"topic\": \"phil_alerts\"\n    }\n    ```\n\n=== \"Keepalive message\"\n    ``` json\n    {\n        \"id\": \"371sevb0pD\",\n        \"time\": 1638542275,\n        \"event\": \"keepalive\",\n        \"topic\": \"phil_alerts\"\n    }\n    ```    \n\n=== \"Poll request message\"\n    ``` json\n    {\n        \"id\": \"371sevb0pD\",\n        \"time\": 1638542275,\n        \"event\": \"poll_request\",\n        \"topic\": \"phil_alerts\"\n    }\n    ```\n\n## List of all parameters\nThe following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**,\nand can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.\n\n| Parameter   | Aliases (case-insensitive) | Description                                                                     |\n|-------------|----------------------------|---------------------------------------------------------------------------------|\n| `poll`      | `X-Poll`, `po`             | Return cached messages and close connection                                     |\n| `since`     | `X-Since`, `si`            | Return cached messages since timestamp, duration or message ID                  |\n| `scheduled` | `X-Scheduled`, `sched`     | Include scheduled/delayed messages in message list                              |\n| `id`        | `X-ID`                     | Filter: Only return messages that match this exact message ID                   |\n| `message`   | `X-Message`, `m`           | Filter: Only return messages that match this exact message string               |\n| `title`     | `X-Title`, `t`             | Filter: Only return messages that match this exact title string                 |\n| `priority`  | `X-Priority`, `prio`, `p`  | Filter: Only return messages that match *any priority listed* (comma-separated) |\n| `tags`      | `X-Tags`, `tag`, `ta`      | Filter: Only return messages that match *all listed tags* (comma-separated)     |\n"
  },
  {
    "path": "docs/subscribe/cli.md",
    "content": "# Subscribe via ntfy CLI\nIn addition to subscribing via the [web UI](web.md), the [phone app](phone.md), or the [API](api.md), you can subscribe\nto topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that can be used to [self-host a server](../install.md).\n\n!!! info\n    The **ntfy CLI is not required to send or receive messages**. You can instead [send messages with curl](../publish.md),\n    and even use it to [subscribe to topics](api.md). It may be a little more convenient to use the ntfy CLI than writing \n    your own script. It all depends on the use case. 😀\n\n## Install + configure\nTo install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and \nclient are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client \nby creating `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user). You \ncan find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub. \n\nIf you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**,\nyou may want to edit the `default-host` option:\n\n``` yaml\n# Base URL used to expand short topic names in the \"ntfy publish\" and \"ntfy subscribe\" commands.\n# If you self-host a ntfy server, you'll likely want to change this.\n#\ndefault-host: https://ntfy.myhost.com\n```\n\n## Publish messages\nYou can send messages with the ntfy CLI using the `ntfy publish` command (or any of its aliases `pub`, `send` or \n`trigger`). There are a lot of examples on the page about [publishing messages](../publish.md), but here are a few\nquick ones:\n\n=== \"Simple send\"\n    ```\n    ntfy publish mytopic This is a message\n    ntfy publish mytopic \"This is a message\"\n    ntfy pub mytopic \"This is a message\" \n    ```\n\n=== \"Send with title, priority, and tags\"\n    ```\n    ntfy publish \\\n        --title=\"Thing sold on eBay\" \\\n        --priority=high \\\n        --tags=partying_face \\\n        mytopic \\\n        \"Somebody just bought the thing that you sell\"\n    ```\n\n=== \"Send at 8:30am\"\n    ```\n    ntfy pub --at=8:30am delayed_topic Laterzz\n    ```\n\n=== \"Triggering a webhook\"\n    ```\n    ntfy trigger mywebhook\n    ntfy pub mywebhook\n    ```\n\n### Attaching a local file\nYou can easily upload and attach a local file to a notification:\n\n```\n$ ntfy pub --file README.md mytopic | jq .\n{\n  \"id\": \"meIlClVLABJQ\",\n  \"time\": 1655825460,\n  \"event\": \"message\",\n  \"topic\": \"mytopic\",\n  \"message\": \"You received a file: README.md\",\n  \"attachment\": {\n    \"name\": \"README.md\",\n    \"type\": \"text/plain; charset=utf-8\",\n    \"size\": 2892,\n    \"expires\": 1655836260,\n    \"url\": \"https://ntfy.sh/file/meIlClVLABJQ.txt\"\n  }\n}\n```\n\n### Wait for PID/command\nIf you have a long-running command and want to **publish a notification when the command completes**, \nyou may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the\ncommand is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`).\n\nRun a command and wait for it to complete (here: `rsync ...`):\n\n```\n$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq .\n{\n  \"id\": \"Re0rWXZQM8WB\",\n  \"time\": 1655825624,\n  \"event\": \"message\",\n  \"topic\": \"mytopic\",\n  \"message\": \"Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/\"\n}\n```\n\nOr, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this:\n\n=== \"Using a PID directly\"\n    ```\n    $ ntfy pub --wait-pid 8458 mytopic | jq .\n    {\n      \"id\": \"orM6hJKNYkWb\",\n      \"time\": 1655825827,\n      \"event\": \"message\",\n      \"topic\": \"mytopic\",\n      \"message\": \"Process with PID 8458 exited after 2.003s\"\n    }\n    ```\n\n=== \"Using a `pidof`\"\n    ```\n    $ ntfy pub --wait-pid $(pidof rsync) mytopic | jq .\n    {\n      \"id\": \"orM6hJKNYkWb\",\n      \"time\": 1655825827,\n      \"event\": \"message\",\n      \"topic\": \"mytopic\",\n      \"message\": \"Process with PID 8458 exited after 2.003s\"\n    }\n    ```\n\n## Subscribe to topics\nYou can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command\nwill either print or execute a command for every arriving message. There are a few different ways \nin which the command can be run:\n\n### Stream messages as JSON\n```\nntfy subscribe TOPIC\n```\nIf you run the command like this, it prints the JSON representation of every incoming message. This is useful \nwhen you have a command that wants to stream-read incoming JSON messages. Unless `--poll` is passed, this command \nstays open forever.\n\n```\n$ ntfy sub mytopic\n{\"id\":\"nZ8PjH5oox\",\"time\":1639971913,\"event\":\"message\",\"topic\":\"mytopic\",\"message\":\"hi there\"}\n{\"id\":\"sekSLWTujn\",\"time\":1639972063,\"event\":\"message\",\"topic\":\"mytopic\",priority:5,\"message\":\"Oh no!\"}\n...\n```\n\n<figure>\n  <video controls muted autoplay loop width=\"650\" src=\"../../static/img/cli-subscribe-video-1.mp4\"></video>\n  <figcaption>Subscribe in JSON mode</figcaption>\n</figure>\n\n### Run command for every message\n```\nntfy subscribe TOPIC COMMAND\n```\nIf you run it like this, a COMMAND is executed for every incoming messages. Scroll down to see a list of available\nenvironment variables. Here are a few examples:\n \n```\nntfy sub mytopic 'notify-send \"$m\"'\nntfy sub topic1 /my/script.sh\nntfy sub topic1 'echo \"Message $m was received. Its title was $t and it had priority $p\"'\n```\n\n<figure>\n  <video controls muted autoplay loop width=\"650\" src=\"../../static/img/cli-subscribe-video-2.webm\"></video>\n  <figcaption>Execute command on incoming messages</figcaption>\n</figure>\n\nThe message fields are passed to the command as environment variables and can be used in scripts. Note that since \nthese are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them\nin double-quotes, you should be fine:\n\n| Variable         | Aliases                    | Description                            |\n|------------------|----------------------------|----------------------------------------|\n| `$NTFY_ID`       | `$id`                      | Unique message ID                      |\n| `$NTFY_TIME`     | `$time`                    | Unix timestamp of the message delivery |\n| `$NTFY_TOPIC`    | `$topic`                   | Topic name                             |\n| `$NTFY_MESSAGE`  | `$message`, `$m`           | Message body                           |\n| `$NTFY_TITLE`    | `$title`, `$t`             | Message title                          |\n| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max)        |\n| `$NTFY_TAGS`     | `$tags`, `$tag`, `$ta`     | Message tags (comma separated list)    |\n| `$NTFY_RAW`      | `$raw`                     | Raw JSON message                       |\n   \n### Subscribe to multiple topics\n```\nntfy subscribe --from-config\n```\nTo subscribe to multiple topics at once, and run different commands for each one, you can use `ntfy subscribe --from-config`,\nwhich will read the `subscribe` config from the config file. Please also check out the [ntfy-client systemd service](#using-the-systemd-service).\n\nHere's an example config file that subscribes to three different topics, executing a different command for each of them:\n\n=== \"~/.config/ntfy/client.yml (Linux)\"\n    ```yaml\n    default-host: https://ntfy.sh\n    default-user: phill\n    default-password: mypass\n\n    subscribe:\n    - topic: echo-this\n      command: 'echo \"Message received: $message\"'\n    - topic: alerts\n      command: notify-send -i /usr/share/ntfy/logo.png \"Important\" \"$m\"\n      if:\n        priority: high,urgent\n    - topic: calc\n      command: 'gnome-calculator 2>/dev/null &'\n    - topic: print-temp\n      command: |\n            echo \"You can easily run inline scripts, too.\"\n            temp=\"$(sensors | awk '/Pack/ { print substr($4,2,2) }')\"\n            if [ $temp -gt 80 ]; then\n              echo \"Warning: CPU temperature is $temp. Too high.\"\n            else\n              echo \"CPU temperature is $temp. That's alright.\"\n            fi\n    ```\n\n=== \"~/Library/Application Support/ntfy/client.yml (macOS)\"\n    ```yaml\n    default-host: https://ntfy.sh\n    default-user: phill\n    default-password: mypass\n\n    subscribe:\n      - topic: echo-this\n        command: 'echo \"Message received: $message\"'\n      - topic: alerts\n        command: osascript -e \"display notification \\\"$message\\\"\"\n        if:\n          priority: high,urgent\n      - topic: calc\n        command: open -a Calculator\n    ```\n\n=== \"%AppData%\\ntfy\\client.yml (Windows)\"\n    ```yaml\n    default-host: https://ntfy.sh\n    default-user: phill\n    default-password: mypass\n\n    subscribe:\n    - topic: echo-this\n      command: 'echo Message received: %message%'\n    - topic: alerts\n      command: |\n        notifu /m \"%NTFY_MESSAGE%\"\n        exit 0\n      if:\n        priority: high,urgent\n    - topic: calc\n      command: calc\n    ```\n\nIn this example, when `ntfy subscribe --from-config` is executed:\n\n* Messages to `echo-this` simply echos to standard out\n* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux), \n  [notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS) \n* Messages to `calc` open the calculator 😀 (*because, why not*)\n* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only)\n\nI hope this shows how powerful this command is. Here's a short video that demonstrates the above example:\n\n<figure>\n  <video controls muted autoplay loop width=\"650\" src=\"../../static/img/cli-subscribe-video-3.webm\"></video>\n  <figcaption>Execute all the things</figcaption>\n</figure>\n\nIf most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both).\nYou can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults\nwill be used, otherwise, the subscription settings will override the defaults.\n\n!!! warning\n    Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not\n    require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.\n\n### Using the systemd service\nYou can use the `ntfy-client` systemd services to subscribe to multiple topics just like in the example above.\n\nYou have the option of either enabling `ntfy-client` as a **system service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))\nor **user service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/user/ntfy-client.service)). Neither system service nor user service are enabled or started by default, so you have to do that yourself.\n\n**System service:** The `ntfy-client` systemd system service runs as the `ntfy` user. When enabled, it is started at system boot. To configure it as a system\nservice, edit `/etc/ntfy/client.yml` and then enable/start the service (as root), like so:\n\n```\nsudo systemctl enable ntfy-client\nsudo systemctl restart ntfy-client\n```\n\nThe system service runs as user `ntfy`, meaning that typical Linux permission restrictions apply. It also means that the system service cannot run commands in your X session as the primary machine user (unlike the user service).\n\n**User service:** The `ntfy-client` user service is run when the user logs into their desktop environment. To enable/start it, edit `~/.config/ntfy/client.yml` and\nrun the following commands (without sudo!):\n\n```\nsystemctl --user enable ntfy-client\nsystemctl --user restart ntfy-client\n```\n\nUnlike the system service, the user service can interact with the user's desktop environment, and run commands like `notify-send` to display desktop notifications.\nIt can also run commands that require access to the user's home directory, such as `gnome-calculator`.\n\n### Authentication\nDepending on whether the server is configured to support [access control](../config.md#access-control), some topics\nmay be read/write protected so that only users with the correct credentials can subscribe or publish to them.\nTo publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)\nwith a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing\nyour password. \n\nYou can either add your username and password to the configuration file:\n=== \"~/.config/ntfy/client.yml\"\n\t```yaml\n\t - topic: secret\n\t   command: 'notify-send \"$m\"'\n\t   user: phill\n\t   password: mypass\n\t```\n\nOr with the `ntfy subscribe` command:\n```\nntfy subscribe \\\n  -u phil:mypass \\\n  ntfy.example.com/mysecrets\n```\n"
  },
  {
    "path": "docs/subscribe/phone.md",
    "content": "# Subscribe from your phone\nYou can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347)\nto receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available\non GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to \ncontribute, or [build your own](../develop.md).\n\n<a href=\"https://play.google.com/store/apps/details?id=io.heckel.ntfy\"><img width=\"170\" src=\"../../static/img/badge-googleplay.png\"></a>\n<a href=\"https://f-droid.org/en/packages/io.heckel.ntfy/\"><img width=\"170\" src=\"../../static/img/badge-fdroid.svg\"></a>\n<a href=\"https://apps.apple.com/us/app/ntfy/id1625396347\"><img width=\"150\" src=\"../../static/img/badge-appstore.png\"></a>\n\nYou can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy), \n[F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), or via the APKs from [GitHub Releases](https://github.com/binwiederhier/ntfy-android/releases).\nThe Google Play and F-Droid releases are largely identical, with the one exception that the F-Droid flavor does not use Firebase. \nThe iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).\n\nAlternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.\nThe PWA is a website that you can add to your home screen, and it will behave just like a native app.\n\nIf you're downloading the APKs from [GitHub](https://github.com/binwiederhier/ntfy-android/releases), they are signed with\na certificate with the following SHA-256 fingerprint: `6e145d7ae685eff75468e5067e03a6c3645453343e4e181dac8b6b17ff67489d`.\nYou can also query the DNS TXT records for `ntfy.sh` to find this fingerprint.\n\n## Overview\nA picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty\nstraight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.\n\n<div id=\"android-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-main.png\"><img src=\"../../static/img/android-screenshot-main.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-detail.png\"><img src=\"../../static/img/android-screenshot-detail.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-pause.png\"><img src=\"../../static/img/android-screenshot-pause.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-add.png\"><img src=\"../../static/img/android-screenshot-add.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-add-instant.png\"><img src=\"../../static/img/android-screenshot-add-instant.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-add-other.png\"><img src=\"../../static/img/android-screenshot-add-other.png\"/></a>\n</div>\n\nIf those screenshots are still not enough, here's a video:\n\n<figure>\n  <video controls muted autoplay loop width=\"650\" src=\"../../static/img/android-video-overview.mp4\"></video>\n  <figcaption>Sending push notifications to your Android phone</figcaption>\n</figure>\n\n## Message priority\n_Supported on:_ :material-android: :material-apple:\n\nWhen you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines\nhow urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.\n\nBy default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent\npriority (>= 4) will also show as pop-over, like so:\n\n<figure markdown>\n  ![priority notification](../static/img/priority-notification.png){ width=500 }\n  <figcaption>High and urgent notifications show as pop-over</figcaption>\n</figure>\n\nYou can change these settings in Android by long-pressing on the app, and tapping \"Notifications\", or from the \"Settings\"\nmenu under \"Channel settings\". There is one notification channel for each priority:\n\n<figure markdown>\n  ![notification settings](../static/img/android-screenshot-notification-settings.png){ width=500 }\n  <figcaption>Per-priority channels</figcaption>\n</figure>\n\nPer notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)**\nsetting, and other settings such as popover or notification dot:\n\n<figure markdown>\n  ![channel details](../static/img/android-screenshot-notification-details.jpg){ width=500 }\n  <figcaption>Per-priority sound/vibration settings</figcaption>\n</figure>\n\n## Instant delivery\n_Supported on:_ :material-android:\n\nInstant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e. \nwhen the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which \nyou'll see as a permanent notification that looks like this:\n\n<figure markdown>\n  ![foreground service](../static/img/foreground-service.png){ width=500 }\n  <figcaption>Instant delivery foreground notification</figcaption>\n</figure>\n\nTo turn off this notification, long-press on the foreground notification (screenshot above) and navigate to the \nsettings. Then toggle the \"Subscription Service\" off:\n\n<figure markdown>\n  ![foreground service](../static/img/notification-settings.png){ width=500 }\n  <figcaption>Turning off the persistent instant delivery notification</figcaption>\n</figure>\n\n**Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay** \n(sometimes many minutes, or even hours later). If you've ever picked up your phone and \nsuddenly had 10 messages that were sent long before you know what I'm talking about.\n\nThe reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging). FCM is the \n*only* Google approved way to send push messages to Android devices, and it's what pretty much all apps use to deliver push \nnotifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.\n\nThe ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.\nIt won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.\n\n!!! info \"F-Droid: Always instant delivery\"\n    Since the F-Droid build does not include Firebase, **all subscriptions use instant delivery by default**, and \n    there is no option to disable it. The F-Droid app hides all mentions of \"instant delivery\" in the UI, since \n    showing options that can't be changed would only be confusing.\n\n## Publishing messages\n_Supported on:_ :material-android:\n\nThe Android app allows you to **publish messages directly from the app**, without needing to use curl or any other \ntool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom \nof the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.\nIf the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.\n\nFor more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets \nyou compose a full notification with all available options, including title, tags, priority, click URL, email \nforwarding, delayed delivery, attachments, Markdown formatting, and phone calls.\n\n<div id=\"publish-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-publish-message-bar.jpg\"><img src=\"../../static/img/android-screenshot-publish-message-bar.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-publish-dialog.jpg\"><img src=\"../../static/img/android-screenshot-publish-dialog.jpg\"/></a>\n</div>\n\n## Share to topic\n_Supported on:_ :material-android:\n\nYou can share files to a topic using Android's \"Share\" feature. This works in almost any app that supports sharing files\nor text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics\nyou shared content to and lists them at the bottom.\n\nThe feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:\n\n<div id=\"share-to-topic-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-share-1.jpg\"><img src=\"../../static/img/android-screenshot-share-1.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-share-2.jpg\"><img src=\"../../static/img/android-screenshot-share-2.jpg\"/></a>\n</div>\n\n## ntfy:// links\n_Supported on:_ :material-android:\n\nThe ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)\nsuch as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),\nor to simply directly link to a topic from a mobile website. \n\n!!! info\n    Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is \n    **not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).\n\n**Supported link formats:**\n\n| Link format                                                                     | Example                                   | Description                                                                                                                                                                                         |\n|---------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| <span style=\"white-space: nowrap\">`ntfy://<host>/<topic>`</span>                | `ntfy://ntfy.sh/mytopic`                  | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |\n| <span style=\"white-space: nowrap\">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic.                                                                                                                                       |\n| <span style=\"white-space: nowrap\">`ntfy://<host>/<topic>?secure=false`</span>   | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!)                                                |\n\n## Advanced settings\n\n### Custom headers\n_Supported on:_ :material-android:\n\nIf your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or \na reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to \nthat server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by\nyour setup. To add custom headers, go to **Settings → Advanced → Custom headers**.\n\n<div id=\"custom-headers-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-custom-headers.jpg\"><img src=\"../../static/img/android-screenshot-custom-headers.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-custom-headers-add.jpg\"><img src=\"../../static/img/android-screenshot-custom-headers-add.jpg\"/></a>\n</div>\n\n!!! warning\n    If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy\n    sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user\n    for that server.\n\n### Manage certificates\n_Supported on:_ :material-android:\n\nIf you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)** \nfor client authentication, you can manage certificates in the app settings.\n\nGo to **Settings → Advanced → Manage certificates** to:\n\n- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.\n  This is useful for self-signed certificates that are not trusted by the Android system.\n- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This \n  certificate will be presented to the server when connecting.\n\nWhen you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and \nallow you to review and trust the certificate.\n\n<div id=\"certificates-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-certs-manage.jpg\"><img src=\"../../static/img/android-screenshot-certs-manage.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-certs-warning-dialog.jpg\"><img src=\"../../static/img/android-screenshot-certs-warning-dialog.jpg\"/></a>\n</div>\n\n### Language\n_Supported on:_ :material-android:\n\nThe Android app supports many languages and uses the **system language by default**. If you'd like to use the app in \na different language than your system, you can override it in **Settings → General → Language**.\n\n<div id=\"language-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-language-selection.jpg\"><img src=\"../../static/img/android-screenshot-language-selection.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-language-german.jpg\"><img src=\"../../static/img/android-screenshot-language-german.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-language-hebrew.jpg\"><img src=\"../../static/img/android-screenshot-language-hebrew.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-language-chinese.jpg\"><img src=\"../../static/img/android-screenshot-language-chinese.jpg\"/></a>\n</div>\n\nThe app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many\nmore. Languages with more than 80% of strings translated are shown in the language picker.\n\n!!! tip \"Help translate ntfy\"\n    If you'd like to help translate ntfy into your language or improve existing translations, please visit the\n    [ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!\n\n## Integrations\n\n### UnifiedPush\n_Supported on:_ :material-android:\n\n[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned\n[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications \nin the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. \n\nTo use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/). \nThat's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md)\nto handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):\n\n<div id=\"unifiedpush-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-unifiedpush-fluffychat.jpg\"><img src=\"../../static/img/android-screenshot-unifiedpush-fluffychat.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-unifiedpush-subscription.jpg\"><img src=\"../../static/img/android-screenshot-unifiedpush-subscription.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-unifiedpush-settings.jpg\"><img src=\"../../static/img/android-screenshot-unifiedpush-settings.jpg\"/></a>\n</div>\n\n### Automation apps\n_Supported on:_ :material-android:\n\nThe ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)\nor [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can\n**react to incoming messages**, as well as **send messages**.\n\n#### React to incoming messages\nTo react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see\n[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).\nHere's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)\nand [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch \nbroadcasts is supported:\n\n<div id=\"integration-screenshots-receive-1\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-macrodroid-overview.png\"><img src=\"../../static/img/android-screenshot-macrodroid-overview.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-macrodroid-trigger.png\"><img src=\"../../static/img/android-screenshot-macrodroid-trigger.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-macrodroid-action.png\"><img src=\"../../static/img/android-screenshot-macrodroid-action.png\"/></a>\n</div>\n\n<div id=\"integration-screenshots-receive-2\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-tasker-profiles.png\"><img src=\"../../static/img/android-screenshot-tasker-profiles.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-event-edit.png\"><img src=\"../../static/img/android-screenshot-tasker-event-edit.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-task-edit.png\"><img src=\"../../static/img/android-screenshot-tasker-task-edit.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-action-edit.png\"><img src=\"../../static/img/android-screenshot-tasker-action-edit.png\"/></a>\n</div>\n\nFor MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed.\nIf you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent \nnotification popups:\n\n<figure markdown>\n  ![muted subscription](../static/img/android-screenshot-muted.png){ width=500 }\n  <figcaption>Muting notifications to prevent popups</figcaption>\n</figure>\n\nHere's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:\n\n| Extra name           | Type                         | Example                                  | Description                                                                        |\n|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------|\n| `id`                 | *String*                     | `bP8dMjO8ig`                             | Randomly chosen message identifier (likely not very useful for task automation)    |\n| `base_url`           | *String*                     | `https://ntfy.sh`                        | Root URL of the ntfy server this message came from                                 |\n| `topic` ❤️           | *String*                     | `mytopic`                                | Topic name; **you'll likely want to filter for a specific topic**                  |\n| `muted`              | *Boolean*                    | `true`                                   | Indicates whether the subscription was muted in the app                            |\n| `muted_str`          | *String (`true` or `false`)* | `true`                                   | Same as `muted`, but as string `true` or `false`                                   |\n| `time`               | *Int*                        | `1635528741`                             | Message date time, as Unix time stamp                                              |\n| `title`              | *String*                     | `Some title`                             | Message [title](../publish.md#message-title); may be empty if not set              |\n| `message` ❤️         | *String*                     | `Some message`                           | Message body; **this is likely what you're interested in**                         |\n| `message_bytes`      | *ByteArray*                  | `(binary data)`                          | Message body as binary data                                                        |\n| `encoding`️          | *String*                     | -                                        | Message encoding (empty or \"base64\")                                               |\n| `tags`               | *String*                     | `tag1,tag2,..`                           | Comma-separated list of [tags](../publish.md#tags-emojis)                          |\n| `tags_map`           | *String*                     | `0=tag1,1=tag2,..`                       | Map of tags to make it easier to map first, second, ... tag                        |\n| `priority`           | *Int (between 1-5)*          | `4`                                      | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |\n| `click`              | *String*                     | `https://google.com`                     | [Click action](../publish.md#click-action) URL, or empty if not set                |\n| `attachment_name`    | *String*                     | `attachment.jpg`                         | Filename of the attachment; may be empty if not set                                |\n| `attachment_type`    | *String*                     | `image/jpeg`                             | Mime type of the attachment; may be empty if not set                               |\n| `attachment_size`    | *Long*                       | `9923111`                                | Size in bytes of the attachment; may be zero if not set                            |\n| `attachment_expires` | *Long*                       | `1655514244`                             | Expiry date as Unix timestamp of the attachment URL; may be zero if not set        |\n| `attachment_url`     | *String*                     | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set                                     |\n\n#### Send messages using intents\nTo send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)\nand [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can \nbroadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP\nPOST request to [publish a message](../publish.md). This is primarily useful for apps that do not support HTTP POST/PUT\n(like MacroDroid). In Tasker, you can simply use the \"HTTP Request\" action, which is a little easier and also works if \nntfy is not installed.\n\nHere's what that looks like:\n\n<div id=\"integration-screenshots-send\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-macrodroid-send-macro.png\"><img src=\"../../static/img/android-screenshot-macrodroid-send-macro.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-macrodroid-send-action.png\"><img src=\"../../static/img/android-screenshot-macrodroid-send-action.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-profile-send.png\"><img src=\"../../static/img/android-screenshot-tasker-profile-send.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-task-edit-post.png\"><img src=\"../../static/img/android-screenshot-tasker-task-edit-post.png\"/></a>\n    <a href=\"../../static/img/android-screenshot-tasker-action-http-post.png\"><img src=\"../../static/img/android-screenshot-tasker-action-http-post.png\"/></a>\n</div>\n\nThe following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:\n\n| Extra name   | Required | Type                          | Example           | Description                                                                        |\n|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------|\n| `base_url`   | -        | *String*                      | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh`  |\n| `topic` ❤️   | ✔        | *String*                      | `mytopic`         | Topic name; **you must set this**                                                  |\n| `title`      | -        | *String*                      | `Some title`      | Message [title](../publish.md#message-title); may be empty if not set              |\n| `message` ❤️ | ✔        | *String*                      | `Some message`    | Message body; **you must set this**                                                |\n| `tags`       | -        | *String*                      | `tag1,tag2,..`    | Comma-separated list of [tags](../publish.md#tags-emojis)                          |\n| `priority`   | -        | *String or Int (between 1-5)* | `4`               | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |\n\n## Troubleshooting\n\n### Connection error dialog\n_Supported on:_ :material-android:\n\nIf the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens\nthe **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose\nthe issue.\n\n<div id=\"connection-error-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/android-screenshot-connection-error-warning.jpg\"><img src=\"../../static/img/android-screenshot-connection-error-warning.jpg\"/></a>\n    <a href=\"../../static/img/android-screenshot-connection-error-dialog.jpg\"><img src=\"../../static/img/android-screenshot-connection-error-dialog.jpg\"/></a>\n</div>\n\nCommon connection errors include:\n\n| Error | Description |\n|-------|-------------|\n| Connection refused | The server may be down or the address may be incorrect |\n| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |\n| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |\n| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |\n\nIf you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**\nand share them for debugging.\n"
  },
  {
    "path": "docs/subscribe/pwa.md",
    "content": "# Using the progressive web app (PWA)\nWhile ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA)\nand thus can be **installed on both desktop and mobile devices**.\n\nThis gives it its own launcher (e.g. shortcut on Windows, app on macOS, launcher shortcut on Linux, home screen icon on iOS, and\nlauncher icon on Android), a standalone window, push notifications, and an app badge with the unread notification count.\n\nWeb app installation is **supported on** (see [compatibility table](https://caniuse.com/web-app-manifest) for details):\n\n- **Chrome:** Android, Windows, Linux, macOS\n- **Safari:** iOS 16.4+, macOS 14+\n- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/)\n- **Edge:** Windows\n\nNote that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work.\n\n## Installation\n\n### Chrome on Desktop\nTo install and register the web app via Chrome, click the \"install app\" icon. After installation, you can find the app in your\napp drawer:\n\n<div id=\"pwa-screenshots-chrome-safari-desktop\" class=\"screenshots\">\n    <a href=\"../../static/img/pwa-install.png\"><img src=\"../../static/img/pwa-install.png\"/></a>\n    <a href=\"../../static/img/pwa.png\"><img src=\"../../static/img/pwa.png\"/></a> \n    <a href=\"../../static/img/pwa-badge.png\"><img src=\"../../static/img/pwa-badge.png\"/></a>\n</div>\n\n### Safari on macOS\nTo install and register the web app via Safari, click on the Share menu and click Add to Dock. You need to be on macOS Sonoma (14) or higher.\n\n<div id=\"pwa-screenshots-safari-desktop\" class=\"screenshots\">\n    <a href=\"../../static/img/pwa-install-macos-safari-add-to-dock.png\"><img src=\"../../static/img/pwa-install-macos-safari-add-to-dock.png\"/></a>\n</div>\n\n### Chrome/Firefox on Android\nFor Chrome on Android, either click the \"Add to Home Screen\" banner at the bottom of the screen, or select \"Install app\"\nin the menu, and then click \"Install\" in the popup menu. After installation, you can find the app in your app drawer, \nand on your home screen.\n\n<div id=\"pwa-screenshots-chrome-android\" class=\"screenshots\">\n    <a href=\"../../static/img/pwa-install-chrome-android.jpg\"><img src=\"../../static/img/pwa-install-chrome-android.jpg\"/></a>\n    <a href=\"../../static/img/pwa-install-chrome-android-menu.jpg\"><img src=\"../../static/img/pwa-install-chrome-android-menu.jpg\"/></a>\n    <a href=\"../../static/img/pwa-install-chrome-android-popup.jpg\"><img src=\"../../static/img/pwa-install-chrome-android-popup.jpg\"/></a>\n</div>\n\nFor Firefox, select \"Install\" in the menu, and then click \"Add\" to add an icon to your home screen:\n\n<div id=\"pwa-screenshots-firefox-android\" class=\"screenshots\">\n    <a href=\"../../static/img/pwa-install-firefox-android-menu.jpg\"><img src=\"../../static/img/pwa-install-firefox-android-menu.jpg\"/></a>\n    <a href=\"../../static/img/pwa-install-firefox-android-popup.jpg\"><img src=\"../../static/img/pwa-install-firefox-android-popup.jpg\"/></a>\n</div>\n\n### Safari on iOS\nOn iOS Safari, tap on the Share menu, then tap \"Add to Home Screen\":\n\n<div id=\"pwa-screenshots-safari-ios\" class=\"screenshots\">\n    <a href=\"../../static/img/pwa-install-safari-ios-button.jpg\"><img src=\"../../static/img/pwa-install-safari-ios-button.jpg\"/></a>\n    <a href=\"../../static/img/pwa-install-safari-ios-menu.jpg\"><img src=\"../../static/img/pwa-install-safari-ios-menu.jpg\"/></a>\n    <a href=\"../../static/img/pwa-install-safari-ios-add-icon.jpg\"><img src=\"../../static/img/pwa-install-safari-ios-add-icon.jpg\"/></a>\n</div>\n\n## Background notifications\nBackground notifications via web push are enabled by default and cannot be turned off when the app is installed, as notifications would\nnot be delivered reliably otherwise. You can mute topics you don't want to receive notifications for.\n\nOn desktop, you generally need either your browser or the web app open to receive notifications, though the ntfy tab doesn't need to be\nopen. On mobile, you don't need to have the web app open to receive notifications. Look at the [web docs](./web.md#background-notifications)\nfor a detailed breakdown.\n"
  },
  {
    "path": "docs/subscribe/web.md",
    "content": "# Subscribe from the web app\nThe web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app).\nTo subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic\nwill appear in the web app, and pop up as a notification.**\n\n<div id=\"subscribe-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-subscribe.png\"><img src=\"../../static/img/web-subscribe.png\"/></a> \n</div>\n\n## Publish messages\nTo learn how to send messages, check out the [publishing page](../publish.md).\n\n<div id=\"web-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-detail.png\"><img src=\"../../static/img/web-detail.png\"/></a> \n    <a href=\"../../static/img/web-notification.png\"><img src=\"../../static/img/web-notification.png\"/></a>\n</div>\n\n## Topic reservations\nIf topic reservations are enabled, you can claim ownership over topics and define access to it:\n\n<div id=\"reserve-screenshots\" class=\"screenshots\">\n    <a href=\"../../static/img/web-reserve-topic.png\"><img src=\"../../static/img/web-reserve-topic.png\"/></a> \n    <a href=\"../../static/img/web-reserve-topic-dialog.png\"><img src=\"../../static/img/web-reserve-topic-dialog.png\"/></a>\n</div>\n\n## Notification features and browser support\n\n- Emoji tags are supported in all browsers\n\n- [Click](../publish.md#click-action) actions are supported in all browsers\n\n- Only Chrome, Edge, and Opera support displaying view and http [actions](../publish.md#action-buttons) in notifications.\n\n  Their presentation is platform specific.\n  \n  Note that HTTP actions are performed using fetch and thus are limited to the [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)\n  rules, which means that any URL you include needs to respond to a [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request)\n  with headers allowing the origin of the ntfy web app (`Access-Control-Allow-Origin: https://ntfy.sh`) or `*`.\n\n- Only Chrome, Edge, and Opera support displaying [images](../publish.md#attachments) in notifications.\n\nLook at the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility)\nfor more info.\n\n## Background notifications\nWhile subscribing, you have the option to enable background notifications on supported browsers (see \"Settings\" tab).\n\nNote: If you add the web app to your homescreen (as a progressive web app, more info in the [installed web app](pwa.md)\ndocs), you cannot turn these off, as notifications would not be delivered reliably otherwise. You can mute topics you don't want to receive\nnotifications for.\n\n**If background notifications are off:** This requires an active ntfy tab to be open to receive notifications.\nThese are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser\nis allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications,\n**pinning the ntfy tab on your browser** is a good solution to leave it running.\n\n**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active\nntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the\nsame server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself.\n\nIf the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them\nby opening the app again, and will get a warning notification before they are paused.\n\n| Browser | Platform | Browser Running | Browser Not Running | Restrictions                                            |\n|---------|----------|-----------------|---------------------|---------------------------------------------------------|\n| Chrome  | Desktop  | ✅               | ❌                   |                                                         |\n| Firefox | Desktop  | ✅               | ❌                   |                                                         |\n| Edge    | Desktop  | ✅               | ❌                   |                                                         |\n| Opera   | Desktop  | ✅               | ❌                   |                                                         |\n| Safari  | Desktop  | ✅               | ✅                   | requires Safari 16.1, macOS 13 Ventura                  |\n| Chrome  | Android  | ✅               | ✅                   |                                                         |\n| Firefox | Android  | ✅               | ✅                   |                                                         |\n| Safari  | iOS      | ⚠️              | ⚠️                  | requires iOS 16.4, only when app is added to homescreen |\n\n(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info)\n"
  },
  {
    "path": "docs/terms.md",
    "content": "# Terms of Service\n\n**Last updated:** January 26, 2026\n\nPlease read these Terms of Service (\"Terms\") carefully before using the ntfy.sh website and service (the \"Service\") \noperated by ntfy LLC (\"us\", \"we\", or \"our\").\n\nYour access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These \nTerms apply to all visitors, users, and others who access or use the Service.\n\n**By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the \nTerms, you may not access the Service.**\n\n## Service description\n\nntfy (pronounced \"notify\") is a simple HTTP-based pub-sub notification service. It allows you to send push \nnotifications to your phone or desktop via scripts from any computer, using a REST API. The Service includes:\n\n- The ntfy.sh hosted server\n- The ntfy web application\n- The ntfy mobile applications (Android and iOS)\n- The ntfy command-line interface (CLI)\n\nThe server software and mobile applications are open source and can be [self-hosted](install.md). These Terms \napply specifically to the ntfy.sh hosted service.\n\n## Subscriptions and billing\n\n### Free tier\n\nYou may use the Service without creating an account or subscribing to a paid plan. Free usage is subject to \nrate limits and other restrictions as described in our documentation.\n\n### Paid plans\n\nSome features of the Service are available only through paid subscription plans (\"Subscriptions\"). You will \nbe billed in advance on a recurring basis (\"Billing Cycle\"). Billing cycles are available on a monthly or \nannual basis.\n\nAt the end of each Billing Cycle, your Subscription will automatically renew under the same conditions unless \nyou cancel it or we cancel it. You may cancel your Subscription renewal through your account settings in the \nweb application.\n\nA valid payment method is required to process payment for your Subscription. You shall provide us with accurate \nand complete billing information. By submitting such payment information, you authorize us to charge all \nSubscription fees incurred through your account to your payment method.\n\nPayment processing is handled by Stripe. Your payment information is subject to Stripe's \n[privacy policy](https://stripe.com/privacy) and [terms of service](https://stripe.com/legal).\n\nShould automatic billing fail to occur for any reason, we will retry the payment according to Stripe's retry \nschedule. If payment continues to fail after multiple attempts, your Subscription will be canceled and your \naccount will revert to the free tier.\n\n### Fee changes\n\nWe may, in our sole discretion and at any time, modify the Subscription fees for paid plans. Any fee change \nwill become effective at the end of the then-current Billing Cycle.\n\nWe will provide you with reasonable prior notice of any change in Subscription fees to give you an opportunity \nto cancel your Subscription before such change becomes effective.\n\nYour continued use of the Service after a fee change comes into effect constitutes your agreement to pay the \nmodified Subscription fee.\n\n## Refunds\n\nRefund requests for Subscriptions may be considered on a case-by-case basis and granted at the sole discretion \nof ntfy LLC. To request a refund, please contact us at [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh).\n\n## User accounts\n\nWhen you create an account with us, you must provide information that is accurate, complete, and current at \nall times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of \nyour account.\n\nYou are responsible for:\n\n- Safeguarding the password that you use to access the Service\n- Any activities or actions under your account, whether your password is with our Service or a third-party service\n- Keeping your account credentials confidential\n\nYou agree not to disclose your password to any third party. You must notify us immediately upon becoming aware \nof any breach of security or unauthorized use of your account.\n\nYou represent that you are at least 18 years old, or that you are at least the minimum age required to form\na binding contract in your jurisdiction, and have the legal authority to enter into these Terms.\n\n## Acceptable use\n\nYou agree not to use the Service to:\n\n- Send spam, unsolicited messages, or messages to recipients who have not consented to receive them\n- Distribute malware, viruses, or any other malicious software\n- Transmit illegal content or content that violates the rights of others\n- Harass, abuse, or harm another person or group\n- Impersonate any person or entity, or falsely state or misrepresent your affiliation with a person or entity\n- Interfere with or disrupt the Service or servers or networks connected to the Service\n- Attempt to gain unauthorized access to the Service, other accounts, or computer systems\n- Use the Service for any illegal purpose or in violation of any applicable laws or regulations\n- Circumvent rate limits or other technical restrictions\n- Use the Service in a manner that could reasonably be expected to impose an unreasonable or disproportionately\n  large load on our infrastructure\n\nWe reserve the right to investigate and take appropriate action against anyone who, in our sole discretion, \nviolates this provision, including removing content, terminating accounts, and reporting to law enforcement.\n\n### Topic names\n\nTopic names on ntfy.sh are public. If you use the Service without access controls, your topic name functions \nas a password. You are responsible for choosing topic names that cannot be easily guessed. We are not responsible \nfor any unauthorized access to messages published to easily guessable topic names.\n\nFor reserved topics and access control features, consider subscribing to a paid plan.\n\n## Intellectual property\n\n### Open source software\n\nThe ntfy server, web application, and mobile applications are open source software, dual-licensed under the \n[Apache License 2.0](https://github.com/binwiederhier/ntfy/blob/main/LICENSE) and \n[GPLv2](https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2). You are free to use, modify, and \ndistribute the software in accordance with these licenses.\n\n### Trademarks\n\nThe ntfy name, logo, and branding are trademarks of ntfy LLC. Our trademarks may not be used in connection \nwith any product or service without our prior written consent.\n\n### Your content\n\nYou retain ownership of any content you transmit through the Service. By using the Service, you grant us a \nlimited license to process and transmit your content solely for the purpose of providing the Service.\n\n## Service availability\n\nThe Service is provided on a \"best effort\" basis. We do not guarantee any specific uptime or availability.\n\nWe strive to maintain high availability, but the Service may be interrupted for maintenance, updates, or \ndue to circumstances beyond our control. We will make reasonable efforts to notify users of planned \nmaintenance when possible.\n\nFor applications requiring guaranteed uptime or specific service level agreements, we recommend \n[self-hosting your own ntfy server](install.md).\n\nA [status page](https://ntfy.statuspage.io/) is available to check the current operational status of the Service.\n\n## Third-party services\n\nThe Service relies on third-party services to provide certain functionality:\n\n- **Firebase Cloud Messaging (FCM)** - For push notifications to Android and iOS devices\n- **Twilio** - For phone call notifications\n- **Amazon SES** - For email notifications\n- **Stripe** - For payment processing\n\nYour use of these features is subject to the respective third-party terms and privacy policies. For more \ndetails, see our [privacy policy](privacy.md).\n\n## Links to other websites\n\nOur Service may contain links to third-party websites or services that are not owned or controlled by us.\n\nWe have no control over, and assume no responsibility for, the content, privacy policies, or practices of \nany third-party websites or services. You acknowledge and agree that we shall not be responsible or liable, \ndirectly or indirectly, for any damage or loss caused by or in connection with the use of any such content, \ngoods, or services available through any such websites or services.\n\n## Termination\n\nWe may terminate or suspend your account immediately, without prior notice or liability, for any reason \nwhatsoever, including without limitation if you breach these Terms.\n\nUpon termination, your right to use the Service will immediately cease. If you wish to terminate your account, \nyou may do so through your account settings or by simply discontinuing use of the Service.\n\nTermination of your account will result in the deletion of your account data in accordance with our \n[privacy policy](privacy.md).\n\nWe may retain certain data as required to comply with legal obligations, resolve disputes, and enforce our\nagreements, as described in our privacy policy.\n\n## Limitation of liability\n\nIn no event shall ntfy LLC, nor its owner, employees, partners, agents, suppliers, or affiliates, be liable \nfor any indirect, incidental, special, consequential, or punitive damages, including without limitation:\n\n- Loss of profits, data, use, goodwill, or other intangible losses\n- Damages resulting from your access to, use of, or inability to access or use the Service\n- Damages resulting from any conduct or content of any third party on the Service\n- Damages resulting from any content obtained from the Service\n- Damages resulting from unauthorized access, use, or alteration of your transmissions or content\n\nThis limitation applies whether based on warranty, contract, tort (including negligence), or any other legal \ntheory, whether or not we have been informed of the possibility of such damage, and even if a remedy set \nforth herein is found to have failed of its essential purpose.\n\n## Indemnification\n\nYou agree to defend, indemnify, and hold harmless ntfy LLC and its owner, employees, partners, agents, suppliers,\nand affiliates from and against any claims, damages, obligations, losses, liabilities, costs, or debt, and\nexpenses (including but not limited to attorney's fees) arising from:\n\n- Your use of and access to the Service\n- Your violation of any term of these Terms\n- Your violation of any applicable law or regulation\n- Your content, including any claim that your content infringes or misappropriates the rights of any third party\n\n## Disclaimer\n\nYour use of the Service is at your sole risk. The Service is provided on an \"AS IS\" and \"AS AVAILABLE\" basis, \nwithout warranties of any kind, whether express or implied, including but not limited to implied warranties of \nmerchantability, fitness for a particular purpose, non-infringement, or course of performance.\n\nntfy LLC does not warrant that:\n\n- The Service will function uninterrupted, secure, or available at any particular time or location\n- Any errors or defects will be corrected\n- The Service is free of viruses or other harmful components\n- The results of using the Service will meet your requirements\n- Messages will be delivered successfully or in a timely manner\n\nIf your use case requires guaranteed message delivery, high availability, or handling of sensitive data, we \nstrongly recommend [self-hosting your own ntfy server](install.md) where you have full control over the \ninfrastructure and data.\n\n## Governing law\n\nThese Terms shall be governed and construed in accordance with the laws of the State of Connecticut, United States,\nwithout regard to its conflict of law provisions.\n\nAny legal action or proceeding arising under these Terms shall be brought exclusively in the federal or state\ncourts located in Connecticut, and the parties hereby consent to personal jurisdiction and venue therein.\n\nOur failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. \nIf any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions \nof these Terms will remain in effect.\n\nThese Terms constitute the entire agreement between us regarding our Service and supersede any prior agreements \nwe might have had regarding the Service.\n\n## Changes to these Terms\n\nWe reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is \nmaterial, we will try to provide at least 30 days' notice prior to any new terms taking effect.\n\nWhat constitutes a material change will be determined at our sole discretion. Changes will be posted on this \npage with an updated \"Last updated\" date. You may also review all changes in the \n[Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/terms.md).\n\nBy continuing to access or use our Service after those revisions become effective, you agree to be bound by \nthe revised Terms. If you do not agree to the new Terms, please stop using the Service.\n\n## Contact\n\nIf you have any questions about these Terms, please see our [contact page](contact.md) or email us at \n[support@mail.ntfy.sh](mailto:support@mail.ntfy.sh).\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\nThis page lists a few suggestions of what to do when things don't work as expected. This is not a complete list. \nIf this page does not help, feel free to reach out via one of the channels listed on the [contact page](contact.md).\nWe're happy to help.\n\n## ntfy server\nIf you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing\nin the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately\nboils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file:\n\n=== \"server.yml (debug)\"\n    ``` yaml\n    log-level: debug\n    ```\n\n=== \"server.yml (trace)\"\n    ``` yaml\n    log-level: trace\n    ```\n\nIf you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace`\nto the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at\nthe logs using `journalctl -u ntfy -f`. The logs will look something like this:\n\n=== \"Example logs (debug)\"\n    ```\n    $ ntfy serve --debug\n    2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup)\n    2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)\n    2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)\n    2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)\n    2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00)\n    2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00)\n    2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00)\n    2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache)\n    ...    \n    ```\n\n=== \"Example logs (trace)\"\n    ```\n    $ ntfy serve --trace\n    2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup)\n    2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)\n    2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1\n    User-Agent: curl/7.81.0\n    Accept: */*\n    Content-Length: 2\n    Content-Type: application/x-www-form-urlencoded\n    \n    hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={\n      \"id\": \"Khaup1RVclU3\",\n      \"time\": 1679337659,\n      \"expires\": 1679380859,\n      \"event\": \"message\",\n      \"topic\": \"mytopic\",\n      \"message\": \"hi\"\n    }, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00)\n    2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache)\n    ...\n    ```\n\n## Android app\nOn Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log\nentries, which you can then copy or upload. \n\n<figure markdown>\n  ![Recording logs on Android](static/img/android-screenshot-logs.jpg){ width=400 }\n  <figcaption>Recording logs on Android</figcaption>\n</figure>\n\nWhen you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all\ntopics and hostnames with fruits. Here's an example:\n\n```\nThis is a log of the ntfy Android app. The log shows up to 1,000 entries.\nServer URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n\nDevice info:\n--\nntfy: 1.16.0 (play)\nOS: 4.19.157-perf+\nAndroid: 13 (SDK 33)\n...\n\nLogs\n--\n\n1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false\n1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service\n1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b)\n1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262\n1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START\n1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions\n1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})]\n1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})]\n...\n```\n\nTo get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb).\nAfter you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can\nget detailed logs like so:\n\n```\n# Connect to phone (enable Wireless debugging first)\nadb connect 192.168.1.137:39539\n\n# Print all logs; you may have to pass the -s option\nadb logcat\nadb -s 192.168.1.137:39539 logcat\n\n# Only list ntfy logs\nadb logcat --pid=$(adb shell pidof -s io.heckel.ntfy)\nadb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy)\n```\n\n## Web app\nThe web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your \nkeyboard.\n\n<figure markdown>\n  ![Web app logs](static/img/web-logs.png)\n  <figcaption>Web app logs in the developer console</figcaption>\n</figure>\n\n## iOS app\nSorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.\n\n## Other\n\n### \"Reconnecting...\" / Late notifications on mobile (self-hosted)\n\nIf all of your topics are showing as \"Reconnecting\" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:\n\n* If ntfy is behind a reverse proxy (such as Nginx):\n    * Make sure `behind-proxy` is enabled in ntfy's config.\n    * Make sure WebSockets are enabled in the reverse proxy config.\n* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.\n    * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.\n"
  },
  {
    "path": "examples/grafana-dashboard/ntfy-grafana.json",
    "content": "{\n  \"__inputs\": [\n    {\n      \"name\": \"DS_PROMETHEUS\",\n      \"label\": \"Prometheus\",\n      \"description\": \"\",\n      \"type\": \"datasource\",\n      \"pluginId\": \"prometheus\",\n      \"pluginName\": \"Prometheus\"\n    }\n  ],\n  \"__elements\": {},\n  \"__requires\": [\n    {\n      \"type\": \"grafana\",\n      \"id\": \"grafana\",\n      \"name\": \"Grafana\",\n      \"version\": \"9.4.3\"\n    },\n    {\n      \"type\": \"datasource\",\n      \"id\": \"prometheus\",\n      \"name\": \"Prometheus\",\n      \"version\": \"1.0.0\"\n    },\n    {\n      \"type\": \"panel\",\n      \"id\": \"stat\",\n      \"name\": \"Stat\",\n      \"version\": \"\"\n    },\n    {\n      \"type\": \"panel\",\n      \"id\": \"timeseries\",\n      \"name\": \"Time series\",\n      \"version\": \"\"\n    }\n  ],\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"target\": {\n          \"limit\": 100,\n          \"matchAny\": false,\n          \"tags\": [],\n          \"type\": \"dashboard\"\n        },\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": null,\n  \"links\": [],\n  \"liveNow\": false,\n  \"panels\": [\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 38,\n      \"panels\": [],\n      \"title\": \"Overview\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"light-green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 36,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"ntfy_messages_published_success{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Messages cached\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Published\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"orange\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 4,\n        \"y\": 1\n      },\n      \"id\": 33,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_messages_cached_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Messages cached\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Cached\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"#69bfb5\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 8,\n        \"y\": 1\n      },\n      \"id\": 31,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_visitors_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Visitors\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Visitors\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 12,\n        \"y\": 1\n      },\n      \"id\": 32,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_users_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Visitors\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Users\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"blue\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 16,\n        \"y\": 1\n      },\n      \"id\": 34,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_topics_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Topics\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Topics\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"purple\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 20,\n        \"y\": 1\n      },\n      \"id\": 35,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"textMode\": \"auto\"\n      },\n      \"pluginVersion\": \"9.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_subscribers_total\",\n          \"legendFormat\": \"Subscribers\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Subscribers\",\n      \"type\": \"stat\"\n    },\n    {\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 4\n      },\n      \"id\": 10,\n      \"title\": \"Metrics\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"Number of successfully published messages, and messages that could not be published (due to rate limiting, bad formatting, etc.)\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failed\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisColorMode\",\n                \"value\": \"text\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 0,\n        \"y\": 5\n      },\n      \"id\": 42,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_messages_published_success{job=\\\"$job\\\"}[$rate])\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_messages_published_failure{job=\\\"$job\\\"}[$rate])\",\n          \"hide\": false,\n          \"legendFormat\": \"Failed\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Messages published (per second)\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"Number of messages published since last ntfy server restart\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failed\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 6,\n        \"y\": 5\n      },\n      \"id\": 4,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_messages_published_success{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Successful\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_messages_published_failure{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Failed\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Messages published\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"Number of messages currently stored in message cache\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 12,\n        \"y\": 5\n      },\n      \"id\": 2,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_messages_cached_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Messages in database\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Messages cached\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 18,\n        \"y\": 5\n      },\n      \"id\": 14,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_visitors_total{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Visitors\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_topics_total{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Topics\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_subscribers_total{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Subscribers\",\n          \"range\": true,\n          \"refId\": \"C\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_users_total{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Users\",\n          \"range\": true,\n          \"refId\": \"D\"\n        }\n      ],\n      \"title\": \"Visitors, subscribers, topics\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 0,\n        \"y\": 12\n      },\n      \"id\": 43,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(job) (rate(ntfy_http_requests_total{job=\\\"$job\\\"}[$rate]))\",\n          \"legendFormat\": \"Requests per second\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"HTTP requests (per second)\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 9,\n        \"x\": 6,\n        \"y\": 12\n      },\n      \"id\": 41,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"sortBy\": \"Mean\",\n          \"sortDesc\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(http_code) (rate(ntfy_http_requests_total{job=\\\"$job\\\", http_code!=\\\"200\\\", http_code!=\\\"429\\\", http_code!=\\\"507\\\"}[$rate]))\",\n          \"legendFormat\": \"{{http_code}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"HTTP errors (per second, excl. 429/507)\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 9,\n        \"x\": 15,\n        \"y\": 12\n      },\n      \"id\": 16,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"sortBy\": \"Mean\",\n          \"sortDesc\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(ntfy_code) (rate(ntfy_http_requests_total{http_code!=\\\"200\\\", job=\\\"$job\\\"}[$rate]))\",\n          \"legendFormat\": \"{{http_method}} {{http_code}} {{ntfy_code}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"HTTP errors (per second, ntfy code)\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 0,\n        \"y\": 19\n      },\n      \"id\": 20,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_attachments_total_size{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Total size in MB\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Attachments: Total cache size\",\n      \"transformations\": [],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": -1,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failure\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 6,\n        \"y\": 19\n      },\n      \"id\": 27,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_firebase_published_success{job=\\\"$job\\\"}[$rate])\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_firebase_published_failure{job=\\\"$job\\\"}[$rate])\",\n          \"hide\": false,\n          \"legendFormat\": \"Failure\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Firebase messages sent\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Rejected (HTTP 507)\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 12,\n        \"y\": 19\n      },\n      \"id\": 26,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_unifiedpush_published_success{job=\\\"$job\\\"}[$rate])\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_http_requests_total{job=\\\"$job\\\",http_code=\\\"507\\\"}[$rate])\",\n          \"hide\": false,\n          \"legendFormat\": \"Rejected (HTTP 507)\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"UnifiedPush messages\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failure\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 18,\n        \"y\": 19\n      },\n      \"id\": 24,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_matrix_published_success{job=\\\"$job\\\"}[$rate])\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(ntfy_matrix_published_failure{job=\\\"$job\\\"}[$rate])\",\n          \"hide\": false,\n          \"legendFormat\": \"Failure\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Matrix messages published\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failure\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 0,\n        \"y\": 26\n      },\n      \"id\": 12,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_emails_sent_success{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_emails_sent_failure{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Failure\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Emails sent\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Failure\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 6,\n        \"y\": 26\n      },\n      \"id\": 22,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_emails_received_success{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Success\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_emails_received_failure{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Failure\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Emails received\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"ms\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 12,\n        \"y\": 26\n      },\n      \"id\": 29,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"ntfy_message_publish_duration_ms{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Duration\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Message publish duration\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 33\n      },\n      \"id\": 8,\n      \"panels\": [],\n      \"title\": \"Internals\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 0,\n        \"y\": 34\n      },\n      \"id\": 6,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"go_goroutines{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Go routines\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Go routines\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"log\": 10,\n              \"type\": \"symlog\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"none\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 6,\n        \"y\": 34\n      },\n      \"id\": 44,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"process_open_fds{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Open\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"process_max_fds{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Max\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"File descriptors\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 6,\n        \"x\": 12,\n        \"y\": 34\n      },\n      \"id\": 45,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"process_resident_memory_bytes{job=\\\"$job\\\"}\",\n          \"legendFormat\": \"Resident memory used by ntfy (RSS)\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"process_virtual_memory_bytes{job=\\\"$job\\\"}\",\n          \"hide\": false,\n          \"legendFormat\": \"Virtual memory used by ntfy (VSS)\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Resident/virtual memory\",\n      \"type\": \"timeseries\"\n    }\n  ],\n  \"refresh\": \"10s\",\n  \"revision\": 1,\n  \"schemaVersion\": 38,\n  \"style\": \"dark\",\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {},\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"${DS_PROMETHEUS}\"\n        },\n        \"definition\": \"label_values(ntfy_visitors_total, job)\",\n        \"hide\": 0,\n        \"includeAll\": false,\n        \"label\": \"Job\",\n        \"multi\": false,\n        \"name\": \"job\",\n        \"options\": [],\n        \"query\": {\n          \"query\": \"label_values(ntfy_visitors_total, job)\",\n          \"refId\": \"StandardVariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"skipUrlSync\": false,\n        \"sort\": 0,\n        \"type\": \"query\"\n      },\n      {\n        \"auto\": false,\n        \"auto_count\": 30,\n        \"auto_min\": \"10s\",\n        \"current\": {\n          \"selected\": false,\n          \"text\": \"30m\",\n          \"value\": \"30m\"\n        },\n        \"description\": \"Average per-second rates over values from this time span\",\n        \"hide\": 0,\n        \"label\": \"Rate\",\n        \"name\": \"rate\",\n        \"options\": [\n          {\n            \"selected\": false,\n            \"text\": \"1m\",\n            \"value\": \"1m\"\n          },\n          {\n            \"selected\": false,\n            \"text\": \"5m\",\n            \"value\": \"5m\"\n          },\n          {\n            \"selected\": false,\n            \"text\": \"10m\",\n            \"value\": \"10m\"\n          },\n          {\n            \"selected\": true,\n            \"text\": \"30m\",\n            \"value\": \"30m\"\n          },\n          {\n            \"selected\": false,\n            \"text\": \"1h\",\n            \"value\": \"1h\"\n          }\n        ],\n        \"query\": \"1m,5m,10m,30m,1h\",\n        \"queryValue\": \"\",\n        \"refresh\": 2,\n        \"skipUrlSync\": false,\n        \"type\": \"interval\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-24h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"ntfy App\",\n  \"uid\": \"TO6HgexVz\",\n  \"version\": 24,\n  \"weekStart\": \"\"\n}"
  },
  {
    "path": "examples/linux-desktop-notifications/notify-desktop.sh",
    "content": "#!/bin/bash\n# This is an example shell script showing how to consume a ntfy.sh topic using\n# a simple script. The notify-send command sends any arriving message as a desktop notification.\n\nTOPIC_URL=ntfy.sh/mytopic\n\nwhile read msg; do\n  [ -n \"$msg\" ] && notify-send \"$msg\"\ndone < <(stdbuf -i0 -o0 curl -s $TOPIC_URL/raw)\n"
  },
  {
    "path": "examples/publish-go/main.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc main() {\n\t// Without additional headers (priority, tags, title), it's a one liner.\n\t// Check out https://ntfy.sh/mytopic in your browser after running this.\n\thttp.Post(\"https://ntfy.sh/mytopic\", \"text/plain\", strings.NewReader(\"Backup successful 😀\"))\n\n\t// If you'd like to add title, priority, or tags, it's a little harder.\n\t// Check out https://ntfy.sh/phil_alerts in your browser.\n\treq, err := http.NewRequest(\"POST\", \"https://ntfy.sh/phil_alerts\",\n\t\tstrings.NewReader(\"Remote access to phils-laptop detected. Act right away.\"))\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\treq.Header.Set(\"Title\", \"Unauthorized access detected\")\n\treq.Header.Set(\"Priority\", \"urgent\")\n\treq.Header.Set(\"Tags\", \"warning,skull\")\n\tif _, err := http.DefaultClient.Do(req); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "examples/publish-php/publish.php",
    "content": "<?php\n\n// Check out https://ntfy.sh/phil_alerts in your browser after running this.\nfile_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([\n    'http' => [\n        'method' => 'POST', // PUT also works\n        'header' =>\n            \"Content-Type: text/plain\\r\\n\" .\n            \"Title: Unauthorized access detected\\r\\n\" .\n            \"Priority: urgent\\r\\n\" .\n            \"Tags: warning,skull\",\n        'content' => 'Remote access to phils-laptop detected. Act right away.'\n    ]\n]));\n"
  },
  {
    "path": "examples/publish-python/publish.py",
    "content": "#!/usr/bin/env python3\n\nimport requests\n\nresp = requests.get(\"https://ntfy.sh/mytopic/trigger\",\n    data=\"Backup successful 😀\".encode(encoding='utf-8'),\n    headers={\n        \"Priority\": \"high\",\n        \"Tags\": \"warning,skull\",\n        \"Title\": \"Hello there\"\n    })\nresp.raise_for_status()\n"
  },
  {
    "path": "examples/ssh-login-alert/ntfy-ssh-login.sh",
    "content": "#!/bin/bash\n# This is a PAM script hook that shows how to notify you when\n# somebody logs into your server. Place at /usr/local/bin/ntfy-ssh-login.sh (with chmod +x!).\n\nTOPIC_URL=ntfy.sh/alerts\n\nif [ \"${PAM_TYPE}\" = \"open_session\" ]; then\n  curl -H tags:warning -H prio:high -d \"SSH login to $(hostname): ${PAM_USER} from ${PAM_RHOST}\" \"${TOPIC_URL}\"\nfi\n"
  },
  {
    "path": "examples/ssh-login-alert/pam_sshd",
    "content": "# PAM config file snippet\n#\n# Put this snippet AT THE END of the file /etc/pam.d/sshd\n# See https://geekthis.net/post/run-scripts-after-ssh-authentication/ for details.\n\n# (lots of stuff here ...)\n\nsession optional pam_exec.so /usr/local/bin/ntfy-ssh-login.sh\n"
  },
  {
    "path": "examples/subscribe-go/main.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"log\"\n\t\"net/http\"\n)\n\nfunc main() {\n\tresp, err := http.Get(\"https://ntfy.sh/phil_alerts/json\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer resp.Body.Close()\n\tscanner := bufio.NewScanner(resp.Body)\n\tfor scanner.Scan() {\n\t\tprintln(scanner.Text())\n\t}\n}\n"
  },
  {
    "path": "examples/subscribe-php/subscribe.php",
    "content": "<?php\n\n$fp = fopen('https://ntfy.sh/phil_alerts/json', 'r');\nif (!$fp) {\n    die('cannot open stream');\n}\nwhile (!feof($fp)) {\n    $buffer = fgets($fp, 2048);\n    echo $buffer;\n    flush();\n}\nfclose($fp);\n"
  },
  {
    "path": "examples/subscribe-python/subscribe.py",
    "content": "#!/usr/bin/env python3\n\nimport requests\n\nresp = requests.get(\"https://ntfy.sh/mytopic/json\", stream=True)\nfor line in resp.iter_lines():\n    if line:\n        print(line)\n"
  },
  {
    "path": "examples/web-example-eventsource/example-sse.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>ntfy.sh: EventSource Example</title>\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <style>\n        body { font-size: 1.2em; line-height: 130%; }\n        #events { font-family: monospace; }\n    </style>\n</head>\n<body>\n<h1>ntfy.sh: EventSource Example</h1>\n<p>\n    This is an example showing how to use <a href=\"https://ntfy.sh\">ntfy.sh</a> with\n    <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/EventSource\">EventSource</a>.<br/>\n    This example doesn't need a server. You can just save the HTML page and run it from anywhere.\n</p>\n<button id=\"publishButton\">Send test notification</button>\n<p><b>Log:</b></p>\n<div id=\"events\"></div>\n\n<script type=\"text/javascript\">\n    const publishURL = `https://ntfy.sh/example`;\n    const subscribeURL = `https://ntfy.sh/example/sse`;\n    const events = document.getElementById('events');\n    const eventSource = new EventSource(subscribeURL);\n\n    // Publish button\n    document.getElementById(\"publishButton\").onclick = () => {\n        fetch(publishURL, {\n            method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!\n            body: `It is ${new Date().toString()}. This is a test.`\n        })\n    };\n\n    // Incoming events\n    eventSource.onopen = () => {\n        let event = document.createElement('div');\n        event.innerHTML = `EventSource connected to ${subscribeURL}`;\n        events.appendChild(event);\n    };\n    eventSource.onerror = (e) => {\n        let event = document.createElement('div');\n        event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;\n        events.appendChild(event);\n    };\n    eventSource.onmessage = (e) => {\n        let event = document.createElement('div');\n        event.innerHTML = e.data;\n        events.appendChild(event);\n    };\n</script>\n\n</body>\n</html>\n"
  },
  {
    "path": "examples/web-example-websocket/example-ws.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>ntfy.sh: WebSocket Example</title>\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <style>\n        body { font-size: 1.2em; line-height: 130%; }\n        #events { font-family: monospace; }\n    </style>\n</head>\n<body>\n<h1>ntfy.sh: WebSocket Example</h1>\n<p>\n    This is an example showing how to use <a href=\"https://ntfy.sh\">ntfy.sh</a> with\n    <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/WebSocket\">WebSocket</a>.<br/>\n    This example doesn't need a server. You can just save the HTML page and run it from anywhere.\n</p>\n<button id=\"publishButton\">Send test notification</button>\n<p><b>Log:</b></p>\n<div id=\"events\"></div>\n\n<script type=\"text/javascript\">\n    const publishURL = `https://ntfy.sh/example`;\n    const subscribeURL = `wss://ntfy.sh/example/ws`;\n    const events = document.getElementById('events');\n    const websocket = new WebSocket(subscribeURL);\n\n    // Publish button\n    document.getElementById(\"publishButton\").onclick = () => {\n        fetch(publishURL, {\n            method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!\n            body: `It is ${new Date().toString()}. This is a test.`\n        })\n    };\n\n    // Incoming events\n    websocket.onopen = () => {\n        let event = document.createElement('div');\n        event.innerHTML = `WebSocket connected to ${subscribeURL}`;\n        events.appendChild(event);\n    };\n    websocket.onerror = (e) => {\n        let event = document.createElement('div');\n        event.innerHTML = `WebSocket error: Failed to connect to ${subscribeURL}`;\n        events.appendChild(event);\n    };\n    websocket.onmessage = (e) => {\n        let event = document.createElement('div');\n        event.innerHTML = e.data;\n        events.appendChild(event);\n    };\n</script>\n\n</body>\n</html>\n"
  },
  {
    "path": "go.mod",
    "content": "module heckel.io/ntfy/v2\n\ngo 1.25.0\n\nrequire (\n\tcloud.google.com/go/firestore v1.21.0 // indirect\n\tcloud.google.com/go/storage v1.61.3 // indirect\n\tgithub.com/BurntSushi/toml v1.6.0 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/emersion/go-smtp v0.18.0\n\tgithub.com/gabriel-vasile/mimetype v1.4.13\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/mattn/go-sqlite3 v1.14.37\n\tgithub.com/olebedev/when v1.1.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/urfave/cli/v2 v2.27.7\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/oauth2 v0.36.0 // indirect\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/term v0.41.0\n\tgolang.org/x/time v0.15.0\n\tgoogle.golang.org/api v0.272.0\n\tgopkg.in/yaml.v2 v2.4.0\n)\n\nreplace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pin version due to breaking changes, see #839\n\nrequire github.com/pkg/errors v0.9.1 // indirect\n\nrequire (\n\tfirebase.google.com/go/v4 v4.19.0\n\tgithub.com/SherClockHolmes/webpush-go v1.4.0\n\tgithub.com/jackc/pgx/v5 v5.8.0\n\tgithub.com/microcosm-cc/bluemonday v1.0.27\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/stripe/stripe-go/v74 v74.30.0\n\tgolang.org/x/sys v0.42.0\n\tgolang.org/x/text v0.35.0\n)\n\nrequire (\n\tcel.dev/expr v0.25.1 // indirect\n\tcloud.google.com/go v0.123.0 // indirect\n\tcloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tcloud.google.com/go/compute/metadata v0.9.0 // indirect\n\tcloud.google.com/go/iam v1.5.3 // indirect\n\tcloud.google.com/go/longrunning v0.8.0 // indirect\n\tcloud.google.com/go/monitoring v1.24.3 // indirect\n\tgithub.com/AlekSi/pointer v1.2.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect\n\tgithub.com/MicahParks/keyfunc v1.9.0 // indirect\n\tgithub.com/aymerick/douceur v0.2.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect\n\tgithub.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect\n\tgithub.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-jose/go-jose/v4 v4.1.3 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/golang-jwt/jwt/v4 v4.5.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1 // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.19.0 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.67.5 // indirect\n\tgithub.com/prometheus/procfs v0.20.1 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/spiffe/go-spiffe/v2 v2.6.0 // indirect\n\tgithub.com/stretchr/objx v0.5.2 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect\n\tgo.opentelemetry.io/otel v1.42.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.42.0 // indirect\n\tgo.opentelemetry.io/otel/sdk v1.42.0 // indirect\n\tgo.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.42.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.4 // indirect\n\tgolang.org/x/net v0.52.0 // indirect\n\tgoogle.golang.org/appengine/v2 v2.0.6 // indirect\n\tgoogle.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect\n\tgoogle.golang.org/grpc v1.79.3 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=\ncel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=\ncloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=\ncloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=\ncloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg=\ncloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=\ncloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=\ncloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=\ncloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=\ncloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=\ncloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=\ncloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=\ncloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=\ncloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=\ncloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=\ncloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=\ncloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=\ncloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=\ncloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=\ncloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=\ncloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=\nfirebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=\nfirebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=\ngithub.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=\ngithub.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=\ngithub.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=\ngithub.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=\ngithub.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=\ngithub.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=\ngithub.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=\ngithub.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=\ngithub.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=\ngithub.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=\ngithub.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=\ngithub.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=\ngithub.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=\ngithub.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=\ngithub.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=\ngithub.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=\ngithub.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=\ngithub.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=\ngithub.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=\ngithub.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=\ngithub.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=\ngithub.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=\ngithub.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=\ngithub.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=\ngithub.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=\ngithub.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=\ngithub.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=\ngithub.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=\ngithub.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=\ngithub.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=\ngithub.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=\ngithub.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=\ngithub.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=\ngithub.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=\ngithub.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=\ngithub.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=\ngithub.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=\ngithub.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=\ngithub.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=\ngithub.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=\ngithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=\ngithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=\ngo.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=\ngo.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=\ngo.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=\ngo.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=\ngo.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=\ngo.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=\ngo.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=\ngo.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=\ngo.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=\ngo.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=\ngo.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=\ngo.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=\ngo.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=\ngo.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=\ngolang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=\ngoogle.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=\ngoogle.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=\ngoogle.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=\ngoogle.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=\ngoogle.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=\ngoogle.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "log/event.go",
    "content": "package log\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"log\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tfieldTag       = \"tag\"\n\tfieldError     = \"error\"\n\tfieldTimeTaken = \"time_taken_ms\"\n\tfieldExitCode  = \"exit_code\"\n\ttagStdLog      = \"stdlog\"\n)\n\n// Event represents a single log event\ntype Event struct {\n\tTimestamp  string `json:\"time\"`\n\tLevel      Level  `json:\"level\"`\n\tMessage    string `json:\"message\"`\n\ttime       time.Time\n\tcontexters []Contexter\n\tfields     Context\n}\n\n// newEvent creates a new log event\n//\n// We delay allocations and processing for efficiency, because most log events\n// are never actually rendered, so we don't format the time, or allocate a fields map.\nfunc newEvent() *Event {\n\treturn &Event{\n\t\ttime: time.Now(),\n\t}\n}\n\n// Fatal logs the event as FATAL, and exits the program with exit code 1\nfunc (e *Event) Fatal(message string, v ...any) {\n\te.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)\n\tfmt.Fprintf(os.Stderr, message+\"\\n\", v...) // Always output error to stderr\n\tos.Exit(1)\n}\n\n// Error logs the event with log level error\nfunc (e *Event) Error(message string, v ...any) *Event {\n\treturn e.Log(ErrorLevel, message, v...)\n}\n\n// Warn logs the event with log level warn\nfunc (e *Event) Warn(message string, v ...any) *Event {\n\treturn e.Log(WarnLevel, message, v...)\n}\n\n// Info logs the event with log level info\nfunc (e *Event) Info(message string, v ...any) *Event {\n\treturn e.Log(InfoLevel, message, v...)\n}\n\n// Debug logs the event with log level debug\nfunc (e *Event) Debug(message string, v ...any) *Event {\n\treturn e.Log(DebugLevel, message, v...)\n}\n\n// Trace logs the event with log level trace\nfunc (e *Event) Trace(message string, v ...any) *Event {\n\treturn e.Log(TraceLevel, message, v...)\n}\n\n// Tag adds a \"tag\" field to the log event\nfunc (e *Event) Tag(tag string) *Event {\n\treturn e.Field(fieldTag, tag)\n}\n\n// Time sets the time field\nfunc (e *Event) Time(t time.Time) *Event {\n\te.time = t\n\treturn e\n}\n\n// Timing runs f and records the time if took to execute it in \"time_taken_ms\"\nfunc (e *Event) Timing(f func()) *Event {\n\tstart := time.Now()\n\tf()\n\treturn e.Field(fieldTimeTaken, time.Since(start).Milliseconds())\n}\n\n// Err adds an \"error\" field to the log event\nfunc (e *Event) Err(err error) *Event {\n\tif err == nil {\n\t\treturn e\n\t} else if c, ok := err.(Contexter); ok {\n\t\treturn e.With(c)\n\t}\n\treturn e.Field(fieldError, err.Error())\n}\n\n// Field adds a custom field and value to the log event\nfunc (e *Event) Field(key string, value any) *Event {\n\tif e.fields == nil {\n\t\te.fields = make(Context)\n\t}\n\te.fields[key] = value\n\treturn e\n}\n\n// FieldIf adds a custom field and value to the log event if the given level is loggable\nfunc (e *Event) FieldIf(key string, value any, level Level) *Event {\n\tif e.Loggable(level) {\n\t\treturn e.Field(key, value)\n\t}\n\treturn e\n}\n\n// Fields adds a map of fields to the log event\nfunc (e *Event) Fields(fields Context) *Event {\n\tif e.fields == nil {\n\t\te.fields = make(Context)\n\t}\n\tfor k, v := range fields {\n\t\te.fields[k] = v\n\t}\n\treturn e\n}\n\n// With adds the fields of the given Contexter structs to the log event by calling their Context method\nfunc (e *Event) With(contexters ...Contexter) *Event {\n\tif e.contexters == nil {\n\t\te.contexters = contexters\n\t} else {\n\t\te.contexters = append(e.contexters, contexters...)\n\t}\n\treturn e\n}\n\n// Render returns the rendered log event as a string, or an empty string. The event is only rendered,\n// if either the global log level is >= l, or if the log level in one of the overrides matches\n// the level.\n//\n// If no overrides are defined (default), the Contexter array is not applied unless the event\n// is actually logged. If overrides are defined, then Contexters have to be applied in any case\n// to determine if they match. This is super complicated, but required for efficiency.\nfunc (e *Event) Render(l Level, message string, v ...any) string {\n\tappliedContexters := e.maybeApplyContexters()\n\tif !e.Loggable(l) {\n\t\treturn \"\"\n\t}\n\te.Message = fmt.Sprintf(message, v...)\n\te.Level = l\n\te.Timestamp = util.FormatTime(e.time)\n\tif !appliedContexters {\n\t\te.applyContexters()\n\t}\n\tif CurrentFormat() == JSONFormat {\n\t\treturn e.JSON()\n\t}\n\treturn e.String()\n}\n\n// Log logs the event to the defined output, or does nothing if Render returns an empty string\nfunc (e *Event) Log(l Level, message string, v ...any) *Event {\n\tif m := e.Render(l, message, v...); m != \"\" {\n\t\tlog.Println(m)\n\t}\n\treturn e\n}\n\n// Loggable returns true if the given log level is lower or equal to the current log level\nfunc (e *Event) Loggable(l Level) bool {\n\treturn e.globalLevelWithOverride() <= l\n}\n\n// IsTrace returns true if the current log level is TraceLevel\nfunc (e *Event) IsTrace() bool {\n\treturn e.Loggable(TraceLevel)\n}\n\n// IsDebug returns true if the current log level is DebugLevel or below\nfunc (e *Event) IsDebug() bool {\n\treturn e.Loggable(DebugLevel)\n}\n\n// JSON returns the event as a JSON representation\nfunc (e *Event) JSON() string {\n\tb, _ := json.Marshal(e)\n\ts := string(b)\n\tif len(e.fields) > 0 {\n\t\tb, _ := json.Marshal(e.fields)\n\t\ts = fmt.Sprintf(\"{%s,%s}\", s[1:len(s)-1], string(b[1:len(b)-1]))\n\t}\n\treturn s\n}\n\n// String returns the event as a string\nfunc (e *Event) String() string {\n\tif len(e.fields) == 0 {\n\t\treturn fmt.Sprintf(\"%s %s\", e.Level.String(), e.Message)\n\t}\n\tfields := make([]string, 0)\n\tfor k, v := range e.fields {\n\t\tfields = append(fields, fmt.Sprintf(\"%s=%v\", k, v))\n\t}\n\tsort.Strings(fields)\n\treturn fmt.Sprintf(\"%s %s (%s)\", e.Level.String(), e.Message, strings.Join(fields, \", \"))\n}\n\nfunc (e *Event) globalLevelWithOverride() Level {\n\tmu.RLock()\n\tl, ov := level, overrides\n\tmu.RUnlock()\n\tif e.fields == nil {\n\t\treturn l\n\t}\n\tfor field, fieldOverrides := range ov {\n\t\tvalue, exists := e.fields[field]\n\t\tif exists {\n\t\t\tfor _, o := range fieldOverrides {\n\t\t\t\tif o.value == \"\" || o.value == value || o.value == fmt.Sprintf(\"%v\", value) {\n\t\t\t\t\treturn o.level\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn l\n}\n\nfunc (e *Event) maybeApplyContexters() bool {\n\tmu.RLock()\n\thasOverrides := len(overrides) > 0\n\tmu.RUnlock()\n\tif hasOverrides {\n\t\te.applyContexters()\n\t}\n\treturn hasOverrides // = applied\n}\n\nfunc (e *Event) applyContexters() {\n\tfor _, c := range e.contexters {\n\t\te.Fields(c.Context())\n\t}\n}\n"
  },
  {
    "path": "log/log.go",
    "content": "package log\n\nimport (\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// Defaults for package level variables\nvar (\n\tDefaultLevel  = InfoLevel\n\tDefaultFormat = TextFormat\n\tDefaultOutput = &peekLogWriter{os.Stderr}\n)\n\nvar (\n\tlevel               = DefaultLevel\n\tformat              = DefaultFormat\n\toverrides           = make(map[string][]*levelOverride)\n\toutput    io.Writer = DefaultOutput\n\tfilename            = \"\"\n\tmu                  = &sync.RWMutex{}\n)\n\n// init sets the default log output (including log.SetOutput)\n//\n// This has to be explicitly called, because DefaultOutput is a peekLogWriter,\n// which wraps os.Stderr.\nfunc init() {\n\tSetOutput(DefaultOutput)\n}\n\n// Fatal prints the given message, and exits the program\nfunc Fatal(message string, v ...any) {\n\tnewEvent().Fatal(message, v...)\n}\n\n// Error prints the given message, if the current log level is ERROR or lower\nfunc Error(message string, v ...any) {\n\tnewEvent().Error(message, v...)\n}\n\n// Warn prints the given message, if the current log level is WARN or lower\nfunc Warn(message string, v ...any) {\n\tnewEvent().Warn(message, v...)\n}\n\n// Info prints the given message, if the current log level is INFO or lower\nfunc Info(message string, v ...any) {\n\tnewEvent().Info(message, v...)\n}\n\n// Debug prints the given message, if the current log level is DEBUG or lower\nfunc Debug(message string, v ...any) {\n\tnewEvent().Debug(message, v...)\n}\n\n// Trace prints the given message, if the current log level is TRACE\nfunc Trace(message string, v ...any) {\n\tnewEvent().Trace(message, v...)\n}\n\n// With creates a new log event and adds the fields of the given Contexter structs\nfunc With(contexts ...Contexter) *Event {\n\treturn newEvent().With(contexts...)\n}\n\n// Field creates a new log event and adds a custom field and value to it\nfunc Field(key string, value any) *Event {\n\treturn newEvent().Field(key, value)\n}\n\n// Fields creates a new log event and adds a map of fields to it\nfunc Fields(fields Context) *Event {\n\treturn newEvent().Fields(fields)\n}\n\n// Tag creates a new log event and adds a \"tag\" field to it\nfunc Tag(tag string) *Event {\n\treturn newEvent().Tag(tag)\n}\n\n// Time creates a new log event and sets the time field\nfunc Time(time time.Time) *Event {\n\treturn newEvent().Time(time)\n}\n\n// Timing runs f and records the time if took to execute it in \"time_taken_ms\"\nfunc Timing(f func()) *Event {\n\treturn newEvent().Timing(f)\n}\n\n// CurrentLevel returns the current log level\nfunc CurrentLevel() Level {\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\treturn level\n}\n\n// SetLevel sets a new log level\nfunc SetLevel(newLevel Level) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tlevel = newLevel\n}\n\n// SetLevelOverride adds a log override for the given field\nfunc SetLevelOverride(field string, value string, level Level) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tif _, ok := overrides[field]; !ok {\n\t\toverrides[field] = make([]*levelOverride, 0)\n\t}\n\toverrides[field] = append(overrides[field], &levelOverride{value: value, level: level})\n}\n\n// ResetLevelOverrides removes all log level overrides\nfunc ResetLevelOverrides() {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\toverrides = make(map[string][]*levelOverride)\n}\n\n// CurrentFormat returns the current log format\nfunc CurrentFormat() Format {\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\treturn format\n}\n\n// SetFormat sets a new log format\nfunc SetFormat(newFormat Format) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tformat = newFormat\n\tif newFormat == JSONFormat {\n\t\tDisableDates()\n\t}\n}\n\n// SetOutput sets the log output writer\nfunc SetOutput(w io.Writer) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\toutput = &peekLogWriter{w}\n\tif f, ok := w.(*os.File); ok {\n\t\tfilename = f.Name()\n\t} else {\n\t\tfilename = \"\"\n\t}\n\tlog.SetOutput(output)\n}\n\n// File returns the log file, if any, or an empty string otherwise\nfunc File() string {\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\treturn filename\n}\n\n// IsFile returns true if the output is a non-default file\nfunc IsFile() bool {\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\treturn filename != \"\"\n}\n\n// DisableDates disables the date/time prefix\nfunc DisableDates() {\n\tlog.SetFlags(0)\n}\n\n// Loggable returns true if the given log level is lower or equal to the current log level\nfunc Loggable(l Level) bool {\n\treturn CurrentLevel() <= l\n}\n\n// IsTrace returns true if the current log level is TraceLevel\nfunc IsTrace() bool {\n\treturn Loggable(TraceLevel)\n}\n\n// IsDebug returns true if the current log level is DebugLevel or below\nfunc IsDebug() bool {\n\treturn Loggable(DebugLevel)\n}\n\n// peekLogWriter is an io.Writer which will peek at the rendered log event,\n// and ensure that the rendered output is valid JSON. This is a hack!\ntype peekLogWriter struct {\n\tw io.Writer\n}\n\nfunc (w *peekLogWriter) Write(p []byte) (n int, err error) {\n\tif len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {\n\t\treturn w.w.Write(p)\n\t}\n\tm := newEvent().Tag(tagStdLog).Render(InfoLevel, \"%s\", strings.TrimSpace(string(p)))\n\tif m == \"\" {\n\t\treturn 0, nil\n\t}\n\treturn w.w.Write([]byte(m + \"\\n\"))\n}\n"
  },
  {
    "path": "log/log_test.go",
    "content": "package log\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"github.com/stretchr/testify/require\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestMain(m *testing.M) {\n\texitCode := m.Run()\n\tresetState()\n\tSetLevel(ErrorLevel) // For other modules!\n\tos.Exit(exitCode)\n}\n\nfunc TestLog_TagContextFieldFields(t *testing.T) {\n\tt.Cleanup(resetState)\n\tv := &fakeVisitor{\n\t\tUserID: \"u_abc\",\n\t\tIP:     \"1.2.3.4\",\n\t}\n\terr := &fakeError{\n\t\tCode:    123,\n\t\tMessage: \"some error\",\n\t}\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\tSetLevelOverride(\"tag\", \"stripe\", DebugLevel)\n\tSetLevelOverride(\"number\", \"5\", DebugLevel)\n\n\tTag(\"mytag\").\n\t\tField(\"field2\", 123).\n\t\tField(\"field1\", \"value1\").\n\t\tTime(time.Unix(123, 999000000).UTC()).\n\t\tInfo(\"hi there %s\", \"phil\")\n\n\tTag(\"not-stripe\").\n\t\tDebug(\"this message will not appear\")\n\n\tWith(v).\n\t\tFields(Context{\n\t\t\t\"stripe_customer_id\":     \"acct_123\",\n\t\t\t\"stripe_subscription_id\": \"sub_123\",\n\t\t}).\n\t\tTag(\"stripe\").\n\t\tErr(err).\n\t\tTime(time.Unix(456, 123000000).UTC()).\n\t\tDebug(\"Subscription status %s\", \"active\")\n\n\tField(\"number\", 5).\n\t\tTime(time.Unix(777, 001000000).UTC()).\n\t\tDebug(\"The number 5 is an int, but the level override is a string\")\n\n\texpected := `{\"time\":\"1970-01-01T00:02:03.999Z\",\"level\":\"INFO\",\"message\":\"hi there phil\",\"field1\":\"value1\",\"field2\":123,\"tag\":\"mytag\"}\n{\"time\":\"1970-01-01T00:07:36.123Z\",\"level\":\"DEBUG\",\"message\":\"Subscription status active\",\"error\":\"some error\",\"error_code\":123,\"stripe_customer_id\":\"acct_123\",\"stripe_subscription_id\":\"sub_123\",\"tag\":\"stripe\",\"user_id\":\"u_abc\",\"visitor_ip\":\"1.2.3.4\"}\n{\"time\":\"1970-01-01T00:12:57Z\",\"level\":\"DEBUG\",\"message\":\"The number 5 is an int, but the level override is a string\",\"number\":5}\n`\n\trequire.Equal(t, expected, out.String())\n}\n\nfunc TestLog_NoAllocIfNotPrinted(t *testing.T) {\n\tt.Cleanup(resetState)\n\tv := &fakeVisitor{\n\t\tUserID: \"u_abc\",\n\t\tIP:     \"1.2.3.4\",\n\t}\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\n\t// Do not log, do not call contexters (because global level is INFO)\n\tv.contextCalled = false\n\tev := With(v)\n\tev.Debug(\"some message\")\n\trequire.False(t, v.contextCalled)\n\trequire.Equal(t, \"\", ev.Timestamp)\n\trequire.Equal(t, Level(0), ev.Level)\n\trequire.Equal(t, \"\", ev.Message)\n\trequire.Nil(t, ev.fields)\n\n\t// Logged because info level, contexters called\n\tv.contextCalled = false\n\tev = With(v).Time(time.Unix(1111, 0).UTC())\n\tev.Info(\"some message\")\n\trequire.True(t, v.contextCalled)\n\trequire.NotNil(t, ev.fields)\n\trequire.Equal(t, \"1.2.3.4\", ev.fields[\"visitor_ip\"])\n\n\t// Not logged, but contexters called, because overrides exist\n\tSetLevel(DebugLevel)\n\tSetLevelOverride(\"tag\", \"overridetag\", TraceLevel)\n\tv.contextCalled = false\n\tev = Tag(\"sometag\").Field(\"field\", \"value\").With(v).Time(time.Unix(123, 0).UTC())\n\tev.Trace(\"some debug message\")\n\trequire.True(t, v.contextCalled) // If there are overrides, we must call the context to determine the filter fields\n\trequire.Equal(t, \"\", ev.Timestamp)\n\trequire.Equal(t, Level(0), ev.Level)\n\trequire.Equal(t, \"\", ev.Message)\n\trequire.Equal(t, 4, len(ev.fields))\n\trequire.Equal(t, \"value\", ev.fields[\"field\"])\n\trequire.Equal(t, \"sometag\", ev.fields[\"tag\"])\n\n\t// Logged because of override tag, and contexters called\n\tv.contextCalled = false\n\tev = Tag(\"overridetag\").Field(\"field\", \"value\").With(v).Time(time.Unix(123, 0).UTC())\n\tev.Trace(\"some trace message\")\n\trequire.True(t, v.contextCalled)\n\trequire.Equal(t, \"1970-01-01T00:02:03Z\", ev.Timestamp)\n\trequire.Equal(t, TraceLevel, ev.Level)\n\trequire.Equal(t, \"some trace message\", ev.Message)\n\n\t// Logged because of field override, and contexters called\n\tResetLevelOverrides()\n\tSetLevelOverride(\"visitor_ip\", \"1.2.3.4\", TraceLevel)\n\tv.contextCalled = false\n\tev = With(v).Time(time.Unix(124, 0).UTC())\n\tev.Trace(\"some trace message with override\")\n\trequire.True(t, v.contextCalled)\n\trequire.Equal(t, \"1970-01-01T00:02:04Z\", ev.Timestamp)\n\trequire.Equal(t, TraceLevel, ev.Level)\n\trequire.Equal(t, \"some trace message with override\", ev.Message)\n\n\texpected := `{\"time\":\"1970-01-01T00:18:31Z\",\"level\":\"INFO\",\"message\":\"some message\",\"user_id\":\"u_abc\",\"visitor_ip\":\"1.2.3.4\"}\n{\"time\":\"1970-01-01T00:02:03Z\",\"level\":\"TRACE\",\"message\":\"some trace message\",\"field\":\"value\",\"tag\":\"overridetag\",\"user_id\":\"u_abc\",\"visitor_ip\":\"1.2.3.4\"}\n{\"time\":\"1970-01-01T00:02:04Z\",\"level\":\"TRACE\",\"message\":\"some trace message with override\",\"user_id\":\"u_abc\",\"visitor_ip\":\"1.2.3.4\"}\n`\n\trequire.Equal(t, expected, out.String())\n}\n\nfunc TestLog_Timing(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\n\tTiming(func() { time.Sleep(300 * time.Millisecond) }).\n\t\tTime(time.Unix(12, 0).UTC()).\n\t\tInfo(\"A thing that takes a while\")\n\n\tvar ev struct {\n\t\tTimeTakenMs int64 `json:\"time_taken_ms\"`\n\t}\n\trequire.Nil(t, json.Unmarshal(out.Bytes(), &ev))\n\trequire.True(t, ev.TimeTakenMs >= 300)\n\trequire.Contains(t, out.String(), `{\"time\":\"1970-01-01T00:00:12Z\",\"level\":\"INFO\",\"message\":\"A thing that takes a while\",\"time_taken_ms\":`)\n}\n\nfunc TestLog_LevelOverrideAny(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\tSetLevelOverride(\"this_one\", \"\", DebugLevel)\n\tSetLevelOverride(\"time_taken_ms\", \"\", TraceLevel)\n\n\tTime(time.Unix(11, 0).UTC()).Field(\"this_one\", \"11\").Debug(\"this is logged\")\n\tTime(time.Unix(12, 0).UTC()).Field(\"not_this\", \"11\").Debug(\"this is not logged\")\n\tTime(time.Unix(13, 0).UTC()).Field(\"this_too\", \"11\").Info(\"this is also logged\")\n\tTime(time.Unix(14, 0).UTC()).Field(\"time_taken_ms\", 0).Info(\"this is also logged\")\n\n\texpected := `{\"time\":\"1970-01-01T00:00:11Z\",\"level\":\"DEBUG\",\"message\":\"this is logged\",\"this_one\":\"11\"}\n{\"time\":\"1970-01-01T00:00:13Z\",\"level\":\"INFO\",\"message\":\"this is also logged\",\"this_too\":\"11\"}\n{\"time\":\"1970-01-01T00:00:14Z\",\"level\":\"INFO\",\"message\":\"this is also logged\",\"time_taken_ms\":0}\n`\n\trequire.Equal(t, expected, out.String())\n\trequire.False(t, IsFile())\n\trequire.Equal(t, \"\", File())\n}\n\nfunc TestLog_LevelOverride_ManyOnSameField(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\tSetLevelOverride(\"tag\", \"manager\", DebugLevel)\n\tSetLevelOverride(\"tag\", \"publish\", DebugLevel)\n\n\tTime(time.Unix(11, 0).UTC()).Field(\"tag\", \"manager\").Debug(\"this is logged\")\n\tTime(time.Unix(12, 0).UTC()).Field(\"tag\", \"no-match\").Debug(\"this is not logged\")\n\tTime(time.Unix(13, 0).UTC()).Field(\"tag\", \"publish\").Info(\"this is also logged\")\n\n\texpected := `{\"time\":\"1970-01-01T00:00:11Z\",\"level\":\"DEBUG\",\"message\":\"this is logged\",\"tag\":\"manager\"}\n{\"time\":\"1970-01-01T00:00:13Z\",\"level\":\"INFO\",\"message\":\"this is also logged\",\"tag\":\"publish\"}\n`\n\trequire.Equal(t, expected, out.String())\n\trequire.False(t, IsFile())\n\trequire.Equal(t, \"\", File())\n}\n\nfunc TestLog_FieldIf(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetLevel(DebugLevel)\n\tSetFormat(JSONFormat)\n\n\tTime(time.Unix(11, 0).UTC()).\n\t\tFieldIf(\"trace_field\", \"manager\", TraceLevel). // This is not logged\n\t\tField(\"tag\", \"manager\").\n\t\tDebug(\"trace_field is not logged\")\n\tSetLevel(TraceLevel)\n\tTime(time.Unix(12, 0).UTC()).\n\t\tFieldIf(\"trace_field\", \"manager\", TraceLevel). // Now it is logged\n\t\tField(\"tag\", \"manager\").\n\t\tDebug(\"trace_field is logged\")\n\n\texpected := `{\"time\":\"1970-01-01T00:00:11Z\",\"level\":\"DEBUG\",\"message\":\"trace_field is not logged\",\"tag\":\"manager\"}\n{\"time\":\"1970-01-01T00:00:12Z\",\"level\":\"DEBUG\",\"message\":\"trace_field is logged\",\"tag\":\"manager\",\"trace_field\":\"manager\"}\n`\n\trequire.Equal(t, expected, out.String())\n}\n\nfunc TestLog_UsingStdLogger_JSON(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\tSetFormat(JSONFormat)\n\n\tlog.Println(\"Some other library is using the standard Go logger\")\n\trequire.Contains(t, out.String(), `,\"level\":\"INFO\",\"message\":\"Some other library is using the standard Go logger\",\"tag\":\"stdlog\"}`+\"\\n\")\n}\n\nfunc TestLog_UsingStdLogger_Text(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tvar out bytes.Buffer\n\tSetOutput(&out)\n\n\tlog.Println(\"Some other library is using the standard Go logger\")\n\trequire.Contains(t, out.String(), `Some other library is using the standard Go logger`+\"\\n\")\n\trequire.NotContains(t, out.String(), `{`)\n}\n\nfunc TestLog_File(t *testing.T) {\n\tt.Cleanup(resetState)\n\n\tlogfile := filepath.Join(t.TempDir(), \"ntfy.log\")\n\tf, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)\n\trequire.Nil(t, err)\n\tSetOutput(f)\n\tSetFormat(JSONFormat)\n\trequire.True(t, IsFile())\n\trequire.Equal(t, logfile, File())\n\n\tTime(time.Unix(11, 0).UTC()).Field(\"this_one\", \"11\").Info(\"this is logged\")\n\trequire.Nil(t, f.Close())\n\n\tf, err = os.Open(logfile)\n\trequire.Nil(t, err)\n\tcontents, err := io.ReadAll(f)\n\trequire.Nil(t, err)\n\trequire.Equal(t, `{\"time\":\"1970-01-01T00:00:11Z\",\"level\":\"INFO\",\"message\":\"this is logged\",\"this_one\":\"11\"}`+\"\\n\", string(contents))\n}\n\ntype fakeError struct {\n\tCode    int\n\tMessage string\n}\n\nfunc (e fakeError) Error() string {\n\treturn e.Message\n}\n\nfunc (e fakeError) Context() Context {\n\treturn Context{\n\t\t\"error\":      e.Message,\n\t\t\"error_code\": e.Code,\n\t}\n}\n\ntype fakeVisitor struct {\n\tUserID        string\n\tIP            string\n\tcontextCalled bool\n}\n\nfunc (v *fakeVisitor) Context() Context {\n\tv.contextCalled = true\n\treturn Context{\n\t\t\"user_id\":    v.UserID,\n\t\t\"visitor_ip\": v.IP,\n\t}\n}\n\nfunc resetState() {\n\tSetLevel(DefaultLevel)\n\tSetFormat(DefaultFormat)\n\tSetOutput(DefaultOutput)\n\tResetLevelOverrides()\n}\n"
  },
  {
    "path": "log/types.go",
    "content": "package log\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n)\n\n// Level is a well-known log level, as defined below\ntype Level int\n\n// Well known log levels\nconst (\n\tTraceLevel Level = iota\n\tDebugLevel\n\tInfoLevel\n\tWarnLevel\n\tErrorLevel\n\tFatalLevel\n)\n\nfunc (l Level) String() string {\n\tswitch l {\n\tcase TraceLevel:\n\t\treturn \"TRACE\"\n\tcase DebugLevel:\n\t\treturn \"DEBUG\"\n\tcase InfoLevel:\n\t\treturn \"INFO\"\n\tcase WarnLevel:\n\t\treturn \"WARN\"\n\tcase ErrorLevel:\n\t\treturn \"ERROR\"\n\tcase FatalLevel:\n\t\treturn \"FATAL\"\n\t}\n\treturn \"unknown\"\n}\n\n// MarshalJSON converts a level to a JSON string\nfunc (l Level) MarshalJSON() ([]byte, error) {\n\treturn json.Marshal(l.String())\n}\n\n// ToLevel converts a string to a Level. It returns InfoLevel if the string\n// does not match any known log levels.\nfunc ToLevel(s string) Level {\n\tswitch strings.ToUpper(s) {\n\tcase \"TRACE\":\n\t\treturn TraceLevel\n\tcase \"DEBUG\":\n\t\treturn DebugLevel\n\tcase \"INFO\":\n\t\treturn InfoLevel\n\tcase \"WARN\", \"WARNING\":\n\t\treturn WarnLevel\n\tcase \"ERROR\":\n\t\treturn ErrorLevel\n\tcase \"FATAL\":\n\t\treturn FatalLevel\n\tdefault:\n\t\treturn InfoLevel\n\t}\n}\n\n// Format is a well-known log format\ntype Format int\n\n// Log formats\nconst (\n\tTextFormat Format = iota\n\tJSONFormat\n)\n\nfunc (f Format) String() string {\n\tswitch f {\n\tcase TextFormat:\n\t\treturn \"text\"\n\tcase JSONFormat:\n\t\treturn \"json\"\n\t}\n\treturn \"unknown\"\n}\n\n// ToFormat converts a string to a Format. It returns TextFormat if the string\n// does not match any known log formats.\nfunc ToFormat(s string) Format {\n\tswitch strings.ToLower(s) {\n\tcase \"text\":\n\t\treturn TextFormat\n\tcase \"json\":\n\t\treturn JSONFormat\n\tdefault:\n\t\treturn TextFormat\n\t}\n}\n\n// Contexter allows structs to export a key-value pairs in the form of a Context\ntype Contexter interface {\n\tContext() Context\n}\n\n// Context represents an object's state in the form of key-value pairs\ntype Context map[string]any\n\n// Merge merges other into this context\nfunc (c Context) Merge(other Context) {\n\tfor k, v := range other {\n\t\tc[k] = v\n\t}\n}\n\ntype levelOverride struct {\n\tvalue string\n\tlevel Level\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\n\t\"github.com/urfave/cli/v2\"\n\t\"heckel.io/ntfy/v2/cmd\"\n)\n\n// These variables are set during build time using -ldflags\nvar (\n\tversion = \"dev\"\n\tcommit  = \"unknown\"\n\tdate    = \"unknown\"\n)\n\nfunc main() {\n\tcli.AppHelpTemplate += fmt.Sprintf(`\nTry 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.\n\nTo report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.\nIf you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj9w), or\nthe Matrix room (https://matrix.to/#/#ntfy:matrix.org).\n\nntfy %s (%s), runtime %s, built at %s\nCopyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2\n`, version, maybeShortCommit(commit), runtime.Version(), date)\n\n\tapp := cmd.New()\n\tapp.Version = version\n\tapp.Metadata = map[string]any{\n\t\tcmd.MetadataKeyDate:   date,\n\t\tcmd.MetadataKeyCommit: commit,\n\t}\n\n\tif err := app.Run(os.Args); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err.Error())\n\t\tos.Exit(1)\n\t}\n}\n\nfunc maybeShortCommit(commit string) string {\n\tif len(commit) > 7 {\n\t\treturn commit[:7]\n\t}\n\treturn commit\n}\n"
  },
  {
    "path": "message/cache.go",
    "content": "package message\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\ttagMessageCache = \"message_cache\"\n)\n\nvar errNoRows = errors.New(\"no rows found\")\n\n// queries holds the database-specific SQL queries\ntype queries struct {\n\tinsertMessage                    string\n\tdeleteMessage                    string\n\tselectScheduledMessageIDsBySeqID string\n\tdeleteScheduledBySequenceID      string\n\tupdateMessagesForTopicExpiry     string\n\tselectMessagesByID               string\n\tselectMessagesSinceTime          string\n\tselectMessagesSinceTimeScheduled string\n\tselectMessagesSinceID            string\n\tselectMessagesSinceIDScheduled   string\n\tselectMessagesLatest             string\n\tselectMessagesDue                string\n\tselectMessagesExpired            string\n\tupdateMessagePublished           string\n\tselectMessagesCount              string\n\tselectTopics                     string\n\tupdateAttachmentDeleted          string\n\tselectAttachmentsExpired         string\n\tselectAttachmentsSizeBySender    string\n\tselectAttachmentsSizeByUserID    string\n\tselectStats                      string\n\tupdateStats                      string\n\tupdateMessageTime                string\n}\n\n// Cache stores published messages\ntype Cache struct {\n\tdb      *db.DB\n\tqueue   *util.BatchingQueue[*model.Message]\n\tnop     bool\n\tmu      *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer)\n\tqueries queries\n}\n\nfunc newCache(db *db.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache {\n\tvar queue *util.BatchingQueue[*model.Message]\n\tif batchSize > 0 || batchTimeout > 0 {\n\t\tqueue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout)\n\t}\n\tc := &Cache{\n\t\tdb:      db,\n\t\tqueue:   queue,\n\t\tnop:     nop,\n\t\tmu:      mu,\n\t\tqueries: queries,\n\t}\n\tgo c.processMessageBatches()\n\treturn c\n}\n\nfunc (c *Cache) maybeLock() {\n\tif c.mu != nil {\n\t\tc.mu.Lock()\n\t}\n}\n\nfunc (c *Cache) maybeUnlock() {\n\tif c.mu != nil {\n\t\tc.mu.Unlock()\n\t}\n}\n\n// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously.\n// The message is queued only if \"batchSize\" or \"batchTimeout\" are passed to the constructor.\nfunc (c *Cache) AddMessage(m *model.Message) error {\n\tif c.queue != nil {\n\t\tc.queue.Enqueue(m)\n\t\treturn nil\n\t}\n\treturn c.addMessages([]*model.Message{m})\n}\n\n// AddMessages synchronously stores a batch of messages to the message cache\nfunc (c *Cache) AddMessages(ms []*model.Message) error {\n\treturn c.addMessages(ms)\n}\n\nfunc (c *Cache) addMessages(ms []*model.Message) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\tif c.nop {\n\t\treturn nil\n\t}\n\tif len(ms) == 0 {\n\t\treturn nil\n\t}\n\tstart := time.Now()\n\ttx, err := c.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\tstmt, err := tx.Prepare(c.queries.insertMessage)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\tfor _, m := range ms {\n\t\tif m.Event != model.MessageEvent && m.Event != model.MessageDeleteEvent && m.Event != model.MessageClearEvent {\n\t\t\treturn model.ErrUnexpectedMessageType\n\t\t}\n\t\tpublished := m.Time <= time.Now().Unix()\n\t\ttags := util.SanitizeUTF8(strings.Join(m.Tags, \",\"))\n\t\tvar attachmentName, attachmentType, attachmentURL string\n\t\tvar attachmentSize, attachmentExpires int64\n\t\tvar attachmentDeleted bool\n\t\tif m.Attachment != nil {\n\t\t\tattachmentName = util.SanitizeUTF8(m.Attachment.Name)\n\t\t\tattachmentType = util.SanitizeUTF8(m.Attachment.Type)\n\t\t\tattachmentSize = m.Attachment.Size\n\t\t\tattachmentExpires = m.Attachment.Expires\n\t\t\tattachmentURL = util.SanitizeUTF8(m.Attachment.URL)\n\t\t}\n\t\tvar actionsStr string\n\t\tif len(m.Actions) > 0 {\n\t\t\tactionsBytes, err := json.Marshal(m.Actions)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tactionsStr = string(actionsBytes)\n\t\t}\n\t\tvar sender string\n\t\tif m.Sender.IsValid() {\n\t\t\tsender = m.Sender.String()\n\t\t}\n\t\t_, err := stmt.Exec(\n\t\t\tm.ID,\n\t\t\tm.SequenceID,\n\t\t\tm.Time,\n\t\t\tm.Event,\n\t\t\tm.Expires,\n\t\t\tutil.SanitizeUTF8(m.Topic),\n\t\t\tutil.SanitizeUTF8(m.Message),\n\t\t\tutil.SanitizeUTF8(m.Title),\n\t\t\tm.Priority,\n\t\t\ttags,\n\t\t\tutil.SanitizeUTF8(m.Click),\n\t\t\tutil.SanitizeUTF8(m.Icon),\n\t\t\tactionsStr,\n\t\t\tattachmentName,\n\t\t\tattachmentType,\n\t\t\tattachmentSize,\n\t\t\tattachmentExpires,\n\t\t\tattachmentURL,\n\t\t\tattachmentDeleted, // Always zero\n\t\t\tsender,\n\t\t\tm.User,\n\t\t\tutil.SanitizeUTF8(m.ContentType),\n\t\t\tm.Encoding,\n\t\t\tpublished,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := tx.Commit(); err != nil {\n\t\tlog.Tag(tagMessageCache).Err(err).Error(\"Writing %d message(s) failed (took %v)\", len(ms), time.Since(start))\n\t\treturn err\n\t}\n\tlog.Tag(tagMessageCache).Debug(\"Wrote %d message(s) in %v\", len(ms), time.Since(start))\n\treturn nil\n}\n\n// Messages returns messages for a topic since the given marker, optionally including scheduled messages\nfunc (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {\n\tif since.IsNone() {\n\t\treturn make([]*model.Message, 0), nil\n\t} else if since.IsLatest() {\n\t\treturn c.messagesLatest(topic)\n\t} else if since.IsID() {\n\t\treturn c.messagesSinceID(topic, since, scheduled)\n\t}\n\treturn c.messagesSinceTime(topic, since, scheduled)\n}\n\nfunc (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {\n\tvar rows *sql.Rows\n\tvar err error\n\trdb := c.db.ReadOnly()\n\tif scheduled {\n\t\trows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())\n\t} else {\n\t\trows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn readMessages(rows)\n}\n\nfunc (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {\n\tvar rows *sql.Rows\n\tvar err error\n\trdb := c.db.ReadOnly()\n\tif scheduled {\n\t\trows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())\n\t} else {\n\t\trows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn readMessages(rows)\n}\n\nfunc (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectMessagesLatest, topic)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn readMessages(rows)\n}\n\n// MessagesDue returns all messages that are due for publishing\nfunc (c *Cache) MessagesDue() ([]*model.Message, error) {\n\trows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn readMessages(rows)\n}\n\n// MessagesExpired returns a list of IDs for messages that have expired (should be deleted)\nfunc (c *Cache) MessagesExpired() ([]string, error) {\n\trows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tids := make([]string, 0)\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// Message returns the message with the given ID, or ErrMessageNotFound if not found\nfunc (c *Cache) Message(id string) (*model.Message, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectMessagesByID, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !rows.Next() {\n\t\treturn nil, model.ErrMessageNotFound\n\t}\n\tdefer rows.Close()\n\treturn readMessage(rows)\n}\n\n// UpdateMessageTime updates the time column for a message by ID. This is only used for testing.\nfunc (c *Cache) UpdateMessageTime(messageID string, timestamp int64) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\t_, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID)\n\treturn err\n}\n\n// MarkPublished marks a message as published\nfunc (c *Cache) MarkPublished(m *model.Message) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\t_, err := c.db.Exec(c.queries.updateMessagePublished, m.ID)\n\treturn err\n}\n\n// MessagesCount returns the total number of messages in the cache\nfunc (c *Cache) MessagesCount() (int, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn 0, errNoRows\n\t}\n\tvar count int\n\tif err := rows.Scan(&count); err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\n// Topics returns a list of all topics with messages in the cache\nfunc (c *Cache) Topics() ([]string, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectTopics)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\ttopics := make([]string, 0)\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttopics = append(topics, id)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn topics, nil\n}\n\n// DeleteMessages deletes the messages with the given IDs\nfunc (c *Cache) DeleteMessages(ids ...string) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\treturn db.ExecTx(c.db, func(tx *sql.Tx) error {\n\t\tfor _, id := range ids {\n\t\t\tif _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.\n// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.\nfunc (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\treturn db.QueryTx(c.db, func(tx *sql.Tx) ([]string, error) {\n\t\trows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tdefer rows.Close()\n\t\tids := make([]string, 0)\n\t\tfor rows.Next() {\n\t\t\tvar id string\n\t\t\tif err := rows.Scan(&id); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tids = append(ids, id)\n\t\t}\n\t\tif err := rows.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trows.Close() // Close rows before executing delete in same transaction\n\t\tif _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ids, nil\n\t})\n}\n\n// ExpireMessages marks messages in the given topics as expired\nfunc (c *Cache) ExpireMessages(topics ...string) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\treturn db.ExecTx(c.db, func(tx *sql.Tx) error {\n\t\tfor _, t := range topics {\n\t\t\tif _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// AttachmentsExpired returns message IDs with expired attachments that have not been deleted\nfunc (c *Cache) AttachmentsExpired() ([]string, error) {\n\trows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tids := make([]string, 0)\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// MarkAttachmentsDeleted marks the attachments for the given message IDs as deleted\nfunc (c *Cache) MarkAttachmentsDeleted(ids ...string) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\treturn db.ExecTx(c.db, func(tx *sql.Tx) error {\n\t\tfor _, id := range ids {\n\t\t\tif _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender\nfunc (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn c.readAttachmentBytesUsed(rows)\n}\n\n// AttachmentBytesUsedByUser returns the total size of active attachments for the given user\nfunc (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn c.readAttachmentBytesUsed(rows)\n}\n\nfunc (c *Cache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {\n\tdefer rows.Close()\n\tvar size int64\n\tif !rows.Next() {\n\t\treturn 0, errors.New(\"no rows found\")\n\t}\n\tif err := rows.Scan(&size); err != nil {\n\t\treturn 0, err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn 0, err\n\t}\n\treturn size, nil\n}\n\n// UpdateStats updates the total message count statistic\nfunc (c *Cache) UpdateStats(messages int64) error {\n\tc.maybeLock()\n\tdefer c.maybeUnlock()\n\t_, err := c.db.Exec(c.queries.updateStats, messages)\n\treturn err\n}\n\n// Stats returns the total message count statistic\nfunc (c *Cache) Stats() (messages int64, err error) {\n\trows, err := c.db.ReadOnly().Query(c.queries.selectStats)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn 0, errNoRows\n\t}\n\tif err := rows.Scan(&messages); err != nil {\n\t\treturn 0, err\n\t}\n\treturn messages, nil\n}\n\n// Close closes the underlying database connection\nfunc (c *Cache) Close() error {\n\treturn c.db.Close()\n}\n\nfunc (c *Cache) processMessageBatches() {\n\tif c.queue == nil {\n\t\treturn\n\t}\n\tfor messages := range c.queue.Dequeue() {\n\t\tif err := c.addMessages(messages); err != nil {\n\t\t\tlog.Tag(tagMessageCache).Err(err).Error(\"Cannot write message batch\")\n\t\t}\n\t}\n}\n\nfunc readMessages(rows *sql.Rows) ([]*model.Message, error) {\n\tdefer rows.Close()\n\tmessages := make([]*model.Message, 0)\n\tfor rows.Next() {\n\t\tm, err := readMessage(rows)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmessages = append(messages, m)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn messages, nil\n}\n\nfunc readMessage(rows *sql.Rows) (*model.Message, error) {\n\tvar timestamp, expires, attachmentSize, attachmentExpires int64\n\tvar priority int\n\tvar id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string\n\terr := rows.Scan(\n\t\t&id,\n\t\t&sequenceID,\n\t\t&timestamp,\n\t\t&event,\n\t\t&expires,\n\t\t&topic,\n\t\t&msg,\n\t\t&title,\n\t\t&priority,\n\t\t&tagsStr,\n\t\t&click,\n\t\t&icon,\n\t\t&actionsStr,\n\t\t&attachmentName,\n\t\t&attachmentType,\n\t\t&attachmentSize,\n\t\t&attachmentExpires,\n\t\t&attachmentURL,\n\t\t&sender,\n\t\t&user,\n\t\t&contentType,\n\t\t&encoding,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar tags []string\n\tif tagsStr != \"\" {\n\t\ttags = strings.Split(tagsStr, \",\")\n\t}\n\tvar actions []*model.Action\n\tif actionsStr != \"\" {\n\t\tif err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tsenderIP, err := netip.ParseAddr(sender)\n\tif err != nil {\n\t\tsenderIP = netip.Addr{} // if no IP stored in database, return invalid address\n\t}\n\tvar att *model.Attachment\n\tif attachmentName != \"\" && attachmentURL != \"\" {\n\t\tatt = &model.Attachment{\n\t\t\tName:    attachmentName,\n\t\t\tType:    attachmentType,\n\t\t\tSize:    attachmentSize,\n\t\t\tExpires: attachmentExpires,\n\t\t\tURL:     attachmentURL,\n\t\t}\n\t}\n\treturn &model.Message{\n\t\tID:          id,\n\t\tSequenceID:  sequenceID,\n\t\tTime:        timestamp,\n\t\tExpires:     expires,\n\t\tEvent:       event,\n\t\tTopic:       topic,\n\t\tMessage:     msg,\n\t\tTitle:       title,\n\t\tPriority:    priority,\n\t\tTags:        tags,\n\t\tClick:       click,\n\t\tIcon:        icon,\n\t\tActions:     actions,\n\t\tAttachment:  att,\n\t\tSender:      senderIP,\n\t\tUser:        user,\n\t\tContentType: contentType,\n\t\tEncoding:    encoding,\n\t}, nil\n}\n"
  },
  {
    "path": "message/cache_postgres.go",
    "content": "package message\n\nimport (\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/db\"\n)\n\n// PostgreSQL runtime query constants\nconst (\n\tpostgresInsertMessageQuery = `\n\t\tINSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)\n\t`\n\tpostgresDeleteMessageQuery                    = `DELETE FROM message WHERE mid = $1`\n\tpostgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`\n\tpostgresDeleteScheduledBySequenceIDQuery      = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`\n\tpostgresUpdateMessagesForTopicExpiryQuery     = `UPDATE message SET expires = $1 WHERE topic = $2`\n\tpostgresSelectMessagesByIDQuery               = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE mid = $1\n\t`\n\tpostgresSelectMessagesSinceTimeQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE topic = $1 AND time >= $2 AND published = TRUE\n\t\tORDER BY time, id\n\t`\n\tpostgresSelectMessagesSinceTimeIncludeScheduledQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE topic = $1 AND time >= $2\n\t\tORDER BY time, id\n\t`\n\tpostgresSelectMessagesSinceIDQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE topic = $1\n\t\t  AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)\n\t\t  AND published = TRUE\n\t\tORDER BY time, id\n\t`\n\tpostgresSelectMessagesSinceIDIncludeScheduledQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE topic = $1\n\t\t  AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)\n\t\tORDER BY time, id\n\t`\n\tpostgresSelectMessagesLatestQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE topic = $1 AND published = TRUE\n\t\tORDER BY time DESC, id DESC\n\t\tLIMIT 1\n\t`\n\tpostgresSelectMessagesDueQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding\n\t\tFROM message\n\t\tWHERE time <= $1 AND published = FALSE\n\t\tORDER BY time, id\n\t`\n\tpostgresSelectMessagesExpiredQuery  = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE`\n\tpostgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`\n\tpostgresSelectMessagesCountQuery    = `SELECT COUNT(*) FROM message`\n\tpostgresSelectTopicsQuery           = `SELECT topic FROM message GROUP BY topic`\n\n\tpostgresUpdateAttachmentDeletedQuery       = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1`\n\tpostgresSelectAttachmentsExpiredQuery      = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE`\n\tpostgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2`\n\tpostgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2`\n\n\tpostgresSelectStatsQuery       = `SELECT value FROM message_stats WHERE key = 'messages'`\n\tpostgresUpdateStatsQuery       = `UPDATE message_stats SET value = $1 WHERE key = 'messages'`\n\tpostgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2`\n)\n\nvar postgresQueries = queries{\n\tinsertMessage:                    postgresInsertMessageQuery,\n\tdeleteMessage:                    postgresDeleteMessageQuery,\n\tselectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery,\n\tdeleteScheduledBySequenceID:      postgresDeleteScheduledBySequenceIDQuery,\n\tupdateMessagesForTopicExpiry:     postgresUpdateMessagesForTopicExpiryQuery,\n\tselectMessagesByID:               postgresSelectMessagesByIDQuery,\n\tselectMessagesSinceTime:          postgresSelectMessagesSinceTimeQuery,\n\tselectMessagesSinceTimeScheduled: postgresSelectMessagesSinceTimeIncludeScheduledQuery,\n\tselectMessagesSinceID:            postgresSelectMessagesSinceIDQuery,\n\tselectMessagesSinceIDScheduled:   postgresSelectMessagesSinceIDIncludeScheduledQuery,\n\tselectMessagesLatest:             postgresSelectMessagesLatestQuery,\n\tselectMessagesDue:                postgresSelectMessagesDueQuery,\n\tselectMessagesExpired:            postgresSelectMessagesExpiredQuery,\n\tupdateMessagePublished:           postgresUpdateMessagePublishedQuery,\n\tselectMessagesCount:              postgresSelectMessagesCountQuery,\n\tselectTopics:                     postgresSelectTopicsQuery,\n\tupdateAttachmentDeleted:          postgresUpdateAttachmentDeletedQuery,\n\tselectAttachmentsExpired:         postgresSelectAttachmentsExpiredQuery,\n\tselectAttachmentsSizeBySender:    postgresSelectAttachmentsSizeBySenderQuery,\n\tselectAttachmentsSizeByUserID:    postgresSelectAttachmentsSizeByUserIDQuery,\n\tselectStats:                      postgresSelectStatsQuery,\n\tupdateStats:                      postgresUpdateStatsQuery,\n\tupdateMessageTime:                postgresUpdateMessageTimeQuery,\n}\n\n// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.\nfunc NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {\n\tif err := setupPostgres(d.Primary()); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil\n}\n"
  },
  {
    "path": "message/cache_postgres_schema.go",
    "content": "package message\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"heckel.io/ntfy/v2/db\"\n)\n\n// Initial PostgreSQL schema\nconst (\n\tpostgresCreateTablesQuery = `\n\t\tCREATE TABLE IF NOT EXISTS message (\n\t\t\tid BIGSERIAL PRIMARY KEY,\n\t\t\tmid TEXT NOT NULL,\n\t\t\tsequence_id TEXT NOT NULL,\n\t\t\ttime BIGINT NOT NULL,\n\t\t\tevent TEXT NOT NULL,\n\t\t\texpires BIGINT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tmessage TEXT NOT NULL,\n\t\t\ttitle TEXT NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags TEXT NOT NULL,\n\t\t\tclick TEXT NOT NULL,\n\t\t\ticon TEXT NOT NULL,\n\t\t\tactions TEXT NOT NULL,\n\t\t\tattachment_name TEXT NOT NULL,\n\t\t\tattachment_type TEXT NOT NULL,\n\t\t\tattachment_size BIGINT NOT NULL,\n\t\t\tattachment_expires BIGINT NOT NULL,\n\t\t\tattachment_url TEXT NOT NULL,\n\t\t\tattachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,\n\t\t\tsender TEXT NOT NULL,\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tcontent_type TEXT NOT NULL,\n\t\t\tencoding TEXT NOT NULL,\n\t\t\tpublished BOOLEAN NOT NULL DEFAULT FALSE\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_mid ON message (mid);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';\n\t\tCREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);\n\t\tCREATE TABLE IF NOT EXISTS message_stats (\n\t\t\tkey TEXT PRIMARY KEY,\n\t\t\tvalue BIGINT\n\t\t);\n\t\tINSERT INTO message_stats (key, value) VALUES ('messages', 0);\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t`\n)\n\n// PostgreSQL schema management queries\nconst (\n\tpostgresCurrentSchemaVersion     = 14\n\tpostgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`\n\tpostgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`\n)\n\nfunc setupPostgres(db *sql.DB) error {\n\tvar schemaVersion int\n\tif err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {\n\t\treturn setupNewPostgresDB(db)\n\t} else if schemaVersion > postgresCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, postgresCurrentSchemaVersion)\n\t}\n\treturn nil\n}\n\nfunc setupNewPostgresDB(sqlDB *sql.DB) error {\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(postgresCreateTablesQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "message/cache_sqlite.go",
    "content": "package message\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t_ \"github.com/mattn/go-sqlite3\" // SQLite driver\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// SQLite runtime query constants\nconst (\n\tsqliteInsertMessageQuery = `\n\t\tINSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`\n\tsqliteDeleteMessageQuery                    = `DELETE FROM messages WHERE mid = ?`\n\tsqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`\n\tsqliteDeleteScheduledBySequenceIDQuery      = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`\n\tsqliteUpdateMessagesForTopicExpiryQuery     = `UPDATE messages SET expires = ? WHERE topic = ?`\n\tsqliteSelectMessagesByIDQuery               = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE mid = ?\n\t`\n\tsqliteSelectMessagesSinceTimeQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE topic = ? AND time >= ? AND published = 1\n\t\tORDER BY time, id\n\t`\n\tsqliteSelectMessagesSinceTimeIncludeScheduledQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE topic = ? AND time >= ?\n\t\tORDER BY time, id\n\t`\n\tsqliteSelectMessagesSinceIDQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1\n\t\tORDER BY time, id\n\t`\n\tsqliteSelectMessagesSinceIDIncludeScheduledQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)\n\t\tORDER BY time, id\n\t`\n\tsqliteSelectMessagesLatestQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE topic = ? AND published = 1\n\t\tORDER BY time DESC, id DESC\n\t\tLIMIT 1\n\t`\n\tsqliteSelectMessagesDueQuery = `\n\t\tSELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding\n\t\tFROM messages\n\t\tWHERE time <= ? AND published = 0\n\t\tORDER BY time, id\n\t`\n\tsqliteSelectMessagesExpiredQuery  = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`\n\tsqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`\n\tsqliteSelectMessagesCountQuery    = `SELECT COUNT(*) FROM messages`\n\tsqliteSelectTopicsQuery           = `SELECT topic FROM messages GROUP BY topic`\n\n\tsqliteUpdateAttachmentDeletedQuery       = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`\n\tsqliteSelectAttachmentsExpiredQuery      = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`\n\tsqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`\n\tsqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`\n\n\tsqliteSelectStatsQuery       = `SELECT value FROM stats WHERE key = 'messages'`\n\tsqliteUpdateStatsQuery       = `UPDATE stats SET value = ? WHERE key = 'messages'`\n\tsqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?`\n)\n\nvar sqliteQueries = queries{\n\tinsertMessage:                    sqliteInsertMessageQuery,\n\tdeleteMessage:                    sqliteDeleteMessageQuery,\n\tselectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery,\n\tdeleteScheduledBySequenceID:      sqliteDeleteScheduledBySequenceIDQuery,\n\tupdateMessagesForTopicExpiry:     sqliteUpdateMessagesForTopicExpiryQuery,\n\tselectMessagesByID:               sqliteSelectMessagesByIDQuery,\n\tselectMessagesSinceTime:          sqliteSelectMessagesSinceTimeQuery,\n\tselectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery,\n\tselectMessagesSinceID:            sqliteSelectMessagesSinceIDQuery,\n\tselectMessagesSinceIDScheduled:   sqliteSelectMessagesSinceIDIncludeScheduledQuery,\n\tselectMessagesLatest:             sqliteSelectMessagesLatestQuery,\n\tselectMessagesDue:                sqliteSelectMessagesDueQuery,\n\tselectMessagesExpired:            sqliteSelectMessagesExpiredQuery,\n\tupdateMessagePublished:           sqliteUpdateMessagePublishedQuery,\n\tselectMessagesCount:              sqliteSelectMessagesCountQuery,\n\tselectTopics:                     sqliteSelectTopicsQuery,\n\tupdateAttachmentDeleted:          sqliteUpdateAttachmentDeletedQuery,\n\tselectAttachmentsExpired:         sqliteSelectAttachmentsExpiredQuery,\n\tselectAttachmentsSizeBySender:    sqliteSelectAttachmentsSizeBySenderQuery,\n\tselectAttachmentsSizeByUserID:    sqliteSelectAttachmentsSizeByUserIDQuery,\n\tselectStats:                      sqliteSelectStatsQuery,\n\tupdateStats:                      sqliteUpdateStatsQuery,\n\tupdateMessageTime:                sqliteUpdateMessageTimeQuery,\n}\n\n// NewSQLiteStore creates a SQLite file-backed cache\nfunc NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*Cache, error) {\n\tparentDir := filepath.Dir(filename)\n\tif !util.FileExists(parentDir) {\n\t\treturn nil, fmt.Errorf(\"cache database directory %s does not exist or is not accessible\", parentDir)\n\t}\n\td, err := sql.Open(\"sqlite3\", filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := setupSQLite(d, startupQueries, cacheDuration); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil\n}\n\n// NewMemStore creates an in-memory cache\nfunc NewMemStore() (*Cache, error) {\n\treturn NewSQLiteStore(createMemoryFilename(), \"\", 0, 0, 0, false)\n}\n\n// NewNopStore creates an in-memory cache that discards all messages;\n// it is always empty and can be used if caching is entirely disabled\nfunc NewNopStore() (*Cache, error) {\n\treturn NewSQLiteStore(createMemoryFilename(), \"\", 0, 0, 0, true)\n}\n\n// createMemoryFilename creates a unique memory filename to use for the SQLite backend.\n// From mattn/go-sqlite3: \"Each connection to \":memory:\" opens a brand new in-memory\n// sql database, so if the stdlib's sql engine happens to open another connection and\n// you've only specified \":memory:\", that connection will see a brand new database.\n// A workaround is to use \"file::memory:?cache=shared\" (or \"file:foobar?mode=memory&cache=shared\").\n// Every connection to this string will point to the same in-memory database.\"\nfunc createMemoryFilename() string {\n\treturn fmt.Sprintf(\"file:%s?mode=memory&cache=shared\", util.RandomString(10))\n}\n"
  },
  {
    "path": "message/cache_sqlite_schema.go",
    "content": "package message\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/log\"\n)\n\n// Initial SQLite schema\nconst (\n\tsqliteCreateTablesQuery = `\n\t\tCREATE TABLE IF NOT EXISTS messages (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tmid TEXT NOT NULL,\n\t\t\tsequence_id TEXT NOT NULL,\n\t\t\ttime INT NOT NULL,\n\t\t\tevent TEXT NOT NULL,\n\t\t\texpires INT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tmessage TEXT NOT NULL,\n\t\t\ttitle TEXT NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags TEXT NOT NULL,\n\t\t\tclick TEXT NOT NULL,\n\t\t\ticon TEXT NOT NULL,\n\t\t\tactions TEXT NOT NULL,\n\t\t\tattachment_name TEXT NOT NULL,\n\t\t\tattachment_type TEXT NOT NULL,\n\t\t\tattachment_size INT NOT NULL,\n\t\t\tattachment_expires INT NOT NULL,\n\t\t\tattachment_url TEXT NOT NULL,\n\t\t\tattachment_deleted INT NOT NULL,\n\t\t\tsender TEXT NOT NULL,\n\t\t\tuser TEXT NOT NULL,\n\t\t\tcontent_type TEXT NOT NULL,\n\t\t\tencoding TEXT NOT NULL,\n\t\t\tpublished INT NOT NULL\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);\n\t\tCREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);\n\t\tCREATE INDEX IF NOT EXISTS idx_time ON messages (time);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);\n\t\tCREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);\n\t\tCREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);\n\t\tCREATE INDEX IF NOT EXISTS idx_user ON messages (user);\n\t\tCREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);\n\t\tCREATE TABLE IF NOT EXISTS stats (\n\t\t\tkey TEXT PRIMARY KEY,\n\t\t\tvalue INT\n\t\t);\n\t\tINSERT INTO stats (key, value) VALUES ('messages', 0);\n\t`\n)\n\n// Schema version management for SQLite\nconst (\n\tsqliteCurrentSchemaVersion          = 14\n\tsqliteCreateSchemaVersionTableQuery = `\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t`\n\tsqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`\n\tsqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`\n\tsqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`\n)\n\n// Schema migrations for SQLite\nconst (\n\t// 0 -> 1\n\tsqliteMigrate0To1AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);\n\t\tALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');\n\t`\n\n\t// 1 -> 2\n\tsqliteMigrate1To2AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);\n\t`\n\n\t// 2 -> 3\n\tsqliteMigrate2To3AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');\n\t\tALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');\n\t\tALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');\n\t`\n\t// 3 -> 4\n\tsqliteMigrate3To4AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');\n\t`\n\n\t// 4 -> 5\n\tsqliteMigrate4To5AlterMessagesTableQuery = `\n\t\tCREATE TABLE IF NOT EXISTS messages_new (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tmid TEXT NOT NULL,\n\t\t\ttime INT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tmessage TEXT NOT NULL,\n\t\t\ttitle TEXT NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags TEXT NOT NULL,\n\t\t\tclick TEXT NOT NULL,\n\t\t\tattachment_name TEXT NOT NULL,\n\t\t\tattachment_type TEXT NOT NULL,\n\t\t\tattachment_size INT NOT NULL,\n\t\t\tattachment_expires INT NOT NULL,\n\t\t\tattachment_url TEXT NOT NULL,\n\t\t\tattachment_owner TEXT NOT NULL,\n\t\t\tencoding TEXT NOT NULL,\n\t\t\tpublished INT NOT NULL\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);\n\t\tINSERT\n\t\t\tINTO messages_new (\n\t\t\t\tmid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,\n\t\t\t\tattachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)\n\t\t\tSELECT\n\t\t\t\tid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,\n\t\t\t\tattachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published\n\t\t\tFROM messages;\n\t\tDROP TABLE messages;\n\t\tALTER TABLE messages_new RENAME TO messages;\n\t`\n\n\t// 5 -> 6\n\tsqliteMigrate5To6AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');\n\t`\n\n\t// 6 -> 7\n\tsqliteMigrate6To7AlterMessagesTableQuery = `\n\t\tALTER TABLE messages RENAME COLUMN attachment_owner TO sender;\n\t`\n\n\t// 7 -> 8\n\tsqliteMigrate7To8AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');\n\t`\n\n\t// 8 -> 9\n\tsqliteMigrate8To9AlterMessagesTableQuery = `\n\t\tCREATE INDEX IF NOT EXISTS idx_time ON messages (time);\n\t`\n\n\t// 9 -> 10\n\tsqliteMigrate9To10AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');\n\t\tALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');\n\t\tCREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);\n\t\tCREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);\n\t\tCREATE INDEX IF NOT EXISTS idx_user ON messages (user);\n\t\tCREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);\n\t`\n\tsqliteMigrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`\n\n\t// 10 -> 11\n\tsqliteMigrate10To11AlterMessagesTableQuery = `\n\t\tCREATE TABLE IF NOT EXISTS stats (\n\t\t\tkey TEXT PRIMARY KEY,\n\t\t\tvalue INT\n\t\t);\n\t\tINSERT INTO stats (key, value) VALUES ('messages', 0);\n\t`\n\n\t// 11 -> 12\n\tsqliteMigrate11To12AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');\n\t`\n\n\t// 12 -> 13\n\tsqliteMigrate12To13AlterMessagesTableQuery = `\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);\n\t`\n\n\t// 13 -> 14\n\tsqliteMigrate13To14AlterMessagesTableQuery = `\n\t\tALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');\n\t\tALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');\n\t\tCREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);\n\t`\n)\n\nvar (\n\tsqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{\n\t\t0:  sqliteMigrateFrom0,\n\t\t1:  sqliteMigrateFrom1,\n\t\t2:  sqliteMigrateFrom2,\n\t\t3:  sqliteMigrateFrom3,\n\t\t4:  sqliteMigrateFrom4,\n\t\t5:  sqliteMigrateFrom5,\n\t\t6:  sqliteMigrateFrom6,\n\t\t7:  sqliteMigrateFrom7,\n\t\t8:  sqliteMigrateFrom8,\n\t\t9:  sqliteMigrateFrom9,\n\t\t10: sqliteMigrateFrom10,\n\t\t11: sqliteMigrateFrom11,\n\t\t12: sqliteMigrateFrom12,\n\t\t13: sqliteMigrateFrom13,\n\t}\n)\n\nfunc setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {\n\tif err := runSQLiteStartupQueries(db, startupQueries); err != nil {\n\t\treturn err\n\t}\n\t// If 'messages' table does not exist, this must be a new database\n\tvar messagesCount int\n\tif err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil {\n\t\treturn setupNewSQLite(db)\n\t}\n\t// If 'messages' table exists (schema >= 0), check 'schemaVersion' table\n\tvar schemaVersion int\n\tdb.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero!\n\t// Do migrations\n\tif schemaVersion == sqliteCurrentSchemaVersion {\n\t\treturn nil\n\t} else if schemaVersion > sqliteCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, sqliteCurrentSchemaVersion)\n\t}\n\tfor i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {\n\t\tfn, ok := sqliteMigrations[i]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"cannot find migration step from schema version %d to %d\", i, i+1)\n\t\t} else if err := fn(db, cacheDuration); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc setupNewSQLite(sqlDB *sql.DB) error {\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {\n\tif startupQueries != \"\" {\n\t\tif _, err := db.Exec(startupQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 0 to 1\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 1 to 2\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 2 to 3\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 3 to 4\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 4 to 5\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 5 to 6\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 6 to 7\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 7 to 8\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 8 to 9\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 9 to 10\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 10 to 11\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 11 to 12\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 12 to 13\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {\n\tlog.Tag(tagMessageCache).Info(\"Migrating cache database schema: from 13 to 14\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "message/cache_sqlite_test.go",
    "content": "package message_test\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"github.com/mattn/go-sqlite3\" // SQLite driver\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/message\"\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nfunc TestSqliteStore_Migration_From0(t *testing.T) {\n\tfilename := newSqliteTestStoreFile(t)\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\n\t// Create \"version 0\" schema\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tCREATE TABLE IF NOT EXISTS messages (\n\t\t\tid VARCHAR(20) PRIMARY KEY,\n\t\t\ttime INT NOT NULL,\n\t\t\ttopic VARCHAR(64) NOT NULL,\n\t\t\tmessage VARCHAR(1024) NOT NULL\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Insert a bunch of messages\n\tfor i := 0; i < 10; i++ {\n\t\t_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,\n\t\t\tfmt.Sprintf(\"abcd%d\", i), time.Now().Unix(), \"mytopic\", fmt.Sprintf(\"some message %d\", i))\n\t\trequire.Nil(t, err)\n\t}\n\trequire.Nil(t, db.Close())\n\n\t// Create store to trigger migration\n\ts := newSqliteTestStoreFromFile(t, filename, \"\")\n\tcheckSqliteSchemaVersion(t, filename)\n\n\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 10, len(messages))\n\trequire.Equal(t, \"some message 5\", messages[5].Message)\n\trequire.Equal(t, \"\", messages[5].Title)\n\trequire.Nil(t, messages[5].Tags)\n\trequire.Equal(t, 0, messages[5].Priority)\n}\n\nfunc TestSqliteStore_Migration_From1(t *testing.T) {\n\tfilename := newSqliteTestStoreFile(t)\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\n\t// Create \"version 1\" schema\n\t_, err = db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS messages (\n\t\t\tid VARCHAR(20) PRIMARY KEY,\n\t\t\ttime INT NOT NULL,\n\t\t\ttopic VARCHAR(64) NOT NULL,\n\t\t\tmessage VARCHAR(512) NOT NULL,\n\t\t\ttitle VARCHAR(256) NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags VARCHAR(256) NOT NULL\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO schemaVersion (id, version) VALUES (1, 1);\n\t`)\n\trequire.Nil(t, err)\n\n\t// Insert a bunch of messages\n\tfor i := 0; i < 10; i++ {\n\t\t_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,\n\t\t\tfmt.Sprintf(\"abcd%d\", i), time.Now().Unix(), \"mytopic\", fmt.Sprintf(\"some message %d\", i), \"\", 0, \"\")\n\t\trequire.Nil(t, err)\n\t}\n\trequire.Nil(t, db.Close())\n\n\t// Create store to trigger migration\n\ts := newSqliteTestStoreFromFile(t, filename, \"\")\n\tcheckSqliteSchemaVersion(t, filename)\n\n\t// Add delayed message\n\tdelayedMessage := model.NewDefaultMessage(\"mytopic\", \"some delayed message\")\n\tdelayedMessage.Time = time.Now().Add(time.Minute).Unix()\n\trequire.Nil(t, s.AddMessage(delayedMessage))\n\n\t// 10, not 11!\n\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 10, len(messages))\n\n\t// 11!\n\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, true)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 11, len(messages))\n\n\t// Check that index \"idx_topic\" exists\n\tverifyDB, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\tdefer verifyDB.Close()\n\trows, err := verifyDB.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)\n\trequire.Nil(t, err)\n\trequire.True(t, rows.Next())\n\tvar indexName string\n\trequire.Nil(t, rows.Scan(&indexName))\n\trequire.Equal(t, \"idx_topic\", indexName)\n\trequire.Nil(t, rows.Close())\n}\n\nfunc TestSqliteStore_Migration_From9(t *testing.T) {\n\t// This primarily tests the awkward migration that introduces the \"expires\" column.\n\t// The migration logic has to update the column, using the existing \"cache-duration\" value.\n\n\tfilename := newSqliteTestStoreFile(t)\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\n\t// Create \"version 9\" schema\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tCREATE TABLE IF NOT EXISTS messages (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tmid TEXT NOT NULL,\n\t\t\ttime INT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tmessage TEXT NOT NULL,\n\t\t\ttitle TEXT NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags TEXT NOT NULL,\n\t\t\tclick TEXT NOT NULL,\n\t\t\ticon TEXT NOT NULL,\n\t\t\tactions TEXT NOT NULL,\n\t\t\tattachment_name TEXT NOT NULL,\n\t\t\tattachment_type TEXT NOT NULL,\n\t\t\tattachment_size INT NOT NULL,\n\t\t\tattachment_expires INT NOT NULL,\n\t\t\tattachment_url TEXT NOT NULL,\n\t\t\tsender TEXT NOT NULL,\n\t\t\tencoding TEXT NOT NULL,\n\t\t\tpublished INT NOT NULL\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);\n\t\tCREATE INDEX IF NOT EXISTS idx_time ON messages (time);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO schemaVersion (id, version) VALUES (1, 9);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Insert a bunch of messages\n\tinsertQuery := `\n\t\tINSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`\n\tfor i := 0; i < 10; i++ {\n\t\t_, err = db.Exec(\n\t\t\tinsertQuery,\n\t\t\tfmt.Sprintf(\"abcd%d\", i),\n\t\t\ttime.Now().Unix(),\n\t\t\t\"mytopic\",\n\t\t\tfmt.Sprintf(\"some message %d\", i),\n\t\t\t\"\",        // title\n\t\t\t0,         // priority\n\t\t\t\"\",        // tags\n\t\t\t\"\",        // click\n\t\t\t\"\",        // icon\n\t\t\t\"\",        // actions\n\t\t\t\"\",        // attachment_name\n\t\t\t\"\",        // attachment_type\n\t\t\t0,         // attachment_size\n\t\t\t0,         // attachment_expires\n\t\t\t\"\",        // attachment_url\n\t\t\t\"9.9.9.9\", // sender\n\t\t\t\"\",        // encoding\n\t\t\t1,         // published\n\t\t)\n\t\trequire.Nil(t, err)\n\t}\n\trequire.Nil(t, db.Close())\n\n\t// Create store to trigger migration\n\tcacheDuration := 17 * time.Hour\n\ts, err := message.NewSQLiteStore(filename, \"\", cacheDuration, 0, 0, false)\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\tcheckSqliteSchemaVersion(t, filename)\n\n\t// Check version\n\tverifyDB, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\tdefer verifyDB.Close()\n\trows, err := verifyDB.Query(`SELECT version FROM schemaVersion WHERE id = 1`)\n\trequire.Nil(t, err)\n\trequire.True(t, rows.Next())\n\tvar version int\n\trequire.Nil(t, rows.Scan(&version))\n\trequire.Equal(t, 14, version)\n\trequire.Nil(t, rows.Close())\n\n\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 10, len(messages))\n\tfor _, m := range messages {\n\t\trequire.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())\n\t\trequire.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())\n\t}\n}\n\nfunc TestSqliteStore_StartupQueries_WAL(t *testing.T) {\n\tfilename := newSqliteTestStoreFile(t)\n\tstartupQueries := `pragma journal_mode = WAL;\npragma synchronous = normal;\npragma temp_store = memory;`\n\ts, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"mytopic\", \"some message\")))\n\trequire.FileExists(t, filename)\n\trequire.FileExists(t, filename+\"-wal\")\n\trequire.FileExists(t, filename+\"-shm\")\n}\n\nfunc TestSqliteStore_StartupQueries_None(t *testing.T) {\n\tfilename := newSqliteTestStoreFile(t)\n\ts, err := message.NewSQLiteStore(filename, \"\", time.Hour, 0, 0, false)\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"mytopic\", \"some message\")))\n\trequire.FileExists(t, filename)\n\trequire.NoFileExists(t, filename+\"-wal\")\n\trequire.NoFileExists(t, filename+\"-shm\")\n}\n\nfunc TestSqliteStore_StartupQueries_Fail(t *testing.T) {\n\tfilename := newSqliteTestStoreFile(t)\n\t_, err := message.NewSQLiteStore(filename, `xx error`, time.Hour, 0, 0, false)\n\trequire.Error(t, err)\n}\n\nfunc TestNopStore(t *testing.T) {\n\ts, err := message.NewNopStore()\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"mytopic\", \"my message\")))\n\n\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\trequire.Nil(t, err)\n\trequire.Empty(t, messages)\n\n\ttopics, err := s.Topics()\n\trequire.Nil(t, err)\n\trequire.Empty(t, topics)\n}\n\nfunc newSqliteTestStoreFile(t *testing.T) string {\n\treturn filepath.Join(t.TempDir(), \"cache.db\")\n}\n\nfunc newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) *message.Cache {\n\ts, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\treturn s\n}\n\nfunc checkSqliteSchemaVersion(t *testing.T, filename string) {\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\tdefer db.Close()\n\trows, err := db.Query(`SELECT version FROM schemaVersion`)\n\trequire.Nil(t, err)\n\trequire.True(t, rows.Next())\n\tvar schemaVersion int\n\trequire.Nil(t, rows.Scan(&schemaVersion))\n\trequire.Equal(t, 14, schemaVersion)\n\trequire.Nil(t, rows.Close())\n}\n"
  },
  {
    "path": "message/cache_test.go",
    "content": "package message_test\n\nimport (\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tdbtest \"heckel.io/ntfy/v2/db/test\"\n\t\"heckel.io/ntfy/v2/message\"\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nfunc newSqliteTestStore(t *testing.T) *message.Cache {\n\tfilename := filepath.Join(t.TempDir(), \"cache.db\")\n\ts, err := message.NewSQLiteStore(filename, \"\", time.Hour, 0, 0, false)\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\treturn s\n}\n\nfunc newMemTestStore(t *testing.T) *message.Cache {\n\ts, err := message.NewMemStore()\n\trequire.Nil(t, err)\n\tt.Cleanup(func() { s.Close() })\n\treturn s\n}\n\nfunc newTestPostgresStore(t *testing.T) *message.Cache {\n\ttestDB := dbtest.CreateTestPostgres(t)\n\tstore, err := message.NewPostgresStore(testDB, 0, 0)\n\trequire.Nil(t, err)\n\treturn store\n}\n\nfunc forEachBackend(t *testing.T, f func(t *testing.T, s *message.Cache)) {\n\tt.Run(\"sqlite\", func(t *testing.T) {\n\t\tf(t, newSqliteTestStore(t))\n\t})\n\tt.Run(\"mem\", func(t *testing.T) {\n\t\tf(t, newMemTestStore(t))\n\t})\n\tt.Run(\"postgres\", func(t *testing.T) {\n\t\tf(t, newTestPostgresStore(t))\n\t})\n}\n\nfunc TestStore_Messages(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"my message\")\n\t\tm1.Time = 1\n\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"my other message\")\n\t\tm2.Time = 2\n\n\t\trequire.Nil(t, s.AddMessage(m1))\n\t\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"example\", \"my example message\")))\n\t\trequire.Nil(t, s.AddMessage(m2))\n\n\t\t// Adding invalid\n\t\trequire.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage(\"mytopic\"))) // These should not be added!\n\t\trequire.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage(\"example\")))      // These should not be added!\n\n\t\t// count\n\t\tcount, err := s.MessagesCount()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, count)\n\n\t\t// mytopic: since all\n\t\tmessages, _ := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, \"my message\", messages[0].Message)\n\t\trequire.Equal(t, \"mytopic\", messages[0].Topic)\n\t\trequire.Equal(t, model.MessageEvent, messages[0].Event)\n\t\trequire.Equal(t, \"\", messages[0].Title)\n\t\trequire.Equal(t, 0, messages[0].Priority)\n\t\trequire.Nil(t, messages[0].Tags)\n\t\trequire.Equal(t, \"my other message\", messages[1].Message)\n\n\t\t// mytopic: since none\n\t\tmessages, _ = s.Messages(\"mytopic\", model.SinceNoMessages, false)\n\t\trequire.Empty(t, messages)\n\n\t\t// mytopic: since m1 (by ID)\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceID(m1.ID), false)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, m2.ID, messages[0].ID)\n\t\trequire.Equal(t, \"my other message\", messages[0].Message)\n\t\trequire.Equal(t, \"mytopic\", messages[0].Topic)\n\n\t\t// mytopic: since 2\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceTime(2), false)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"my other message\", messages[0].Message)\n\n\t\t// mytopic: latest\n\t\tmessages, _ = s.Messages(\"mytopic\", model.SinceLatestMessage, false)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"my other message\", messages[0].Message)\n\n\t\t// example: since all\n\t\tmessages, _ = s.Messages(\"example\", model.SinceAllMessages, false)\n\t\trequire.Equal(t, \"my example message\", messages[0].Message)\n\n\t\t// non-existing: since all\n\t\tmessages, _ = s.Messages(\"doesnotexist\", model.SinceAllMessages, false)\n\t\trequire.Empty(t, messages)\n\t})\n}\n\nfunc TestStore_MessagesLock(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tvar wg sync.WaitGroup\n\t\tfor i := 0; i < 5000; i++ {\n\t\t\twg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tassert.Nil(t, s.AddMessage(model.NewDefaultMessage(\"mytopic\", \"test message\")))\n\t\t\t\twg.Done()\n\t\t\t}()\n\t\t}\n\t\twg.Wait()\n\t})\n}\n\nfunc TestStore_MessagesScheduled(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"message 1\")\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"message 2\")\n\t\tm2.Time = time.Now().Add(time.Hour).Unix()\n\t\tm3 := model.NewDefaultMessage(\"mytopic\", \"message 3\")\n\t\tm3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!\n\t\tm4 := model.NewDefaultMessage(\"mytopic2\", \"message 4\")\n\t\tm4.Time = time.Now().Add(time.Minute).Unix()\n\t\trequire.Nil(t, s.AddMessage(m1))\n\t\trequire.Nil(t, s.AddMessage(m2))\n\t\trequire.Nil(t, s.AddMessage(m3))\n\n\t\tmessages, _ := s.Messages(\"mytopic\", model.SinceAllMessages, false) // exclude scheduled\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"message 1\", messages[0].Message)\n\n\t\tmessages, _ = s.Messages(\"mytopic\", model.SinceAllMessages, true) // include scheduled\n\t\trequire.Equal(t, 3, len(messages))\n\t\trequire.Equal(t, \"message 1\", messages[0].Message)\n\t\trequire.Equal(t, \"message 3\", messages[1].Message) // Order!\n\t\trequire.Equal(t, \"message 2\", messages[2].Message)\n\n\t\tmessages, _ = s.MessagesDue()\n\t\trequire.Empty(t, messages)\n\t})\n}\n\nfunc TestStore_Topics(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"topic1\", \"my example message\")))\n\t\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"topic2\", \"message 1\")))\n\t\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"topic2\", \"message 2\")))\n\t\trequire.Nil(t, s.AddMessage(model.NewDefaultMessage(\"topic2\", \"message 3\")))\n\n\t\ttopics, err := s.Topics()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trequire.Equal(t, 2, len(topics))\n\t\trequire.Contains(t, topics, \"topic1\")\n\t\trequire.Contains(t, topics, \"topic2\")\n\t})\n}\n\nfunc TestStore_MessagesTagsPrioAndTitle(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"some message\")\n\t\tm.Tags = []string{\"tag1\", \"tag2\"}\n\t\tm.Priority = 5\n\t\tm.Title = \"some title\"\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tmessages, _ := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, messages[0].Tags)\n\t\trequire.Equal(t, 5, messages[0].Priority)\n\t\trequire.Equal(t, \"some title\", messages[0].Title)\n\t})\n}\n\nfunc TestStore_MessagesSinceID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"message 1\")\n\t\tm1.Time = 100\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"message 2\")\n\t\tm2.Time = 200\n\t\tm3 := model.NewDefaultMessage(\"mytopic\", \"message 3\")\n\t\tm3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5\n\t\tm4 := model.NewDefaultMessage(\"mytopic\", \"message 4\")\n\t\tm4.Time = 400\n\t\tm5 := model.NewDefaultMessage(\"mytopic\", \"message 5\")\n\t\tm5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7\n\t\tm6 := model.NewDefaultMessage(\"mytopic\", \"message 6\")\n\t\tm6.Time = 600\n\t\tm7 := model.NewDefaultMessage(\"mytopic\", \"message 7\")\n\t\tm7.Time = 700\n\n\t\trequire.Nil(t, s.AddMessage(m1))\n\t\trequire.Nil(t, s.AddMessage(m2))\n\t\trequire.Nil(t, s.AddMessage(m3))\n\t\trequire.Nil(t, s.AddMessage(m4))\n\t\trequire.Nil(t, s.AddMessage(m5))\n\t\trequire.Nil(t, s.AddMessage(m6))\n\t\trequire.Nil(t, s.AddMessage(m7))\n\n\t\t// Case 1: Since ID exists, exclude scheduled\n\t\tmessages, _ := s.Messages(\"mytopic\", model.NewSinceID(m2.ID), false)\n\t\trequire.Equal(t, 3, len(messages))\n\t\trequire.Equal(t, \"message 4\", messages[0].Message)\n\t\trequire.Equal(t, \"message 6\", messages[1].Message) // Not scheduled m3/m5!\n\t\trequire.Equal(t, \"message 7\", messages[2].Message)\n\n\t\t// Case 2: Since ID exists, include scheduled\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceID(m2.ID), true)\n\t\trequire.Equal(t, 5, len(messages))\n\t\trequire.Equal(t, \"message 4\", messages[0].Message)\n\t\trequire.Equal(t, \"message 6\", messages[1].Message)\n\t\trequire.Equal(t, \"message 7\", messages[2].Message)\n\t\trequire.Equal(t, \"message 5\", messages[3].Message) // Order!\n\t\trequire.Equal(t, \"message 3\", messages[4].Message) // Order!\n\n\t\t// Case 3: Since ID does not exist (-> Return all messages), include scheduled\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceID(\"doesntexist\"), true)\n\t\trequire.Equal(t, 7, len(messages))\n\t\trequire.Equal(t, \"message 1\", messages[0].Message)\n\t\trequire.Equal(t, \"message 2\", messages[1].Message)\n\t\trequire.Equal(t, \"message 4\", messages[2].Message)\n\t\trequire.Equal(t, \"message 6\", messages[3].Message)\n\t\trequire.Equal(t, \"message 7\", messages[4].Message)\n\t\trequire.Equal(t, \"message 5\", messages[5].Message) // Order!\n\t\trequire.Equal(t, \"message 3\", messages[6].Message) // Order!\n\n\t\t// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceID(m7.ID), false)\n\t\trequire.Equal(t, 0, len(messages))\n\n\t\t// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled\n\t\tmessages, _ = s.Messages(\"mytopic\", model.NewSinceID(m7.ID), true)\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, \"message 5\", messages[0].Message)\n\t\trequire.Equal(t, \"message 3\", messages[1].Message)\n\t})\n}\n\nfunc TestStore_Prune(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tnow := time.Now().Unix()\n\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"my message\")\n\t\tm1.Time = now - 10\n\t\tm1.Expires = now - 5\n\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"my other message\")\n\t\tm2.Time = now - 5\n\t\tm2.Expires = now + 5 // In the future\n\n\t\tm3 := model.NewDefaultMessage(\"another_topic\", \"and another one\")\n\t\tm3.Time = now - 12\n\t\tm3.Expires = now - 2\n\n\t\trequire.Nil(t, s.AddMessage(m1))\n\t\trequire.Nil(t, s.AddMessage(m2))\n\t\trequire.Nil(t, s.AddMessage(m3))\n\n\t\tcount, err := s.MessagesCount()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, count)\n\n\t\texpiredMessageIDs, err := s.MessagesExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, s.DeleteMessages(expiredMessageIDs...))\n\n\t\tcount, err = s.MessagesCount()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, count)\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"my other message\", messages[0].Message)\n\t})\n}\n\nfunc TestStore_Attachments(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\texpires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"flower for you\")\n\t\tm.ID = \"m1\"\n\t\tm.SequenceID = \"m1\"\n\t\tm.Sender = netip.MustParseAddr(\"1.2.3.4\")\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"flower.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tSize:    5000,\n\t\t\tExpires: expires1,\n\t\t\tURL:     \"https://ntfy.sh/file/AbDeFgJhal.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\texpires2 := time.Now().Add(2 * time.Hour).Unix() // Future\n\t\tm = model.NewDefaultMessage(\"mytopic\", \"sending you a car\")\n\t\tm.ID = \"m2\"\n\t\tm.SequenceID = \"m2\"\n\t\tm.Sender = netip.MustParseAddr(\"1.2.3.4\")\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"car.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tSize:    10000,\n\t\t\tExpires: expires2,\n\t\t\tURL:     \"https://ntfy.sh/file/aCaRURL.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\texpires3 := time.Now().Add(1 * time.Hour).Unix() // Future\n\t\tm = model.NewDefaultMessage(\"another-topic\", \"sending you another car\")\n\t\tm.ID = \"m3\"\n\t\tm.SequenceID = \"m3\"\n\t\tm.User = \"u_BAsbaAa\"\n\t\tm.Sender = netip.MustParseAddr(\"5.6.7.8\")\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"another-car.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tSize:    20000,\n\t\t\tExpires: expires3,\n\t\t\tURL:     \"https://ntfy.sh/file/zakaDHFW.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\n\t\trequire.Equal(t, \"flower for you\", messages[0].Message)\n\t\trequire.Equal(t, \"flower.jpg\", messages[0].Attachment.Name)\n\t\trequire.Equal(t, \"image/jpeg\", messages[0].Attachment.Type)\n\t\trequire.Equal(t, int64(5000), messages[0].Attachment.Size)\n\t\trequire.Equal(t, expires1, messages[0].Attachment.Expires)\n\t\trequire.Equal(t, \"https://ntfy.sh/file/AbDeFgJhal.jpg\", messages[0].Attachment.URL)\n\t\trequire.Equal(t, \"1.2.3.4\", messages[0].Sender.String())\n\n\t\trequire.Equal(t, \"sending you a car\", messages[1].Message)\n\t\trequire.Equal(t, \"car.jpg\", messages[1].Attachment.Name)\n\t\trequire.Equal(t, \"image/jpeg\", messages[1].Attachment.Type)\n\t\trequire.Equal(t, int64(10000), messages[1].Attachment.Size)\n\t\trequire.Equal(t, expires2, messages[1].Attachment.Expires)\n\t\trequire.Equal(t, \"https://ntfy.sh/file/aCaRURL.jpg\", messages[1].Attachment.URL)\n\t\trequire.Equal(t, \"1.2.3.4\", messages[1].Sender.String())\n\n\t\tsize, err := s.AttachmentBytesUsedBySender(\"1.2.3.4\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(10000), size)\n\n\t\tsize, err = s.AttachmentBytesUsedBySender(\"5.6.7.8\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), size) // Accounted to the user, not the IP!\n\n\t\tsize, err = s.AttachmentBytesUsedByUser(\"u_BAsbaAa\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(20000), size)\n\t})\n}\n\nfunc TestStore_AttachmentsExpired(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"flower for you\")\n\t\tm.ID = \"m1\"\n\t\tm.SequenceID = \"m1\"\n\t\tm.Expires = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tm = model.NewDefaultMessage(\"mytopic\", \"message with attachment\")\n\t\tm.ID = \"m2\"\n\t\tm.SequenceID = \"m2\"\n\t\tm.Expires = time.Now().Add(2 * time.Hour).Unix()\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"car.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tSize:    10000,\n\t\t\tExpires: time.Now().Add(2 * time.Hour).Unix(),\n\t\t\tURL:     \"https://ntfy.sh/file/aCaRURL.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tm = model.NewDefaultMessage(\"mytopic\", \"message with external attachment\")\n\t\tm.ID = \"m3\"\n\t\tm.SequenceID = \"m3\"\n\t\tm.Expires = time.Now().Add(2 * time.Hour).Unix()\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"car.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tExpires: 0, // Unknown!\n\t\t\tURL:     \"https://somedomain.com/car.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tm = model.NewDefaultMessage(\"mytopic2\", \"message with expired attachment\")\n\t\tm.ID = \"m4\"\n\t\tm.SequenceID = \"m4\"\n\t\tm.Expires = time.Now().Add(2 * time.Hour).Unix()\n\t\tm.Attachment = &model.Attachment{\n\t\t\tName:    \"expired-car.jpg\",\n\t\t\tType:    \"image/jpeg\",\n\t\t\tSize:    20000,\n\t\t\tExpires: time.Now().Add(-1 * time.Hour).Unix(),\n\t\t\tURL:     \"https://ntfy.sh/file/aCaRURL.jpg\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tids, err := s.AttachmentsExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(ids))\n\t\trequire.Equal(t, \"m4\", ids[0])\n\t})\n}\n\nfunc TestStore_Sender(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"mymessage\")\n\t\tm1.Sender = netip.MustParseAddr(\"1.2.3.4\")\n\t\trequire.Nil(t, s.AddMessage(m1))\n\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"mymessage without sender\")\n\t\trequire.Nil(t, s.AddMessage(m2))\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, messages[0].Sender, netip.MustParseAddr(\"1.2.3.4\"))\n\t\trequire.Equal(t, messages[1].Sender, netip.Addr{})\n\t})\n}\n\nfunc TestStore_DeleteScheduledBySequenceID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Create a scheduled (unpublished) message\n\t\tscheduledMsg := model.NewDefaultMessage(\"mytopic\", \"scheduled message\")\n\t\tscheduledMsg.ID = \"scheduled1\"\n\t\tscheduledMsg.SequenceID = \"seq123\"\n\t\tscheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled\n\t\trequire.Nil(t, s.AddMessage(scheduledMsg))\n\n\t\t// Create a published message with different sequence ID\n\t\tpublishedMsg := model.NewDefaultMessage(\"mytopic\", \"published message\")\n\t\tpublishedMsg.ID = \"published1\"\n\t\tpublishedMsg.SequenceID = \"seq456\"\n\t\tpublishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published\n\t\trequire.Nil(t, s.AddMessage(publishedMsg))\n\n\t\t// Create a scheduled message in a different topic\n\t\totherTopicMsg := model.NewDefaultMessage(\"othertopic\", \"other scheduled\")\n\t\totherTopicMsg.ID = \"other1\"\n\t\totherTopicMsg.SequenceID = \"seq123\" // Same sequence ID as scheduledMsg\n\t\totherTopicMsg.Time = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(otherTopicMsg))\n\n\t\t// Verify all messages exist (including scheduled)\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\n\t\tmessages, err = s.Messages(\"othertopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\n\t\t// Delete scheduled message by sequence ID and verify returned IDs\n\t\tdeletedIDs, err := s.DeleteScheduledBySequenceID(\"mytopic\", \"seq123\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(deletedIDs))\n\t\trequire.Equal(t, \"scheduled1\", deletedIDs[0])\n\n\t\t// Verify scheduled message is deleted\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"published message\", messages[0].Message)\n\n\t\t// Verify other topic's message still exists (topic-scoped deletion)\n\t\tmessages, err = s.Messages(\"othertopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"other scheduled\", messages[0].Message)\n\n\t\t// Deleting non-existent sequence ID should return empty list\n\t\tdeletedIDs, err = s.DeleteScheduledBySequenceID(\"mytopic\", \"nonexistent\")\n\t\trequire.Nil(t, err)\n\t\trequire.Empty(t, deletedIDs)\n\n\t\t// Deleting published message should not affect it (only deletes unpublished)\n\t\tdeletedIDs, err = s.DeleteScheduledBySequenceID(\"mytopic\", \"seq456\")\n\t\trequire.Nil(t, err)\n\t\trequire.Empty(t, deletedIDs)\n\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"published message\", messages[0].Message)\n\t})\n}\n\nfunc TestStore_MessageByID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Add a message\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"some message\")\n\t\tm.Title = \"some title\"\n\t\tm.Priority = 4\n\t\tm.Tags = []string{\"tag1\", \"tag2\"}\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\t// Retrieve by ID\n\t\tretrieved, err := s.Message(m.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, m.ID, retrieved.ID)\n\t\trequire.Equal(t, \"mytopic\", retrieved.Topic)\n\t\trequire.Equal(t, \"some message\", retrieved.Message)\n\t\trequire.Equal(t, \"some title\", retrieved.Title)\n\t\trequire.Equal(t, 4, retrieved.Priority)\n\t\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, retrieved.Tags)\n\n\t\t// Non-existent ID returns ErrMessageNotFound\n\t\t_, err = s.Message(\"doesnotexist\")\n\t\trequire.Equal(t, model.ErrMessageNotFound, err)\n\t})\n}\n\nfunc TestStore_MarkPublished(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Add a scheduled message (future time -> unpublished)\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"scheduled message\")\n\t\tm.Time = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\t// Verify it does not appear in non-scheduled queries\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(messages))\n\n\t\t// Verify it does appear in scheduled queries\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\n\t\t// Mark as published\n\t\trequire.Nil(t, s.MarkPublished(m))\n\n\t\t// Now it should appear in non-scheduled queries too\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"scheduled message\", messages[0].Message)\n\t})\n}\n\nfunc TestStore_ExpireMessages(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Add messages to two topics\n\t\tm1 := model.NewDefaultMessage(\"topic1\", \"message 1\")\n\t\tm1.Expires = time.Now().Add(time.Hour).Unix()\n\t\tm2 := model.NewDefaultMessage(\"topic1\", \"message 2\")\n\t\tm2.Expires = time.Now().Add(time.Hour).Unix()\n\t\tm3 := model.NewDefaultMessage(\"topic2\", \"message 3\")\n\t\tm3.Expires = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(m1))\n\t\trequire.Nil(t, s.AddMessage(m2))\n\t\trequire.Nil(t, s.AddMessage(m3))\n\n\t\t// Verify all messages exist\n\t\tmessages, err := s.Messages(\"topic1\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\t\tmessages, err = s.Messages(\"topic2\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\n\t\t// Expire topic1 messages\n\t\trequire.Nil(t, s.ExpireMessages(\"topic1\"))\n\n\t\t// topic1 messages should now be expired (expires set to past)\n\t\texpiredIDs, err := s.MessagesExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(expiredIDs))\n\t\tsort.Strings(expiredIDs)\n\t\texpectedIDs := []string{m1.ID, m2.ID}\n\t\tsort.Strings(expectedIDs)\n\t\trequire.Equal(t, expectedIDs, expiredIDs)\n\n\t\t// topic2 should be unaffected\n\t\tmessages, err = s.Messages(\"topic2\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"message 3\", messages[0].Message)\n\t})\n}\n\nfunc TestStore_MarkAttachmentsDeleted(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Add a message with an expired attachment (file needs cleanup)\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"old file\")\n\t\tm1.ID = \"msg1\"\n\t\tm1.SequenceID = \"msg1\"\n\t\tm1.Expires = time.Now().Add(time.Hour).Unix()\n\t\tm1.Attachment = &model.Attachment{\n\t\t\tName:    \"old.pdf\",\n\t\t\tType:    \"application/pdf\",\n\t\t\tSize:    50000,\n\t\t\tExpires: time.Now().Add(-time.Hour).Unix(), // Expired\n\t\t\tURL:     \"https://ntfy.sh/file/old.pdf\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m1))\n\n\t\t// Add a message with another expired attachment\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"another old file\")\n\t\tm2.ID = \"msg2\"\n\t\tm2.SequenceID = \"msg2\"\n\t\tm2.Expires = time.Now().Add(time.Hour).Unix()\n\t\tm2.Attachment = &model.Attachment{\n\t\t\tName:    \"another.pdf\",\n\t\t\tType:    \"application/pdf\",\n\t\t\tSize:    30000,\n\t\t\tExpires: time.Now().Add(-time.Hour).Unix(), // Expired\n\t\t\tURL:     \"https://ntfy.sh/file/another.pdf\",\n\t\t}\n\t\trequire.Nil(t, s.AddMessage(m2))\n\n\t\t// Both should show as expired attachments needing cleanup\n\t\tids, err := s.AttachmentsExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(ids))\n\n\t\t// Mark msg1's attachment as deleted (file cleaned up)\n\t\trequire.Nil(t, s.MarkAttachmentsDeleted(\"msg1\"))\n\n\t\t// Now only msg2 should show as needing cleanup\n\t\tids, err = s.AttachmentsExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(ids))\n\t\trequire.Equal(t, \"msg2\", ids[0])\n\n\t\t// Mark msg2 too\n\t\trequire.Nil(t, s.MarkAttachmentsDeleted(\"msg2\"))\n\n\t\t// No more expired attachments to clean up\n\t\tids, err = s.AttachmentsExpired()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(ids))\n\n\t\t// Messages themselves still exist\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\t})\n}\n\nfunc TestStore_Stats(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Initial stats should be zero\n\t\tmessages, err := s.Stats()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), messages)\n\n\t\t// Update stats\n\t\trequire.Nil(t, s.UpdateStats(42))\n\t\tmessages, err = s.Stats()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(42), messages)\n\n\t\t// Update again (overwrites)\n\t\trequire.Nil(t, s.UpdateStats(100))\n\t\tmessages, err = s.Stats()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(100), messages)\n\t})\n}\n\nfunc TestStore_AddMessages(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Batch add multiple messages\n\t\tmsgs := []*model.Message{\n\t\t\tmodel.NewDefaultMessage(\"mytopic\", \"batch 1\"),\n\t\t\tmodel.NewDefaultMessage(\"mytopic\", \"batch 2\"),\n\t\t\tmodel.NewDefaultMessage(\"othertopic\", \"batch 3\"),\n\t\t}\n\t\trequire.Nil(t, s.AddMessages(msgs))\n\n\t\t// Verify all were inserted\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(messages))\n\n\t\tmessages, err = s.Messages(\"othertopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"batch 3\", messages[0].Message)\n\n\t\t// Empty batch should succeed\n\t\trequire.Nil(t, s.AddMessages([]*model.Message{}))\n\n\t\t// Batch with invalid event type should fail\n\t\tbadMsgs := []*model.Message{\n\t\t\tmodel.NewKeepaliveMessage(\"mytopic\"),\n\t\t}\n\t\trequire.NotNil(t, s.AddMessages(badMsgs))\n\t})\n}\n\nfunc TestStore_MessagesDue(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Add a message scheduled in the past (i.e. it's due now)\n\t\tm1 := model.NewDefaultMessage(\"mytopic\", \"due message\")\n\t\tm1.Time = time.Now().Add(-time.Second).Unix()\n\t\t// Set expires in the future so it doesn't get pruned\n\t\tm1.Expires = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(m1))\n\n\t\t// Add a message scheduled in the future (not due)\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"future message\")\n\t\tm2.Time = time.Now().Add(time.Hour).Unix()\n\t\trequire.Nil(t, s.AddMessage(m2))\n\n\t\t// Mark m1 as published so it won't be \"due\"\n\t\t// (MessagesDue returns unpublished messages whose time <= now)\n\t\t// m1 is auto-published (time <= now), so it should not be due\n\t\t// m2 is unpublished (time in future), not due yet\n\t\tdue, err := s.MessagesDue()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(due))\n\n\t\t// Add a message that was explicitly scheduled in the past but time has \"arrived\"\n\t\t// We need to manipulate the database to create a truly \"due\" message:\n\t\t// a message with published=false and time <= now\n\t\tm3 := model.NewDefaultMessage(\"mytopic\", \"truly due message\")\n\t\tm3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now\n\t\trequire.Nil(t, s.AddMessage(m3))\n\n\t\t// Not due yet\n\t\tdue, err = s.MessagesDue()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(due))\n\n\t\t// Wait for it to become due\n\t\ttime.Sleep(3 * time.Second)\n\n\t\tdue, err = s.MessagesDue()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(due))\n\t\trequire.Equal(t, \"truly due message\", due[0].Message)\n\t})\n}\n\nfunc TestStore_MessageFieldRoundTrip(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Create a message with all fields populated\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"hello world\")\n\t\tm.SequenceID = \"custom_seq_id\"\n\t\tm.Title = \"A Title\"\n\t\tm.Priority = 4\n\t\tm.Tags = []string{\"warning\", \"srv01\"}\n\t\tm.Click = \"https://example.com/click\"\n\t\tm.Icon = \"https://example.com/icon.png\"\n\t\tm.Actions = []*model.Action{\n\t\t\t{\n\t\t\t\tID:     \"action1\",\n\t\t\t\tAction: \"view\",\n\t\t\t\tLabel:  \"Open Site\",\n\t\t\t\tURL:    \"https://example.com\",\n\t\t\t\tClear:  true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:      \"action2\",\n\t\t\t\tAction:  \"http\",\n\t\t\t\tLabel:   \"Call Webhook\",\n\t\t\t\tURL:     \"https://example.com/hook\",\n\t\t\t\tMethod:  \"PUT\",\n\t\t\t\tHeaders: map[string]string{\"X-Token\": \"secret\"},\n\t\t\t\tBody:    `{\"key\":\"value\"}`,\n\t\t\t},\n\t\t}\n\t\tm.ContentType = \"text/markdown\"\n\t\tm.Encoding = \"base64\"\n\t\tm.Sender = netip.MustParseAddr(\"9.8.7.6\")\n\t\tm.User = \"u_TestUser123\"\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\t// Retrieve and verify every field\n\t\tretrieved, err := s.Message(m.ID)\n\t\trequire.Nil(t, err)\n\n\t\trequire.Equal(t, m.ID, retrieved.ID)\n\t\trequire.Equal(t, \"custom_seq_id\", retrieved.SequenceID)\n\t\trequire.Equal(t, m.Time, retrieved.Time)\n\t\trequire.Equal(t, m.Expires, retrieved.Expires)\n\t\trequire.Equal(t, model.MessageEvent, retrieved.Event)\n\t\trequire.Equal(t, \"mytopic\", retrieved.Topic)\n\t\trequire.Equal(t, \"hello world\", retrieved.Message)\n\t\trequire.Equal(t, \"A Title\", retrieved.Title)\n\t\trequire.Equal(t, 4, retrieved.Priority)\n\t\trequire.Equal(t, []string{\"warning\", \"srv01\"}, retrieved.Tags)\n\t\trequire.Equal(t, \"https://example.com/click\", retrieved.Click)\n\t\trequire.Equal(t, \"https://example.com/icon.png\", retrieved.Icon)\n\t\trequire.Equal(t, \"text/markdown\", retrieved.ContentType)\n\t\trequire.Equal(t, \"base64\", retrieved.Encoding)\n\t\trequire.Equal(t, netip.MustParseAddr(\"9.8.7.6\"), retrieved.Sender)\n\t\trequire.Equal(t, \"u_TestUser123\", retrieved.User)\n\n\t\t// Verify actions round-trip\n\t\trequire.Equal(t, 2, len(retrieved.Actions))\n\n\t\trequire.Equal(t, \"action1\", retrieved.Actions[0].ID)\n\t\trequire.Equal(t, \"view\", retrieved.Actions[0].Action)\n\t\trequire.Equal(t, \"Open Site\", retrieved.Actions[0].Label)\n\t\trequire.Equal(t, \"https://example.com\", retrieved.Actions[0].URL)\n\t\trequire.Equal(t, true, retrieved.Actions[0].Clear)\n\n\t\trequire.Equal(t, \"action2\", retrieved.Actions[1].ID)\n\t\trequire.Equal(t, \"http\", retrieved.Actions[1].Action)\n\t\trequire.Equal(t, \"Call Webhook\", retrieved.Actions[1].Label)\n\t\trequire.Equal(t, \"https://example.com/hook\", retrieved.Actions[1].URL)\n\t\trequire.Equal(t, \"PUT\", retrieved.Actions[1].Method)\n\t\trequire.Equal(t, \"secret\", retrieved.Actions[1].Headers[\"X-Token\"])\n\t\trequire.Equal(t, `{\"key\":\"value\"}`, retrieved.Actions[1].Body)\n\t})\n}\n\nfunc TestStore_AddMessage_InvalidUTF8(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// 0xc9 0x43: Latin-1 \"ÉC\" — 0xc9 starts a 2-byte UTF-8 sequence but 0x43 ('C') is not a continuation byte\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"\\xc9Cas du serveur\")\n\t\trequire.Nil(t, s.AddMessage(m))\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"\\uFFFDCas du serveur\", messages[0].Message)\n\n\t\t// 0xae: Latin-1 \"®\" — isolated byte above 0x7F, not a valid UTF-8 start for single byte\n\t\tm2 := model.NewDefaultMessage(\"mytopic\", \"Product\\xae Pro\")\n\t\trequire.Nil(t, s.AddMessage(m2))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"Product\\uFFFD Pro\", messages[1].Message)\n\n\t\t// 0xe8 0x6d 0x65: Latin-1 \"ème\" — 0xe8 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte\n\t\tm3 := model.NewDefaultMessage(\"mytopic\", \"probl\\xe8me critique\")\n\t\trequire.Nil(t, s.AddMessage(m3))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"probl\\uFFFDme critique\", messages[2].Message)\n\n\t\t// 0xb2: Latin-1 \"²\" — isolated byte in 0x80-0xBF range (UTF-8 continuation byte without lead)\n\t\tm4 := model.NewDefaultMessage(\"mytopic\", \"CO\\xb2 level high\")\n\t\trequire.Nil(t, s.AddMessage(m4))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"CO\\uFFFD level high\", messages[3].Message)\n\n\t\t// 0xe9 0x6d 0x61: Latin-1 \"éma\" — 0xe9 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte\n\t\tm5 := model.NewDefaultMessage(\"mytopic\", \"th\\xe9matique\")\n\t\trequire.Nil(t, s.AddMessage(m5))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"th\\uFFFDmatique\", messages[4].Message)\n\n\t\t// 0xed 0x64 0x65: Latin-1 \"íde\" — 0xed starts a 3-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte\n\t\tm6 := model.NewDefaultMessage(\"mytopic\", \"vid\\xed\\x64eo surveillance\")\n\t\trequire.Nil(t, s.AddMessage(m6))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"vid\\uFFFDdeo surveillance\", messages[5].Message)\n\n\t\t// 0xf3 0x6e 0x3a 0x20: Latin-1 \"ón: \" — 0xf3 starts a 4-byte UTF-8 sequence but 0x6e ('n') is not a continuation byte\n\t\tm7 := model.NewDefaultMessage(\"mytopic\", \"notificaci\\xf3n: alerta\")\n\t\trequire.Nil(t, s.AddMessage(m7))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"notificaci\\uFFFDn: alerta\", messages[6].Message)\n\n\t\t// 0xb7: Latin-1 \"·\" — isolated continuation byte\n\t\tm8 := model.NewDefaultMessage(\"mytopic\", \"item\\xb7value\")\n\t\trequire.Nil(t, s.AddMessage(m8))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"item\\uFFFDvalue\", messages[7].Message)\n\n\t\t// 0xa8: Latin-1 \"¨\" — isolated continuation byte\n\t\tm9 := model.NewDefaultMessage(\"mytopic\", \"na\\xa8ve\")\n\t\trequire.Nil(t, s.AddMessage(m9))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"na\\uFFFDve\", messages[8].Message)\n\n\t\t// 0xdf 0x64: Latin-1 \"ßd\" — 0xdf starts a 2-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte\n\t\tm10 := model.NewDefaultMessage(\"mytopic\", \"gro\\xdf\\x64ruck\")\n\t\trequire.Nil(t, s.AddMessage(m10))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"gro\\uFFFDdruck\", messages[9].Message)\n\n\t\t// 0xe4 0x67 0x74: Latin-1 \"ägt\" — 0xe4 starts a 3-byte UTF-8 sequence but 0x67 ('g') is not a continuation byte\n\t\tm11 := model.NewDefaultMessage(\"mytopic\", \"tr\\xe4gt Last\")\n\t\trequire.Nil(t, s.AddMessage(m11))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"tr\\uFFFDgt Last\", messages[10].Message)\n\n\t\t// 0xe9 0x65 0x20: Latin-1 \"ée \" — 0xe9 starts a 3-byte UTF-8 sequence but 0x65 ('e') is not a continuation byte\n\t\tm12 := model.NewDefaultMessage(\"mytopic\", \"journ\\xe9\\x65 termin\\xe9\\x65\")\n\t\trequire.Nil(t, s.AddMessage(m12))\n\t\tmessages, err = s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"journ\\uFFFDe termin\\uFFFDe\", messages[11].Message)\n\t})\n}\n\nfunc TestStore_AddMessage_NullByte(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// 0x00: NUL byte — valid UTF-8 but rejected by PostgreSQL\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"hello\\x00world\")\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"helloworld\", messages[0].Message)\n\t})\n}\n\nfunc TestStore_AddMessage_InvalidUTF8InTitleAndTags(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Invalid UTF-8 can arrive via HTTP headers (Title, Tags) which bypass body validation\n\t\tm := model.NewDefaultMessage(\"mytopic\", \"valid message\")\n\t\tm.Title = \"\\xc9clipse du syst\\xe8me\"\n\t\tm.Tags = []string{\"probl\\xe8me\", \"syst\\xe9me\"}\n\t\tm.Click = \"https://example.com/\\xae\"\n\t\trequire.Nil(t, s.AddMessage(m))\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"\\uFFFDclipse du syst\\uFFFDme\", messages[0].Title)\n\t\trequire.Equal(t, \"probl\\uFFFDme\", messages[0].Tags[0])\n\t\trequire.Equal(t, \"syst\\uFFFDme\", messages[0].Tags[1])\n\t\trequire.Equal(t, \"https://example.com/\\uFFFD\", messages[0].Click)\n\t})\n}\n\nfunc TestStore_AddMessage_InvalidUTF8BatchDoesNotDropValidMessages(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, s *message.Cache) {\n\t\t// Previously, a single invalid message would roll back the entire batch transaction.\n\t\t// Sanitization ensures all messages in a batch are written successfully.\n\t\tmsgs := []*model.Message{\n\t\t\tmodel.NewDefaultMessage(\"mytopic\", \"valid message 1\"),\n\t\t\tmodel.NewDefaultMessage(\"mytopic\", \"notificaci\\xf3n: alerta\"),\n\t\t\tmodel.NewDefaultMessage(\"mytopic\", \"valid message 3\"),\n\t\t}\n\t\trequire.Nil(t, s.AddMessages(msgs))\n\n\t\tmessages, err := s.Messages(\"mytopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(messages))\n\t})\n}\n"
  },
  {
    "path": "mkdocs.yml",
    "content": "site_dir: server/docs\nsite_name: ntfy\nsite_url: https://ntfy.sh\nsite_description: Send push notifications to your phone via PUT/POST\ncopyright: Made with ❤️ by Philipp C. Heckel\nrepo_name: binwiederhier/ntfy\nrepo_url: https://github.com/binwiederhier/ntfy\nedit_uri: blob/main/docs/\n\ntheme:\n  name: material\n  font: false\n  language: en\n  custom_dir: docs/_overrides\n  logo: static/img/ntfy.png\n  favicon: static/img/favicon.ico\n  include_search_page: false\n  search_index_only: true\n  palette:\n    - media: \"(prefers-color-scheme: light)\"  # Light mode\n      scheme: default\n      toggle:\n        icon: material/lightbulb-outline\n        name: Switch to dark mode\n    - media: \"(prefers-color-scheme: dark)\"  # Dark mode\n      scheme: slate\n      accent: indigo\n      toggle:\n        icon: material/lightbulb\n        name: Switch to light mode\n  features:\n    - search.suggest\n    - search.highlight\n    - search.share\n    - navigation.sections\n    - toc.integrate\n    - content.tabs.link\nextra:\n  homepage: /\n  social:\n    - icon: fontawesome/brands/github-alt\n      link: https://github.com/binwiederhier\nextra_javascript:\n  - static/js/extra.js\n  - static/js/bcrypt.js\n  - static/js/config-generator.js\nextra_css:\n  - static/css/extra.css\n  - static/css/config-generator.css\n\nmarkdown_extensions:\n  - admonition\n  - meta\n  - toc:\n      permalink: true\n  - pymdownx.tabbed:\n      alternate_style: true\n  - pymdownx.superfences\n  - pymdownx.highlight:\n      extend_pygments_lang:\n        - name: php-inline\n          lang: php\n          options:\n            startinline: true\n  - pymdownx.tasklist:\n      custom_checkbox: true\n  - attr_list\n  - md_in_html\n  - pymdownx.emoji:\n      emoji_index: !!python/name:material.extensions.emoji.twemoji\n      emoji_generator: !!python/name:material.extensions.emoji.to_svg\n\nhooks:\n  - docs/hooks.py\n\nplugins:\n  - search\n  - minify:\n      minify_html: true\n\nnav:\n  - \"Getting started\": index.md\n  - \"Publishing\":\n      - \"Sending messages\": publish.md\n  - \"Subscribing\":\n      - \"From your phone\": subscribe/phone.md\n      - \"From the Web app\": subscribe/web.md\n      - \"From the Desktop\": subscribe/pwa.md\n      - \"From the CLI\": subscribe/cli.md\n      - \"Using the API\": subscribe/api.md\n  - \"Self-hosting\":\n      - \"Installation\": install.md\n      - \"Configuration\": config.md\n  - \"Other things\":\n      - \"FAQs\": faq.md\n      - \"Examples\": examples.md\n      - \"Integrations + projects\": integrations.md\n      - \"Release notes\": releases.md\n      - \"Emojis 🥳 🎉\": emojis.md\n      - \"Template functions\": publish/template-functions.md\n      - \"Troubleshooting\": troubleshooting.md\n      - \"Known issues\": known-issues.md\n      - \"Deprecation notices\": deprecations.md\n      - \"Development\": develop.md\n      - \"Contributing\": contributing.md\n      - \"Privacy policy\": privacy.md\n      - \"Terms of Service\": terms.md\n      - \"Contact\": contact.md\n\n\n"
  },
  {
    "path": "model/model.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n\t\"net/netip\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// List of possible events\nconst (\n\tOpenEvent          = \"open\"\n\tKeepaliveEvent     = \"keepalive\"\n\tMessageEvent       = \"message\"\n\tMessageDeleteEvent = \"message_delete\"\n\tMessageClearEvent  = \"message_clear\"\n\tPollRequestEvent   = \"poll_request\"\n)\n\n// MessageIDLength is the length of a randomly generated message ID\nconst MessageIDLength = 12\n\n// Errors for message operations\nvar (\n\tErrUnexpectedMessageType = errors.New(\"unexpected message type\")\n\tErrMessageNotFound       = errors.New(\"message not found\")\n)\n\n// Message represents a message published to a topic\ntype Message struct {\n\tID          string      `json:\"id\"`                    // Random message ID\n\tSequenceID  string      `json:\"sequence_id,omitempty\"` // Message sequence ID for updating message contents (omitted if same as ID)\n\tTime        int64       `json:\"time\"`                  // Unix time in seconds\n\tExpires     int64       `json:\"expires,omitempty\"`     // Unix time in seconds (not required for open/keepalive)\n\tEvent       string      `json:\"event\"`                 // One of the above\n\tTopic       string      `json:\"topic\"`\n\tTitle       string      `json:\"title,omitempty\"`\n\tMessage     string      `json:\"message,omitempty\"`\n\tPriority    int         `json:\"priority,omitempty\"`\n\tTags        []string    `json:\"tags,omitempty\"`\n\tClick       string      `json:\"click,omitempty\"`\n\tIcon        string      `json:\"icon,omitempty\"`\n\tActions     []*Action   `json:\"actions,omitempty\"`\n\tAttachment  *Attachment `json:\"attachment,omitempty\"`\n\tPollID      string      `json:\"poll_id,omitempty\"`\n\tContentType string      `json:\"content_type,omitempty\"` // text/plain by default (if empty), or text/markdown\n\tEncoding    string      `json:\"encoding,omitempty\"`     // Empty for raw UTF-8, or \"base64\" for encoded bytes\n\tSender      netip.Addr  `json:\"-\"`                      // IP address of uploader, used for rate limiting\n\tUser        string      `json:\"-\"`                      // UserID of the uploader, used to associated attachments\n}\n\n// Context returns a log context for the message\nfunc (m *Message) Context() log.Context {\n\tfields := map[string]any{\n\t\t\"topic\":               m.Topic,\n\t\t\"message_id\":          m.ID,\n\t\t\"message_sequence_id\": m.SequenceID,\n\t\t\"message_time\":        m.Time,\n\t\t\"message_event\":       m.Event,\n\t\t\"message_body_size\":   len(m.Message),\n\t}\n\tif m.Sender.IsValid() {\n\t\tfields[\"message_sender\"] = m.Sender.String()\n\t}\n\tif m.User != \"\" {\n\t\tfields[\"message_user\"] = m.User\n\t}\n\treturn fields\n}\n\n// SanitizeUTF8 replaces invalid UTF-8 sequences and strips NUL bytes from all user-supplied\n// string fields. This is called early in the publish path so that all downstream consumers\n// (Firebase, WebPush, SMTP, cache) receive clean UTF-8 strings.\nfunc (m *Message) SanitizeUTF8() {\n\tm.Topic = util.SanitizeUTF8(m.Topic)\n\tm.Message = util.SanitizeUTF8(m.Message)\n\tm.Title = util.SanitizeUTF8(m.Title)\n\tm.Click = util.SanitizeUTF8(m.Click)\n\tm.Icon = util.SanitizeUTF8(m.Icon)\n\tm.ContentType = util.SanitizeUTF8(m.ContentType)\n\tfor i, tag := range m.Tags {\n\t\tm.Tags[i] = util.SanitizeUTF8(tag)\n\t}\n\tif m.Attachment != nil {\n\t\tm.Attachment.Name = util.SanitizeUTF8(m.Attachment.Name)\n\t\tm.Attachment.Type = util.SanitizeUTF8(m.Attachment.Type)\n\t\tm.Attachment.URL = util.SanitizeUTF8(m.Attachment.URL)\n\t}\n}\n\n// ForJSON returns a copy of the message suitable for JSON output.\n// It clears the SequenceID if it equals the ID to reduce redundancy.\nfunc (m *Message) ForJSON() *Message {\n\tif m.SequenceID == m.ID {\n\t\tclone := *m\n\t\tclone.SequenceID = \"\"\n\t\treturn &clone\n\t}\n\treturn m\n}\n\n// Attachment represents a file attachment on a message\ntype Attachment struct {\n\tName    string `json:\"name\"`\n\tType    string `json:\"type,omitempty\"`\n\tSize    int64  `json:\"size,omitempty\"`\n\tExpires int64  `json:\"expires,omitempty\"`\n\tURL     string `json:\"url\"`\n}\n\n// Action represents a user-defined action on a message\ntype Action struct {\n\tID      string            `json:\"id\"`\n\tAction  string            `json:\"action\"`            // \"view\", \"broadcast\", \"http\", or \"copy\"\n\tLabel   string            `json:\"label\"`             // action button label\n\tClear   bool              `json:\"clear\"`             // clear notification after successful execution\n\tURL     string            `json:\"url,omitempty\"`     // used in \"view\" and \"http\" actions\n\tMethod  string            `json:\"method,omitempty\"`  // used in \"http\" action, default is POST (!)\n\tHeaders map[string]string `json:\"headers,omitempty\"` // used in \"http\" action\n\tBody    string            `json:\"body,omitempty\"`    // used in \"http\" action\n\tIntent  string            `json:\"intent,omitempty\"`  // used in \"broadcast\" action\n\tExtras  map[string]string `json:\"extras,omitempty\"`  // used in \"broadcast\" action\n\tValue   string            `json:\"value,omitempty\"`   // used in \"copy\" action\n}\n\n// NewAction creates a new action with initialized maps\nfunc NewAction() *Action {\n\treturn &Action{\n\t\tHeaders: make(map[string]string),\n\t\tExtras:  make(map[string]string),\n\t}\n}\n\n// NewMessage creates a new message with the current timestamp\nfunc NewMessage(event, topic, msg string) *Message {\n\treturn &Message{\n\t\tID:      util.RandomString(MessageIDLength),\n\t\tTime:    time.Now().Unix(),\n\t\tEvent:   event,\n\t\tTopic:   topic,\n\t\tMessage: msg,\n\t}\n}\n\n// NewOpenMessage is a convenience method to create an open message\nfunc NewOpenMessage(topic string) *Message {\n\treturn NewMessage(OpenEvent, topic, \"\")\n}\n\n// NewKeepaliveMessage is a convenience method to create a keepalive message\nfunc NewKeepaliveMessage(topic string) *Message {\n\treturn NewMessage(KeepaliveEvent, topic, \"\")\n}\n\n// NewDefaultMessage is a convenience method to create a notification message\nfunc NewDefaultMessage(topic, msg string) *Message {\n\treturn NewMessage(MessageEvent, topic, msg)\n}\n\n// NewActionMessage creates a new action message (message_delete or message_clear)\nfunc NewActionMessage(event, topic, sequenceID string) *Message {\n\tm := NewMessage(event, topic, \"\")\n\tm.SequenceID = sequenceID\n\treturn m\n}\n\n// NewPollRequestMessage is a convenience method to create a poll request message\nfunc NewPollRequestMessage(topic, pollID string) *Message {\n\tm := NewMessage(PollRequestEvent, topic, \"New message\")\n\tm.PollID = pollID\n\treturn m\n}\n\n// ValidMessageID returns true if the given string is a valid message ID\nfunc ValidMessageID(s string) bool {\n\treturn util.ValidRandomString(s, MessageIDLength)\n}\n\n// SinceMarker represents a point in time or message ID from which to retrieve messages\ntype SinceMarker struct {\n\ttime time.Time\n\tid   string\n}\n\n// NewSinceTime creates a new SinceMarker from a Unix timestamp\nfunc NewSinceTime(timestamp int64) SinceMarker {\n\treturn SinceMarker{time.Unix(timestamp, 0), \"\"}\n}\n\n// NewSinceID creates a new SinceMarker from a message ID\nfunc NewSinceID(id string) SinceMarker {\n\treturn SinceMarker{time.Unix(0, 0), id}\n}\n\n// IsAll returns true if this is the \"all messages\" marker\nfunc (t SinceMarker) IsAll() bool {\n\treturn t == SinceAllMessages\n}\n\n// IsNone returns true if this is the \"no messages\" marker\nfunc (t SinceMarker) IsNone() bool {\n\treturn t == SinceNoMessages\n}\n\n// IsLatest returns true if this is the \"latest message\" marker\nfunc (t SinceMarker) IsLatest() bool {\n\treturn t == SinceLatestMessage\n}\n\n// IsID returns true if this marker references a specific message ID\nfunc (t SinceMarker) IsID() bool {\n\treturn t.id != \"\" && t.id != SinceLatestMessage.id\n}\n\n// Time returns the time component of the marker\nfunc (t SinceMarker) Time() time.Time {\n\treturn t.time\n}\n\n// ID returns the message ID component of the marker\nfunc (t SinceMarker) ID() string {\n\treturn t.id\n}\n\n// Common SinceMarker values for subscribing to messages\nvar (\n\tSinceAllMessages   = SinceMarker{time.Unix(0, 0), \"\"}\n\tSinceNoMessages    = SinceMarker{time.Unix(1, 0), \"\"}\n\tSinceLatestMessage = SinceMarker{time.Unix(0, 0), \"latest\"}\n)\n"
  },
  {
    "path": "payments/payments.go",
    "content": "//go:build !nopayments\n\npackage payments\n\nimport \"github.com/stripe/stripe-go/v74\"\n\n// Available is a constant used to indicate that Stripe support is available.\n// It can be disabled with the 'nopayments' build tag.\nconst Available = true\n\n// SubscriptionStatus is an alias for stripe.SubscriptionStatus\ntype SubscriptionStatus stripe.SubscriptionStatus\n\n// PriceRecurringInterval is an alias for stripe.PriceRecurringInterval\ntype PriceRecurringInterval stripe.PriceRecurringInterval\n\n// Setup sets the Stripe secret key and disables telemetry\nfunc Setup(stripeSecretKey string) {\n\tstripe.EnableTelemetry = false // Whoa!\n\tstripe.Key = stripeSecretKey\n}\n"
  },
  {
    "path": "payments/payments_dummy.go",
    "content": "//go:build nopayments\n\npackage payments\n\n// Available is a constant used to indicate that Stripe support is available.\n// It can be disabled with the 'nopayments' build tag.\nconst Available = false\n\n// SubscriptionStatus is a dummy type\ntype SubscriptionStatus string\n\n// PriceRecurringInterval is dummy type\ntype PriceRecurringInterval string\n\n// Setup is a dummy type\nfunc Setup(stripeSecretKey string) {\n\t// Nothing to see here\n}\n"
  },
  {
    "path": "requirements.txt",
    "content": "# The documentation uses 'mkdocs', which is written in Python\nmkdocs-material\nmkdocs-minify-plugin\n"
  },
  {
    "path": "scripts/emoji-convert.sh",
    "content": "#!/bin/bash\n\n# This script reduces the size and converts the emoji.json file from https://github.com/github/gemoji/blob/master/db/emoji.json\n# to be used in the Android app (app/src/main/resources/emoji.json) and the Web UI (server/static/js/emoji.js).\n\nSCRIPTDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nROOTDIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\nif [ -z \"$1\" ]; then\n    echo \"Syntax: $0 FILE.(js|json|md)\"\n    echo \"Example:\"\n    echo \"  $0 emoji-converted.json\"\n    echo \"  $0 $ROOTDIR/web/src/app/emojis.js\"\n    echo \"  $0 $ROOTDIR/docs/emojis.md\"\n    exit 1\nfi\n\nif [[ \"$1\" == *.js ]]; then\n  echo -n \"// This file is generated by scripts/emoji-convert.sh to reduce the size\n// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json\nexport const rawEmojis = \" > \"$1\"\n    cat \"$SCRIPTDIR/emoji.json\" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> \"$1\"\nelif [[ \"$1\" == *.md ]]; then\n  echo \"# Emoji reference\n\n<!-- This file was generated by scripts/emoji-convert.sh -->\n\nYou can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically\nconverted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the\n[tagging and emojis page](publish.md#tags-emojis).\n\n<table class=\\\"remove-md-box emoji-table\\\"><tr>\n\" > \"$1\"\n\n  count=\"$(cat \"$SCRIPTDIR/emoji.json\" | jq -r '.[] | .emoji' | wc -l)\"\n  percolumn=$(($count / 3)) # This will misbehave if the count is not divisible by 3\n  for col in 0 1 2; do\n    from=\"$(($col * $percolumn + 1))\"\n    to=\"$(($col * $percolumn + 1 + $percolumn))\"\n    echo \"<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>\" >> \"$1\"\n    cat \"$SCRIPTDIR/emoji.json\" \\\n      | jq -r '.[] | \"<tr><td class=c><code>\" + .aliases[0] + \"</code></td><td class=e>\" + .emoji + \"</td></tr>\"' \\\n      | sed -n \"${from},${to}p\" >> \"$1\"\n    echo \"</tbody></table></td>\" >> \"$1\"\n  done\n  echo \"</tr></table>\" >> \"$1\"\nelse\n  cat \"$SCRIPTDIR/emoji.json\" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' > \"$1\"\nfi\n"
  },
  {
    "path": "scripts/emoji.json",
    "content": "[\n  {\n    \"emoji\": \"😀\"\n  , \"description\": \"grinning face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"grinning\"\n    ]\n  , \"tags\": [\n      \"smile\"\n    , \"happy\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😃\"\n  , \"description\": \"grinning face with big eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smiley\"\n    ]\n  , \"tags\": [\n      \"happy\"\n    , \"joy\"\n    , \"haha\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😄\"\n  , \"description\": \"grinning face with smiling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smile\"\n    ]\n  , \"tags\": [\n      \"happy\"\n    , \"joy\"\n    , \"laugh\"\n    , \"pleased\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😁\"\n  , \"description\": \"beaming face with smiling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"grin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😆\"\n  , \"description\": \"grinning squinting face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"laughing\"\n    , \"satisfied\"\n    ]\n  , \"tags\": [\n      \"happy\"\n    , \"haha\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😅\"\n  , \"description\": \"grinning face with sweat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sweat_smile\"\n    ]\n  , \"tags\": [\n      \"hot\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤣\"\n  , \"description\": \"rolling on the floor laughing\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"rofl\"\n    ]\n  , \"tags\": [\n      \"lol\"\n    , \"laughing\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"😂\"\n  , \"description\": \"face with tears of joy\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"joy\"\n    ]\n  , \"tags\": [\n      \"tears\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙂\"\n  , \"description\": \"slightly smiling face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"slightly_smiling_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🙃\"\n  , \"description\": \"upside-down face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"upside_down_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"😉\"\n  , \"description\": \"winking face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"wink\"\n    ]\n  , \"tags\": [\n      \"flirt\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😊\"\n  , \"description\": \"smiling face with smiling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"blush\"\n    ]\n  , \"tags\": [\n      \"proud\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😇\"\n  , \"description\": \"smiling face with halo\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"innocent\"\n    ]\n  , \"tags\": [\n      \"angel\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥰\"\n  , \"description\": \"smiling face with hearts\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smiling_face_with_three_hearts\"\n    ]\n  , \"tags\": [\n      \"love\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😍\"\n  , \"description\": \"smiling face with heart-eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heart_eyes\"\n    ]\n  , \"tags\": [\n      \"love\"\n    , \"crush\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤩\"\n  , \"description\": \"star-struck\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"star_struck\"\n    ]\n  , \"tags\": [\n      \"eyes\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😘\"\n  , \"description\": \"face blowing a kiss\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kissing_heart\"\n    ]\n  , \"tags\": [\n      \"flirt\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😗\"\n  , \"description\": \"kissing face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kissing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☺️\"\n  , \"description\": \"smiling face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"relaxed\"\n    ]\n  , \"tags\": [\n      \"blush\"\n    , \"pleased\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😚\"\n  , \"description\": \"kissing face with closed eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kissing_closed_eyes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😙\"\n  , \"description\": \"kissing face with smiling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kissing_smiling_eyes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥲\"\n  , \"description\": \"smiling face with tear\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smiling_face_with_tear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"😋\"\n  , \"description\": \"face savoring food\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"yum\"\n    ]\n  , \"tags\": [\n      \"tongue\"\n    , \"lick\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😛\"\n  , \"description\": \"face with tongue\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"stuck_out_tongue\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😜\"\n  , \"description\": \"winking face with tongue\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"stuck_out_tongue_winking_eye\"\n    ]\n  , \"tags\": [\n      \"prank\"\n    , \"silly\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤪\"\n  , \"description\": \"zany face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"zany_face\"\n    ]\n  , \"tags\": [\n      \"goofy\"\n    , \"wacky\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😝\"\n  , \"description\": \"squinting face with tongue\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"stuck_out_tongue_closed_eyes\"\n    ]\n  , \"tags\": [\n      \"prank\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤑\"\n  , \"description\": \"money-mouth face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"money_mouth_face\"\n    ]\n  , \"tags\": [\n      \"rich\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤗\"\n  , \"description\": \"hugging face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hugs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤭\"\n  , \"description\": \"face with hand over mouth\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hand_over_mouth\"\n    ]\n  , \"tags\": [\n      \"quiet\"\n    , \"whoops\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🤫\"\n  , \"description\": \"shushing face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"shushing_face\"\n    ]\n  , \"tags\": [\n      \"silence\"\n    , \"quiet\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🤔\"\n  , \"description\": \"thinking face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"thinking\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤐\"\n  , \"description\": \"zipper-mouth face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"zipper_mouth_face\"\n    ]\n  , \"tags\": [\n      \"silence\"\n    , \"hush\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤨\"\n  , \"description\": \"face with raised eyebrow\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"raised_eyebrow\"\n    ]\n  , \"tags\": [\n      \"suspicious\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😐\"\n  , \"description\": \"neutral face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"neutral_face\"\n    ]\n  , \"tags\": [\n      \"meh\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😑\"\n  , \"description\": \"expressionless face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"expressionless\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😶\"\n  , \"description\": \"face without mouth\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"no_mouth\"\n    ]\n  , \"tags\": [\n      \"mute\"\n    , \"silence\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😶‍🌫️\"\n  , \"description\": \"face in clouds\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"face_in_clouds\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"😏\"\n  , \"description\": \"smirking face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smirk\"\n    ]\n  , \"tags\": [\n      \"smug\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😒\"\n  , \"description\": \"unamused face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"unamused\"\n    ]\n  , \"tags\": [\n      \"meh\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙄\"\n  , \"description\": \"face with rolling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"roll_eyes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"😬\"\n  , \"description\": \"grimacing face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"grimacing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😮‍💨\"\n  , \"description\": \"face exhaling\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"face_exhaling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🤥\"\n  , \"description\": \"lying face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"lying_face\"\n    ]\n  , \"tags\": [\n      \"liar\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"😌\"\n  , \"description\": \"relieved face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"relieved\"\n    ]\n  , \"tags\": [\n      \"whew\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😔\"\n  , \"description\": \"pensive face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"pensive\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😪\"\n  , \"description\": \"sleepy face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sleepy\"\n    ]\n  , \"tags\": [\n      \"tired\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤤\"\n  , \"description\": \"drooling face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"drooling_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"😴\"\n  , \"description\": \"sleeping face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sleeping\"\n    ]\n  , \"tags\": [\n      \"zzz\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😷\"\n  , \"description\": \"face with medical mask\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"mask\"\n    ]\n  , \"tags\": [\n      \"sick\"\n    , \"ill\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤒\"\n  , \"description\": \"face with thermometer\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"face_with_thermometer\"\n    ]\n  , \"tags\": [\n      \"sick\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤕\"\n  , \"description\": \"face with head-bandage\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"face_with_head_bandage\"\n    ]\n  , \"tags\": [\n      \"hurt\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🤢\"\n  , \"description\": \"nauseated face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"nauseated_face\"\n    ]\n  , \"tags\": [\n      \"sick\"\n    , \"barf\"\n    , \"disgusted\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🤮\"\n  , \"description\": \"face vomiting\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"vomiting_face\"\n    ]\n  , \"tags\": [\n      \"barf\"\n    , \"sick\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🤧\"\n  , \"description\": \"sneezing face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sneezing_face\"\n    ]\n  , \"tags\": [\n      \"achoo\"\n    , \"sick\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥵\"\n  , \"description\": \"hot face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hot_face\"\n    ]\n  , \"tags\": [\n      \"heat\"\n    , \"sweating\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥶\"\n  , \"description\": \"cold face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cold_face\"\n    ]\n  , \"tags\": [\n      \"freezing\"\n    , \"ice\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥴\"\n  , \"description\": \"woozy face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"woozy_face\"\n    ]\n  , \"tags\": [\n      \"groggy\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😵\"\n  , \"description\": \"knocked-out face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"dizzy_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😵‍💫\"\n  , \"description\": \"face with spiral eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"face_with_spiral_eyes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🤯\"\n  , \"description\": \"exploding head\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"exploding_head\"\n    ]\n  , \"tags\": [\n      \"mind\"\n    , \"blown\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🤠\"\n  , \"description\": \"cowboy hat face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cowboy_hat_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥳\"\n  , \"description\": \"partying face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"partying_face\"\n    ]\n  , \"tags\": [\n      \"celebration\"\n    , \"birthday\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥸\"\n  , \"description\": \"disguised face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"disguised_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"😎\"\n  , \"description\": \"smiling face with sunglasses\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sunglasses\"\n    ]\n  , \"tags\": [\n      \"cool\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤓\"\n  , \"description\": \"nerd face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"nerd_face\"\n    ]\n  , \"tags\": [\n      \"geek\"\n    , \"glasses\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🧐\"\n  , \"description\": \"face with monocle\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"monocle_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😕\"\n  , \"description\": \"confused face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"confused\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😟\"\n  , \"description\": \"worried face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"worried\"\n    ]\n  , \"tags\": [\n      \"nervous\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙁\"\n  , \"description\": \"slightly frowning face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"slightly_frowning_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☹️\"\n  , \"description\": \"frowning face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"frowning_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"😮\"\n  , \"description\": \"face with open mouth\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"open_mouth\"\n    ]\n  , \"tags\": [\n      \"surprise\"\n    , \"impressed\"\n    , \"wow\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😯\"\n  , \"description\": \"hushed face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hushed\"\n    ]\n  , \"tags\": [\n      \"silence\"\n    , \"speechless\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😲\"\n  , \"description\": \"astonished face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"astonished\"\n    ]\n  , \"tags\": [\n      \"amazed\"\n    , \"gasp\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😳\"\n  , \"description\": \"flushed face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"flushed\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥺\"\n  , \"description\": \"pleading face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"pleading_face\"\n    ]\n  , \"tags\": [\n      \"puppy\"\n    , \"eyes\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😦\"\n  , \"description\": \"frowning face with open mouth\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"frowning\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😧\"\n  , \"description\": \"anguished face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"anguished\"\n    ]\n  , \"tags\": [\n      \"stunned\"\n    ]\n  , \"unicode_version\": \"6.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😨\"\n  , \"description\": \"fearful face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"fearful\"\n    ]\n  , \"tags\": [\n      \"scared\"\n    , \"shocked\"\n    , \"oops\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😰\"\n  , \"description\": \"anxious face with sweat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cold_sweat\"\n    ]\n  , \"tags\": [\n      \"nervous\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😥\"\n  , \"description\": \"sad but relieved face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"disappointed_relieved\"\n    ]\n  , \"tags\": [\n      \"phew\"\n    , \"sweat\"\n    , \"nervous\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😢\"\n  , \"description\": \"crying face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cry\"\n    ]\n  , \"tags\": [\n      \"sad\"\n    , \"tear\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😭\"\n  , \"description\": \"loudly crying face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sob\"\n    ]\n  , \"tags\": [\n      \"sad\"\n    , \"cry\"\n    , \"bawling\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😱\"\n  , \"description\": \"face screaming in fear\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"scream\"\n    ]\n  , \"tags\": [\n      \"horror\"\n    , \"shocked\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😖\"\n  , \"description\": \"confounded face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"confounded\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😣\"\n  , \"description\": \"persevering face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"persevere\"\n    ]\n  , \"tags\": [\n      \"struggling\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😞\"\n  , \"description\": \"disappointed face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"disappointed\"\n    ]\n  , \"tags\": [\n      \"sad\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😓\"\n  , \"description\": \"downcast face with sweat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sweat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😩\"\n  , \"description\": \"weary face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"weary\"\n    ]\n  , \"tags\": [\n      \"tired\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😫\"\n  , \"description\": \"tired face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"tired_face\"\n    ]\n  , \"tags\": [\n      \"upset\"\n    , \"whine\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥱\"\n  , \"description\": \"yawning face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"yawning_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"😤\"\n  , \"description\": \"face with steam from nose\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"triumph\"\n    ]\n  , \"tags\": [\n      \"smug\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😡\"\n  , \"description\": \"pouting face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"rage\"\n    , \"pout\"\n    ]\n  , \"tags\": [\n      \"angry\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😠\"\n  , \"description\": \"angry face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"angry\"\n    ]\n  , \"tags\": [\n      \"mad\"\n    , \"annoyed\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤬\"\n  , \"description\": \"face with symbols on mouth\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cursing_face\"\n    ]\n  , \"tags\": [\n      \"foul\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"😈\"\n  , \"description\": \"smiling face with horns\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smiling_imp\"\n    ]\n  , \"tags\": [\n      \"devil\"\n    , \"evil\"\n    , \"horns\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👿\"\n  , \"description\": \"angry face with horns\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"imp\"\n    ]\n  , \"tags\": [\n      \"angry\"\n    , \"devil\"\n    , \"evil\"\n    , \"horns\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💀\"\n  , \"description\": \"skull\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"skull\"\n    ]\n  , \"tags\": [\n      \"dead\"\n    , \"danger\"\n    , \"poison\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☠️\"\n  , \"description\": \"skull and crossbones\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"skull_and_crossbones\"\n    ]\n  , \"tags\": [\n      \"danger\"\n    , \"pirate\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💩\"\n  , \"description\": \"pile of poo\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hankey\"\n    , \"poop\"\n    , \"shit\"\n    ]\n  , \"tags\": [\n      \"crap\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤡\"\n  , \"description\": \"clown face\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"clown_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"👹\"\n  , \"description\": \"ogre\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"japanese_ogre\"\n    ]\n  , \"tags\": [\n      \"monster\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👺\"\n  , \"description\": \"goblin\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"japanese_goblin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👻\"\n  , \"description\": \"ghost\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"ghost\"\n    ]\n  , \"tags\": [\n      \"halloween\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👽\"\n  , \"description\": \"alien\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"alien\"\n    ]\n  , \"tags\": [\n      \"ufo\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👾\"\n  , \"description\": \"alien monster\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"space_invader\"\n    ]\n  , \"tags\": [\n      \"game\"\n    , \"retro\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤖\"\n  , \"description\": \"robot\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"robot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"😺\"\n  , \"description\": \"grinning cat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smiley_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😸\"\n  , \"description\": \"grinning cat with smiling eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smile_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😹\"\n  , \"description\": \"cat with tears of joy\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"joy_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😻\"\n  , \"description\": \"smiling cat with heart-eyes\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heart_eyes_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😼\"\n  , \"description\": \"cat with wry smile\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"smirk_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😽\"\n  , \"description\": \"kissing cat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kissing_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙀\"\n  , \"description\": \"weary cat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"scream_cat\"\n    ]\n  , \"tags\": [\n      \"horror\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😿\"\n  , \"description\": \"crying cat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"crying_cat_face\"\n    ]\n  , \"tags\": [\n      \"sad\"\n    , \"tear\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"😾\"\n  , \"description\": \"pouting cat\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"pouting_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙈\"\n  , \"description\": \"see-no-evil monkey\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"see_no_evil\"\n    ]\n  , \"tags\": [\n      \"monkey\"\n    , \"blind\"\n    , \"ignore\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙉\"\n  , \"description\": \"hear-no-evil monkey\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hear_no_evil\"\n    ]\n  , \"tags\": [\n      \"monkey\"\n    , \"deaf\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🙊\"\n  , \"description\": \"speak-no-evil monkey\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"speak_no_evil\"\n    ]\n  , \"tags\": [\n      \"monkey\"\n    , \"mute\"\n    , \"hush\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💋\"\n  , \"description\": \"kiss mark\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"kiss\"\n    ]\n  , \"tags\": [\n      \"lipstick\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💌\"\n  , \"description\": \"love letter\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"love_letter\"\n    ]\n  , \"tags\": [\n      \"email\"\n    , \"envelope\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💘\"\n  , \"description\": \"heart with arrow\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"cupid\"\n    ]\n  , \"tags\": [\n      \"love\"\n    , \"heart\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💝\"\n  , \"description\": \"heart with ribbon\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"gift_heart\"\n    ]\n  , \"tags\": [\n      \"chocolates\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💖\"\n  , \"description\": \"sparkling heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sparkling_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💗\"\n  , \"description\": \"growing heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heartpulse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💓\"\n  , \"description\": \"beating heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heartbeat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💞\"\n  , \"description\": \"revolving hearts\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"revolving_hearts\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💕\"\n  , \"description\": \"two hearts\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"two_hearts\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💟\"\n  , \"description\": \"heart decoration\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heart_decoration\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❣️\"\n  , \"description\": \"heart exclamation\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heavy_heart_exclamation\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💔\"\n  , \"description\": \"broken heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"broken_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❤️‍🔥\"\n  , \"description\": \"heart on fire\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heart_on_fire\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"❤️‍🩹\"\n  , \"description\": \"mending heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"mending_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"❤️\"\n  , \"description\": \"red heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"heart\"\n    ]\n  , \"tags\": [\n      \"love\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧡\"\n  , \"description\": \"orange heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"orange_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"💛\"\n  , \"description\": \"yellow heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"yellow_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💚\"\n  , \"description\": \"green heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"green_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💙\"\n  , \"description\": \"blue heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"blue_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💜\"\n  , \"description\": \"purple heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"purple_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤎\"\n  , \"description\": \"brown heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"brown_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🖤\"\n  , \"description\": \"black heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"black_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🤍\"\n  , \"description\": \"white heart\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"white_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"💯\"\n  , \"description\": \"hundred points\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"100\"\n    ]\n  , \"tags\": [\n      \"score\"\n    , \"perfect\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💢\"\n  , \"description\": \"anger symbol\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"anger\"\n    ]\n  , \"tags\": [\n      \"angry\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💥\"\n  , \"description\": \"collision\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"boom\"\n    , \"collision\"\n    ]\n  , \"tags\": [\n      \"explode\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💫\"\n  , \"description\": \"dizzy\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"dizzy\"\n    ]\n  , \"tags\": [\n      \"star\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💦\"\n  , \"description\": \"sweat droplets\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"sweat_drops\"\n    ]\n  , \"tags\": [\n      \"water\"\n    , \"workout\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💨\"\n  , \"description\": \"dashing away\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"dash\"\n    ]\n  , \"tags\": [\n      \"wind\"\n    , \"blow\"\n    , \"fast\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕳️\"\n  , \"description\": \"hole\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"hole\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💣\"\n  , \"description\": \"bomb\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"bomb\"\n    ]\n  , \"tags\": [\n      \"boom\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💬\"\n  , \"description\": \"speech balloon\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"speech_balloon\"\n    ]\n  , \"tags\": [\n      \"comment\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👁️‍🗨️\"\n  , \"description\": \"eye in speech bubble\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"eye_speech_bubble\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🗨️\"\n  , \"description\": \"left speech bubble\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"left_speech_bubble\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🗯️\"\n  , \"description\": \"right anger bubble\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"right_anger_bubble\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💭\"\n  , \"description\": \"thought balloon\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"thought_balloon\"\n    ]\n  , \"tags\": [\n      \"thinking\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💤\"\n  , \"description\": \"zzz\"\n  , \"category\": \"Smileys & Emotion\"\n  , \"aliases\": [\n      \"zzz\"\n    ]\n  , \"tags\": [\n      \"sleeping\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👋\"\n  , \"description\": \"waving hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"wave\"\n    ]\n  , \"tags\": [\n      \"goodbye\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤚\"\n  , \"description\": \"raised back of hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raised_back_of_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🖐️\"\n  , \"description\": \"hand with fingers splayed\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raised_hand_with_fingers_splayed\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"✋\"\n  , \"description\": \"raised hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"hand\"\n    , \"raised_hand\"\n    ]\n  , \"tags\": [\n      \"highfive\"\n    , \"stop\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🖖\"\n  , \"description\": \"vulcan salute\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"vulcan_salute\"\n    ]\n  , \"tags\": [\n      \"prosper\"\n    , \"spock\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"8.3\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👌\"\n  , \"description\": \"OK hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ok_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤌\"\n  , \"description\": \"pinched fingers\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pinched_fingers\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤏\"\n  , \"description\": \"pinching hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pinching_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"✌️\"\n  , \"description\": \"victory hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"v\"\n    ]\n  , \"tags\": [\n      \"victory\"\n    , \"peace\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤞\"\n  , \"description\": \"crossed fingers\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"crossed_fingers\"\n    ]\n  , \"tags\": [\n      \"luck\"\n    , \"hopeful\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤟\"\n  , \"description\": \"love-you gesture\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"love_you_gesture\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤘\"\n  , \"description\": \"sign of the horns\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"metal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤙\"\n  , \"description\": \"call me hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"call_me_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👈\"\n  , \"description\": \"backhand index pointing left\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"point_left\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👉\"\n  , \"description\": \"backhand index pointing right\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"point_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👆\"\n  , \"description\": \"backhand index pointing up\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"point_up_2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🖕\"\n  , \"description\": \"middle finger\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"middle_finger\"\n    , \"fu\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👇\"\n  , \"description\": \"backhand index pointing down\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"point_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"☝️\"\n  , \"description\": \"index pointing up\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"point_up\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👍\"\n  , \"description\": \"thumbs up\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"+1\"\n    , \"thumbsup\"\n    ]\n  , \"tags\": [\n      \"approve\"\n    , \"ok\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👎\"\n  , \"description\": \"thumbs down\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"-1\"\n    , \"thumbsdown\"\n    ]\n  , \"tags\": [\n      \"disapprove\"\n    , \"bury\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"✊\"\n  , \"description\": \"raised fist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fist_raised\"\n    , \"fist\"\n    ]\n  , \"tags\": [\n      \"power\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👊\"\n  , \"description\": \"oncoming fist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fist_oncoming\"\n    , \"facepunch\"\n    , \"punch\"\n    ]\n  , \"tags\": [\n      \"attack\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤛\"\n  , \"description\": \"left-facing fist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fist_left\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤜\"\n  , \"description\": \"right-facing fist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fist_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👏\"\n  , \"description\": \"clapping hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"clap\"\n    ]\n  , \"tags\": [\n      \"praise\"\n    , \"applause\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙌\"\n  , \"description\": \"raising hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raised_hands\"\n    ]\n  , \"tags\": [\n      \"hooray\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👐\"\n  , \"description\": \"open hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"open_hands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤲\"\n  , \"description\": \"palms up together\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"palms_up_together\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤝\"\n  , \"description\": \"handshake\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"handshake\"\n    ]\n  , \"tags\": [\n      \"deal\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🙏\"\n  , \"description\": \"folded hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pray\"\n    ]\n  , \"tags\": [\n      \"please\"\n    , \"hope\"\n    , \"wish\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"✍️\"\n  , \"description\": \"writing hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"writing_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💅\"\n  , \"description\": \"nail polish\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"nail_care\"\n    ]\n  , \"tags\": [\n      \"beauty\"\n    , \"manicure\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤳\"\n  , \"description\": \"selfie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"selfie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💪\"\n  , \"description\": \"flexed biceps\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"muscle\"\n    ]\n  , \"tags\": [\n      \"flex\"\n    , \"bicep\"\n    , \"strong\"\n    , \"workout\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦾\"\n  , \"description\": \"mechanical arm\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mechanical_arm\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦿\"\n  , \"description\": \"mechanical leg\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mechanical_leg\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦵\"\n  , \"description\": \"leg\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"leg\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦶\"\n  , \"description\": \"foot\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"foot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👂\"\n  , \"description\": \"ear\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ear\"\n    ]\n  , \"tags\": [\n      \"hear\"\n    , \"sound\"\n    , \"listen\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦻\"\n  , \"description\": \"ear with hearing aid\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ear_with_hearing_aid\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👃\"\n  , \"description\": \"nose\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"nose\"\n    ]\n  , \"tags\": [\n      \"smell\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧠\"\n  , \"description\": \"brain\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"brain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🫀\"\n  , \"description\": \"anatomical heart\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"anatomical_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🫁\"\n  , \"description\": \"lungs\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"lungs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦷\"\n  , \"description\": \"tooth\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"tooth\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦴\"\n  , \"description\": \"bone\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"👀\"\n  , \"description\": \"eyes\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"eyes\"\n    ]\n  , \"tags\": [\n      \"look\"\n    , \"see\"\n    , \"watch\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👁️\"\n  , \"description\": \"eye\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"eye\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"👅\"\n  , \"description\": \"tongue\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"tongue\"\n    ]\n  , \"tags\": [\n      \"taste\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👄\"\n  , \"description\": \"mouth\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"lips\"\n    ]\n  , \"tags\": [\n      \"kiss\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👶\"\n  , \"description\": \"baby\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"baby\"\n    ]\n  , \"tags\": [\n      \"child\"\n    , \"newborn\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧒\"\n  , \"description\": \"child\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"child\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👦\"\n  , \"description\": \"boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"boy\"\n    ]\n  , \"tags\": [\n      \"child\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👧\"\n  , \"description\": \"girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"girl\"\n    ]\n  , \"tags\": [\n      \"child\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑\"\n  , \"description\": \"person\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"adult\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👱\"\n  , \"description\": \"person: blond hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"blond_haired_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨\"\n  , \"description\": \"man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man\"\n    ]\n  , \"tags\": [\n      \"mustache\"\n    , \"father\"\n    , \"dad\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧔\"\n  , \"description\": \"person: beard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bearded_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧔‍♂️\"\n  , \"description\": \"man: beard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_beard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧔‍♀️\"\n  , \"description\": \"woman: beard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_beard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.1\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦰\"\n  , \"description\": \"man: red hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"red_haired_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦱\"\n  , \"description\": \"man: curly hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"curly_haired_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦳\"\n  , \"description\": \"man: white hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"white_haired_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦲\"\n  , \"description\": \"man: bald\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bald_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩\"\n  , \"description\": \"woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman\"\n    ]\n  , \"tags\": [\n      \"girls\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦰\"\n  , \"description\": \"woman: red hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"red_haired_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦰\"\n  , \"description\": \"person: red hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_red_hair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦱\"\n  , \"description\": \"woman: curly hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"curly_haired_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦱\"\n  , \"description\": \"person: curly hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_curly_hair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦳\"\n  , \"description\": \"woman: white hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"white_haired_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦳\"\n  , \"description\": \"person: white hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_white_hair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦲\"\n  , \"description\": \"woman: bald\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bald_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦲\"\n  , \"description\": \"person: bald\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_bald\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👱‍♀️\"\n  , \"description\": \"woman: blond hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"blond_haired_woman\"\n    , \"blonde_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👱‍♂️\"\n  , \"description\": \"man: blond hair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"blond_haired_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧓\"\n  , \"description\": \"older person\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"older_adult\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👴\"\n  , \"description\": \"old man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"older_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👵\"\n  , \"description\": \"old woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"older_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙍\"\n  , \"description\": \"person frowning\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"frowning_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙍‍♂️\"\n  , \"description\": \"man frowning\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"frowning_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙍‍♀️\"\n  , \"description\": \"woman frowning\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"frowning_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙎\"\n  , \"description\": \"person pouting\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pouting_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙎‍♂️\"\n  , \"description\": \"man pouting\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pouting_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙎‍♀️\"\n  , \"description\": \"woman pouting\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pouting_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙅\"\n  , \"description\": \"person gesturing NO\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"no_good\"\n    ]\n  , \"tags\": [\n      \"stop\"\n    , \"halt\"\n    , \"denied\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙅‍♂️\"\n  , \"description\": \"man gesturing NO\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"no_good_man\"\n    , \"ng_man\"\n    ]\n  , \"tags\": [\n      \"stop\"\n    , \"halt\"\n    , \"denied\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙅‍♀️\"\n  , \"description\": \"woman gesturing NO\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"no_good_woman\"\n    , \"ng_woman\"\n    ]\n  , \"tags\": [\n      \"stop\"\n    , \"halt\"\n    , \"denied\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙆\"\n  , \"description\": \"person gesturing OK\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ok_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙆‍♂️\"\n  , \"description\": \"man gesturing OK\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ok_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙆‍♀️\"\n  , \"description\": \"woman gesturing OK\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ok_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💁\"\n  , \"description\": \"person tipping hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"tipping_hand_person\"\n    , \"information_desk_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💁‍♂️\"\n  , \"description\": \"man tipping hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"tipping_hand_man\"\n    , \"sassy_man\"\n    ]\n  , \"tags\": [\n      \"information\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💁‍♀️\"\n  , \"description\": \"woman tipping hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"tipping_hand_woman\"\n    , \"sassy_woman\"\n    ]\n  , \"tags\": [\n      \"information\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙋\"\n  , \"description\": \"person raising hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raising_hand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙋‍♂️\"\n  , \"description\": \"man raising hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raising_hand_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙋‍♀️\"\n  , \"description\": \"woman raising hand\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"raising_hand_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧏\"\n  , \"description\": \"deaf person\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"deaf_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧏‍♂️\"\n  , \"description\": \"deaf man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"deaf_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧏‍♀️\"\n  , \"description\": \"deaf woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"deaf_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙇\"\n  , \"description\": \"person bowing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bow\"\n    ]\n  , \"tags\": [\n      \"respect\"\n    , \"thanks\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙇‍♂️\"\n  , \"description\": \"man bowing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bowing_man\"\n    ]\n  , \"tags\": [\n      \"respect\"\n    , \"thanks\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🙇‍♀️\"\n  , \"description\": \"woman bowing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bowing_woman\"\n    ]\n  , \"tags\": [\n      \"respect\"\n    , \"thanks\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤦\"\n  , \"description\": \"person facepalming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"facepalm\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤦‍♂️\"\n  , \"description\": \"man facepalming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_facepalming\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤦‍♀️\"\n  , \"description\": \"woman facepalming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_facepalming\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤷\"\n  , \"description\": \"person shrugging\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"shrug\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤷‍♂️\"\n  , \"description\": \"man shrugging\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_shrugging\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤷‍♀️\"\n  , \"description\": \"woman shrugging\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_shrugging\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍⚕️\"\n  , \"description\": \"health worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"health_worker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍⚕️\"\n  , \"description\": \"man health worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_health_worker\"\n    ]\n  , \"tags\": [\n      \"doctor\"\n    , \"nurse\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍⚕️\"\n  , \"description\": \"woman health worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_health_worker\"\n    ]\n  , \"tags\": [\n      \"doctor\"\n    , \"nurse\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🎓\"\n  , \"description\": \"student\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"student\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🎓\"\n  , \"description\": \"man student\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_student\"\n    ]\n  , \"tags\": [\n      \"graduation\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🎓\"\n  , \"description\": \"woman student\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_student\"\n    ]\n  , \"tags\": [\n      \"graduation\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🏫\"\n  , \"description\": \"teacher\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"teacher\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🏫\"\n  , \"description\": \"man teacher\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_teacher\"\n    ]\n  , \"tags\": [\n      \"school\"\n    , \"professor\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🏫\"\n  , \"description\": \"woman teacher\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_teacher\"\n    ]\n  , \"tags\": [\n      \"school\"\n    , \"professor\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍⚖️\"\n  , \"description\": \"judge\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"judge\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍⚖️\"\n  , \"description\": \"man judge\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_judge\"\n    ]\n  , \"tags\": [\n      \"justice\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍⚖️\"\n  , \"description\": \"woman judge\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_judge\"\n    ]\n  , \"tags\": [\n      \"justice\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🌾\"\n  , \"description\": \"farmer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"farmer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🌾\"\n  , \"description\": \"man farmer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_farmer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🌾\"\n  , \"description\": \"woman farmer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_farmer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🍳\"\n  , \"description\": \"cook\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"cook\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🍳\"\n  , \"description\": \"man cook\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_cook\"\n    ]\n  , \"tags\": [\n      \"chef\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🍳\"\n  , \"description\": \"woman cook\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_cook\"\n    ]\n  , \"tags\": [\n      \"chef\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🔧\"\n  , \"description\": \"mechanic\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mechanic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🔧\"\n  , \"description\": \"man mechanic\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_mechanic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🔧\"\n  , \"description\": \"woman mechanic\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_mechanic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🏭\"\n  , \"description\": \"factory worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"factory_worker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🏭\"\n  , \"description\": \"man factory worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_factory_worker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🏭\"\n  , \"description\": \"woman factory worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_factory_worker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍💼\"\n  , \"description\": \"office worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"office_worker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍💼\"\n  , \"description\": \"man office worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_office_worker\"\n    ]\n  , \"tags\": [\n      \"business\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍💼\"\n  , \"description\": \"woman office worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_office_worker\"\n    ]\n  , \"tags\": [\n      \"business\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🔬\"\n  , \"description\": \"scientist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"scientist\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🔬\"\n  , \"description\": \"man scientist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_scientist\"\n    ]\n  , \"tags\": [\n      \"research\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🔬\"\n  , \"description\": \"woman scientist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_scientist\"\n    ]\n  , \"tags\": [\n      \"research\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍💻\"\n  , \"description\": \"technologist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"technologist\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍💻\"\n  , \"description\": \"man technologist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_technologist\"\n    ]\n  , \"tags\": [\n      \"coder\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍💻\"\n  , \"description\": \"woman technologist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_technologist\"\n    ]\n  , \"tags\": [\n      \"coder\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🎤\"\n  , \"description\": \"singer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"singer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🎤\"\n  , \"description\": \"man singer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_singer\"\n    ]\n  , \"tags\": [\n      \"rockstar\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🎤\"\n  , \"description\": \"woman singer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_singer\"\n    ]\n  , \"tags\": [\n      \"rockstar\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🎨\"\n  , \"description\": \"artist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"artist\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🎨\"\n  , \"description\": \"man artist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_artist\"\n    ]\n  , \"tags\": [\n      \"painter\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🎨\"\n  , \"description\": \"woman artist\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_artist\"\n    ]\n  , \"tags\": [\n      \"painter\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍✈️\"\n  , \"description\": \"pilot\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pilot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍✈️\"\n  , \"description\": \"man pilot\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_pilot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍✈️\"\n  , \"description\": \"woman pilot\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_pilot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🚀\"\n  , \"description\": \"astronaut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"astronaut\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🚀\"\n  , \"description\": \"man astronaut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_astronaut\"\n    ]\n  , \"tags\": [\n      \"space\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🚀\"\n  , \"description\": \"woman astronaut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_astronaut\"\n    ]\n  , \"tags\": [\n      \"space\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🚒\"\n  , \"description\": \"firefighter\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"firefighter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🚒\"\n  , \"description\": \"man firefighter\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_firefighter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🚒\"\n  , \"description\": \"woman firefighter\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_firefighter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👮\"\n  , \"description\": \"police officer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"police_officer\"\n    , \"cop\"\n    ]\n  , \"tags\": [\n      \"law\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👮‍♂️\"\n  , \"description\": \"man police officer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"policeman\"\n    ]\n  , \"tags\": [\n      \"law\"\n    , \"cop\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👮‍♀️\"\n  , \"description\": \"woman police officer\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"policewoman\"\n    ]\n  , \"tags\": [\n      \"law\"\n    , \"cop\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🕵️\"\n  , \"description\": \"detective\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"detective\"\n    ]\n  , \"tags\": [\n      \"sleuth\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🕵️‍♂️\"\n  , \"description\": \"man detective\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"male_detective\"\n    ]\n  , \"tags\": [\n      \"sleuth\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🕵️‍♀️\"\n  , \"description\": \"woman detective\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"female_detective\"\n    ]\n  , \"tags\": [\n      \"sleuth\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💂\"\n  , \"description\": \"guard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"guard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💂‍♂️\"\n  , \"description\": \"man guard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"guardsman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💂‍♀️\"\n  , \"description\": \"woman guard\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"guardswoman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🥷\"\n  , \"description\": \"ninja\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"ninja\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👷\"\n  , \"description\": \"construction worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"construction_worker\"\n    ]\n  , \"tags\": [\n      \"helmet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👷‍♂️\"\n  , \"description\": \"man construction worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"construction_worker_man\"\n    ]\n  , \"tags\": [\n      \"helmet\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👷‍♀️\"\n  , \"description\": \"woman construction worker\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"construction_worker_woman\"\n    ]\n  , \"tags\": [\n      \"helmet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤴\"\n  , \"description\": \"prince\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"prince\"\n    ]\n  , \"tags\": [\n      \"crown\"\n    , \"royal\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👸\"\n  , \"description\": \"princess\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"princess\"\n    ]\n  , \"tags\": [\n      \"crown\"\n    , \"royal\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👳\"\n  , \"description\": \"person wearing turban\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_with_turban\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👳‍♂️\"\n  , \"description\": \"man wearing turban\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_with_turban\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👳‍♀️\"\n  , \"description\": \"woman wearing turban\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_with_turban\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👲\"\n  , \"description\": \"person with skullcap\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_with_gua_pi_mao\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧕\"\n  , \"description\": \"woman with headscarf\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_with_headscarf\"\n    ]\n  , \"tags\": [\n      \"hijab\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤵\"\n  , \"description\": \"person in tuxedo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_in_tuxedo\"\n    ]\n  , \"tags\": [\n      \"groom\"\n    , \"marriage\"\n    , \"wedding\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤵‍♂️\"\n  , \"description\": \"man in tuxedo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_in_tuxedo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤵‍♀️\"\n  , \"description\": \"woman in tuxedo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_in_tuxedo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👰\"\n  , \"description\": \"person with veil\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_with_veil\"\n    ]\n  , \"tags\": [\n      \"marriage\"\n    , \"wedding\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👰‍♂️\"\n  , \"description\": \"man with veil\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_with_veil\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👰‍♀️\"\n  , \"description\": \"woman with veil\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_with_veil\"\n    , \"bride_with_veil\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤰\"\n  , \"description\": \"pregnant woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"pregnant_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤱\"\n  , \"description\": \"breast-feeding\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"breast_feeding\"\n    ]\n  , \"tags\": [\n      \"nursing\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🍼\"\n  , \"description\": \"woman feeding baby\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_feeding_baby\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🍼\"\n  , \"description\": \"man feeding baby\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_feeding_baby\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🍼\"\n  , \"description\": \"person feeding baby\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_feeding_baby\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👼\"\n  , \"description\": \"baby angel\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"angel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🎅\"\n  , \"description\": \"Santa Claus\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"santa\"\n    ]\n  , \"tags\": [\n      \"christmas\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤶\"\n  , \"description\": \"Mrs. Claus\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mrs_claus\"\n    ]\n  , \"tags\": [\n      \"santa\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🎄\"\n  , \"description\": \"mx claus\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mx_claus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦸\"\n  , \"description\": \"superhero\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"superhero\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦸‍♂️\"\n  , \"description\": \"man superhero\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"superhero_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦸‍♀️\"\n  , \"description\": \"woman superhero\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"superhero_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦹\"\n  , \"description\": \"supervillain\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"supervillain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦹‍♂️\"\n  , \"description\": \"man supervillain\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"supervillain_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🦹‍♀️\"\n  , \"description\": \"woman supervillain\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"supervillain_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧙\"\n  , \"description\": \"mage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mage\"\n    ]\n  , \"tags\": [\n      \"wizard\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧙‍♂️\"\n  , \"description\": \"man mage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mage_man\"\n    ]\n  , \"tags\": [\n      \"wizard\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧙‍♀️\"\n  , \"description\": \"woman mage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mage_woman\"\n    ]\n  , \"tags\": [\n      \"wizard\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧚\"\n  , \"description\": \"fairy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fairy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧚‍♂️\"\n  , \"description\": \"man fairy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fairy_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧚‍♀️\"\n  , \"description\": \"woman fairy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"fairy_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧛\"\n  , \"description\": \"vampire\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"vampire\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧛‍♂️\"\n  , \"description\": \"man vampire\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"vampire_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧛‍♀️\"\n  , \"description\": \"woman vampire\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"vampire_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧜\"\n  , \"description\": \"merperson\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"merperson\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧜‍♂️\"\n  , \"description\": \"merman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"merman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧜‍♀️\"\n  , \"description\": \"mermaid\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mermaid\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧝\"\n  , \"description\": \"elf\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"elf\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧝‍♂️\"\n  , \"description\": \"man elf\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"elf_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧝‍♀️\"\n  , \"description\": \"woman elf\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"elf_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧞\"\n  , \"description\": \"genie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"genie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧞‍♂️\"\n  , \"description\": \"man genie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"genie_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧞‍♀️\"\n  , \"description\": \"woman genie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"genie_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧟\"\n  , \"description\": \"zombie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"zombie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧟‍♂️\"\n  , \"description\": \"man zombie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"zombie_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧟‍♀️\"\n  , \"description\": \"woman zombie\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"zombie_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"💆\"\n  , \"description\": \"person getting massage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"massage\"\n    ]\n  , \"tags\": [\n      \"spa\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💆‍♂️\"\n  , \"description\": \"man getting massage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"massage_man\"\n    ]\n  , \"tags\": [\n      \"spa\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💆‍♀️\"\n  , \"description\": \"woman getting massage\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"massage_woman\"\n    ]\n  , \"tags\": [\n      \"spa\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💇\"\n  , \"description\": \"person getting haircut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"haircut\"\n    ]\n  , \"tags\": [\n      \"beauty\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💇‍♂️\"\n  , \"description\": \"man getting haircut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"haircut_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💇‍♀️\"\n  , \"description\": \"woman getting haircut\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"haircut_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚶\"\n  , \"description\": \"person walking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"walking\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚶‍♂️\"\n  , \"description\": \"man walking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"walking_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚶‍♀️\"\n  , \"description\": \"woman walking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"walking_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧍\"\n  , \"description\": \"person standing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"standing_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧍‍♂️\"\n  , \"description\": \"man standing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"standing_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧍‍♀️\"\n  , \"description\": \"woman standing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"standing_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧎\"\n  , \"description\": \"person kneeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"kneeling_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧎‍♂️\"\n  , \"description\": \"man kneeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"kneeling_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧎‍♀️\"\n  , \"description\": \"woman kneeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"kneeling_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦯\"\n  , \"description\": \"person with white cane\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_with_probing_cane\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦯\"\n  , \"description\": \"man with white cane\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_with_probing_cane\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦯\"\n  , \"description\": \"woman with white cane\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_with_probing_cane\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦼\"\n  , \"description\": \"person in motorized wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_in_motorized_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦼\"\n  , \"description\": \"man in motorized wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_in_motorized_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦼\"\n  , \"description\": \"woman in motorized wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_in_motorized_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🦽\"\n  , \"description\": \"person in manual wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_in_manual_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.1\"\n  , \"ios_version\": \"13.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍🦽\"\n  , \"description\": \"man in manual wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_in_manual_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍🦽\"\n  , \"description\": \"woman in manual wheelchair\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_in_manual_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏃\"\n  , \"description\": \"person running\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"runner\"\n    , \"running\"\n    ]\n  , \"tags\": [\n      \"exercise\"\n    , \"workout\"\n    , \"marathon\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏃‍♂️\"\n  , \"description\": \"man running\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"running_man\"\n    ]\n  , \"tags\": [\n      \"exercise\"\n    , \"workout\"\n    , \"marathon\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏃‍♀️\"\n  , \"description\": \"woman running\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"running_woman\"\n    ]\n  , \"tags\": [\n      \"exercise\"\n    , \"workout\"\n    , \"marathon\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💃\"\n  , \"description\": \"woman dancing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_dancing\"\n    , \"dancer\"\n    ]\n  , \"tags\": [\n      \"dress\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🕺\"\n  , \"description\": \"man dancing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_dancing\"\n    ]\n  , \"tags\": [\n      \"dancer\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🕴️\"\n  , \"description\": \"person in suit levitating\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"business_suit_levitating\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👯\"\n  , \"description\": \"people with bunny ears\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"dancers\"\n    ]\n  , \"tags\": [\n      \"bunny\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👯‍♂️\"\n  , \"description\": \"men with bunny ears\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"dancing_men\"\n    ]\n  , \"tags\": [\n      \"bunny\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👯‍♀️\"\n  , \"description\": \"women with bunny ears\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"dancing_women\"\n    ]\n  , \"tags\": [\n      \"bunny\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧖\"\n  , \"description\": \"person in steamy room\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"sauna_person\"\n    ]\n  , \"tags\": [\n      \"steamy\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧖‍♂️\"\n  , \"description\": \"man in steamy room\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"sauna_man\"\n    ]\n  , \"tags\": [\n      \"steamy\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧖‍♀️\"\n  , \"description\": \"woman in steamy room\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"sauna_woman\"\n    ]\n  , \"tags\": [\n      \"steamy\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧗\"\n  , \"description\": \"person climbing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"climbing\"\n    ]\n  , \"tags\": [\n      \"bouldering\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧗‍♂️\"\n  , \"description\": \"man climbing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"climbing_man\"\n    ]\n  , \"tags\": [\n      \"bouldering\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧗‍♀️\"\n  , \"description\": \"woman climbing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"climbing_woman\"\n    ]\n  , \"tags\": [\n      \"bouldering\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤺\"\n  , \"description\": \"person fencing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"person_fencing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🏇\"\n  , \"description\": \"horse racing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"horse_racing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"⛷️\"\n  , \"description\": \"skier\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"skier\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏂\"\n  , \"description\": \"snowboarder\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"snowboarder\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏌️\"\n  , \"description\": \"person golfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"golfing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏌️‍♂️\"\n  , \"description\": \"man golfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"golfing_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏌️‍♀️\"\n  , \"description\": \"woman golfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"golfing_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏄\"\n  , \"description\": \"person surfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"surfer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏄‍♂️\"\n  , \"description\": \"man surfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"surfing_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏄‍♀️\"\n  , \"description\": \"woman surfing\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"surfing_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚣\"\n  , \"description\": \"person rowing boat\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"rowboat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚣‍♂️\"\n  , \"description\": \"man rowing boat\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"rowing_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚣‍♀️\"\n  , \"description\": \"woman rowing boat\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"rowing_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏊\"\n  , \"description\": \"person swimming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"swimmer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏊‍♂️\"\n  , \"description\": \"man swimming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"swimming_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏊‍♀️\"\n  , \"description\": \"woman swimming\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"swimming_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"⛹️\"\n  , \"description\": \"person bouncing ball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bouncing_ball_person\"\n    ]\n  , \"tags\": [\n      \"basketball\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"⛹️‍♂️\"\n  , \"description\": \"man bouncing ball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bouncing_ball_man\"\n    , \"basketball_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"⛹️‍♀️\"\n  , \"description\": \"woman bouncing ball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bouncing_ball_woman\"\n    , \"basketball_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏋️\"\n  , \"description\": \"person lifting weights\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"weight_lifting\"\n    ]\n  , \"tags\": [\n      \"gym\"\n    , \"workout\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏋️‍♂️\"\n  , \"description\": \"man lifting weights\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"weight_lifting_man\"\n    ]\n  , \"tags\": [\n      \"gym\"\n    , \"workout\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🏋️‍♀️\"\n  , \"description\": \"woman lifting weights\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"weight_lifting_woman\"\n    ]\n  , \"tags\": [\n      \"gym\"\n    , \"workout\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚴\"\n  , \"description\": \"person biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bicyclist\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚴‍♂️\"\n  , \"description\": \"man biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"biking_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚴‍♀️\"\n  , \"description\": \"woman biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"biking_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚵\"\n  , \"description\": \"person mountain biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mountain_bicyclist\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚵‍♂️\"\n  , \"description\": \"man mountain biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mountain_biking_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🚵‍♀️\"\n  , \"description\": \"woman mountain biking\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"mountain_biking_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤸\"\n  , \"description\": \"person cartwheeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"cartwheeling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤸‍♂️\"\n  , \"description\": \"man cartwheeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_cartwheeling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤸‍♀️\"\n  , \"description\": \"woman cartwheeling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_cartwheeling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤼\"\n  , \"description\": \"people wrestling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"wrestling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🤼‍♂️\"\n  , \"description\": \"men wrestling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"men_wrestling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🤼‍♀️\"\n  , \"description\": \"women wrestling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"women_wrestling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🤽\"\n  , \"description\": \"person playing water polo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"water_polo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤽‍♂️\"\n  , \"description\": \"man playing water polo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_playing_water_polo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤽‍♀️\"\n  , \"description\": \"woman playing water polo\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_playing_water_polo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤾\"\n  , \"description\": \"person playing handball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"handball_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤾‍♂️\"\n  , \"description\": \"man playing handball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_playing_handball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤾‍♀️\"\n  , \"description\": \"woman playing handball\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_playing_handball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤹\"\n  , \"description\": \"person juggling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"juggling_person\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤹‍♂️\"\n  , \"description\": \"man juggling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"man_juggling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🤹‍♀️\"\n  , \"description\": \"woman juggling\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"woman_juggling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧘\"\n  , \"description\": \"person in lotus position\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"lotus_position\"\n    ]\n  , \"tags\": [\n      \"meditation\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧘‍♂️\"\n  , \"description\": \"man in lotus position\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"lotus_position_man\"\n    ]\n  , \"tags\": [\n      \"meditation\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧘‍♀️\"\n  , \"description\": \"woman in lotus position\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"lotus_position_woman\"\n    ]\n  , \"tags\": [\n      \"meditation\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🛀\"\n  , \"description\": \"person taking bath\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bath\"\n    ]\n  , \"tags\": [\n      \"shower\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🛌\"\n  , \"description\": \"person in bed\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"sleeping_bed\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"🧑‍🤝‍🧑\"\n  , \"description\": \"people holding hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"people_holding_hands\"\n    ]\n  , \"tags\": [\n      \"couple\"\n    , \"date\"\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👭\"\n  , \"description\": \"women holding hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"two_women_holding_hands\"\n    ]\n  , \"tags\": [\n      \"couple\"\n    , \"date\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👫\"\n  , \"description\": \"woman and man holding hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couple\"\n    ]\n  , \"tags\": [\n      \"date\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👬\"\n  , \"description\": \"men holding hands\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"two_men_holding_hands\"\n    ]\n  , \"tags\": [\n      \"couple\"\n    , \"date\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💏\"\n  , \"description\": \"kiss\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couplekiss\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍❤️‍💋‍👨\"\n  , \"description\": \"kiss: woman, man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couplekiss_man_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍❤️‍💋‍👨\"\n  , \"description\": \"kiss: man, man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couplekiss_man_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍❤️‍💋‍👩\"\n  , \"description\": \"kiss: woman, woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couplekiss_woman_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"💑\"\n  , \"description\": \"couple with heart\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couple_with_heart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍❤️‍👨\"\n  , \"description\": \"couple with heart: woman, man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couple_with_heart_woman_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👨‍❤️‍👨\"\n  , \"description\": \"couple with heart: man, man\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couple_with_heart_man_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👩‍❤️‍👩\"\n  , \"description\": \"couple with heart: woman, woman\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"couple_with_heart_woman_woman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  , \"skin_tones\": true\n  }\n, {\n    \"emoji\": \"👪\"\n  , \"description\": \"family\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family\"\n    ]\n  , \"tags\": [\n      \"home\"\n    , \"parents\"\n    , \"child\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👨‍👩‍👦\"\n  , \"description\": \"family: man, woman, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_woman_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"👨‍👩‍👧\"\n  , \"description\": \"family: man, woman, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_woman_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👩‍👧‍👦\"\n  , \"description\": \"family: man, woman, girl, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_woman_girl_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👩‍👦‍👦\"\n  , \"description\": \"family: man, woman, boy, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_woman_boy_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👩‍👧‍👧\"\n  , \"description\": \"family: man, woman, girl, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_woman_girl_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👨‍👦\"\n  , \"description\": \"family: man, man, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_man_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👨‍👧\"\n  , \"description\": \"family: man, man, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_man_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👨‍👧‍👦\"\n  , \"description\": \"family: man, man, girl, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_man_girl_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👨‍👦‍👦\"\n  , \"description\": \"family: man, man, boy, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_man_boy_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👨‍👧‍👧\"\n  , \"description\": \"family: man, man, girl, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_man_girl_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👩‍👩‍👦\"\n  , \"description\": \"family: woman, woman, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_woman_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👩‍👩‍👧\"\n  , \"description\": \"family: woman, woman, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_woman_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👩‍👩‍👧‍👦\"\n  , \"description\": \"family: woman, woman, girl, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_woman_girl_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👩‍👩‍👦‍👦\"\n  , \"description\": \"family: woman, woman, boy, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_woman_boy_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👩‍👩‍👧‍👧\"\n  , \"description\": \"family: woman, woman, girl, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_woman_girl_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"👨‍👦\"\n  , \"description\": \"family: man, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👨‍👦‍👦\"\n  , \"description\": \"family: man, boy, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_boy_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👨‍👧\"\n  , \"description\": \"family: man, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👨‍👧‍👦\"\n  , \"description\": \"family: man, girl, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_girl_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👨‍👧‍👧\"\n  , \"description\": \"family: man, girl, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_man_girl_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👩‍👦\"\n  , \"description\": \"family: woman, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👩‍👦‍👦\"\n  , \"description\": \"family: woman, boy, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_boy_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👩‍👧\"\n  , \"description\": \"family: woman, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👩‍👧‍👦\"\n  , \"description\": \"family: woman, girl, boy\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_girl_boy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"👩‍👧‍👧\"\n  , \"description\": \"family: woman, girl, girl\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"family_woman_girl_girl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"🗣️\"\n  , \"description\": \"speaking head\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"speaking_head\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"👤\"\n  , \"description\": \"bust in silhouette\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"bust_in_silhouette\"\n    ]\n  , \"tags\": [\n      \"user\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👥\"\n  , \"description\": \"busts in silhouette\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"busts_in_silhouette\"\n    ]\n  , \"tags\": [\n      \"users\"\n    , \"group\"\n    , \"team\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🫂\"\n  , \"description\": \"people hugging\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"people_hugging\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"👣\"\n  , \"description\": \"footprints\"\n  , \"category\": \"People & Body\"\n  , \"aliases\": [\n      \"footprints\"\n    ]\n  , \"tags\": [\n      \"feet\"\n    , \"tracks\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐵\"\n  , \"description\": \"monkey face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"monkey_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐒\"\n  , \"description\": \"monkey\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"monkey\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦍\"\n  , \"description\": \"gorilla\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"gorilla\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦧\"\n  , \"description\": \"orangutan\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"orangutan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🐶\"\n  , \"description\": \"dog face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dog\"\n    ]\n  , \"tags\": [\n      \"pet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐕\"\n  , \"description\": \"dog\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dog2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦮\"\n  , \"description\": \"guide dog\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"guide_dog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🐕‍🦺\"\n  , \"description\": \"service dog\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"service_dog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🐩\"\n  , \"description\": \"poodle\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"poodle\"\n    ]\n  , \"tags\": [\n      \"dog\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐺\"\n  , \"description\": \"wolf\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"wolf\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦊\"\n  , \"description\": \"fox\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"fox_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦝\"\n  , \"description\": \"raccoon\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"raccoon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐱\"\n  , \"description\": \"cat face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cat\"\n    ]\n  , \"tags\": [\n      \"pet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐈\"\n  , \"description\": \"cat\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cat2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐈‍⬛\"\n  , \"description\": \"black cat\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"black_cat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦁\"\n  , \"description\": \"lion\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"lion\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🐯\"\n  , \"description\": \"tiger face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"tiger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐅\"\n  , \"description\": \"tiger\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"tiger2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐆\"\n  , \"description\": \"leopard\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"leopard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐴\"\n  , \"description\": \"horse face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"horse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐎\"\n  , \"description\": \"horse\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"racehorse\"\n    ]\n  , \"tags\": [\n      \"speed\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦄\"\n  , \"description\": \"unicorn\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"unicorn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦓\"\n  , \"description\": \"zebra\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"zebra\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦌\"\n  , \"description\": \"deer\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"deer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦬\"\n  , \"description\": \"bison\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bison\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🐮\"\n  , \"description\": \"cow face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐂\"\n  , \"description\": \"ox\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"ox\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐃\"\n  , \"description\": \"water buffalo\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"water_buffalo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐄\"\n  , \"description\": \"cow\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cow2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐷\"\n  , \"description\": \"pig face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"pig\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐖\"\n  , \"description\": \"pig\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"pig2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐗\"\n  , \"description\": \"boar\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"boar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐽\"\n  , \"description\": \"pig nose\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"pig_nose\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐏\"\n  , \"description\": \"ram\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"ram\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐑\"\n  , \"description\": \"ewe\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"sheep\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐐\"\n  , \"description\": \"goat\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"goat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐪\"\n  , \"description\": \"camel\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dromedary_camel\"\n    ]\n  , \"tags\": [\n      \"desert\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐫\"\n  , \"description\": \"two-hump camel\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"camel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦙\"\n  , \"description\": \"llama\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"llama\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦒\"\n  , \"description\": \"giraffe\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"giraffe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐘\"\n  , \"description\": \"elephant\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"elephant\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦣\"\n  , \"description\": \"mammoth\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"mammoth\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦏\"\n  , \"description\": \"rhinoceros\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rhinoceros\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦛\"\n  , \"description\": \"hippopotamus\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hippopotamus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐭\"\n  , \"description\": \"mouse face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"mouse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐁\"\n  , \"description\": \"mouse\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"mouse2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐀\"\n  , \"description\": \"rat\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐹\"\n  , \"description\": \"hamster\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hamster\"\n    ]\n  , \"tags\": [\n      \"pet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐰\"\n  , \"description\": \"rabbit face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rabbit\"\n    ]\n  , \"tags\": [\n      \"bunny\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐇\"\n  , \"description\": \"rabbit\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rabbit2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐿️\"\n  , \"description\": \"chipmunk\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"chipmunk\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦫\"\n  , \"description\": \"beaver\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"beaver\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦔\"\n  , \"description\": \"hedgehog\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hedgehog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦇\"\n  , \"description\": \"bat\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🐻\"\n  , \"description\": \"bear\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐻‍❄️\"\n  , \"description\": \"polar bear\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"polar_bear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🐨\"\n  , \"description\": \"koala\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"koala\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐼\"\n  , \"description\": \"panda\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"panda_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦥\"\n  , \"description\": \"sloth\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"sloth\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦦\"\n  , \"description\": \"otter\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"otter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦨\"\n  , \"description\": \"skunk\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"skunk\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦘\"\n  , \"description\": \"kangaroo\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"kangaroo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦡\"\n  , \"description\": \"badger\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"badger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐾\"\n  , \"description\": \"paw prints\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"feet\"\n    , \"paw_prints\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦃\"\n  , \"description\": \"turkey\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"turkey\"\n    ]\n  , \"tags\": [\n      \"thanksgiving\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🐔\"\n  , \"description\": \"chicken\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"chicken\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐓\"\n  , \"description\": \"rooster\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rooster\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐣\"\n  , \"description\": \"hatching chick\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hatching_chick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐤\"\n  , \"description\": \"baby chick\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"baby_chick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐥\"\n  , \"description\": \"front-facing baby chick\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hatched_chick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐦\"\n  , \"description\": \"bird\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bird\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐧\"\n  , \"description\": \"penguin\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"penguin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕊️\"\n  , \"description\": \"dove\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dove\"\n    ]\n  , \"tags\": [\n      \"peace\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦅\"\n  , \"description\": \"eagle\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"eagle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦆\"\n  , \"description\": \"duck\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"duck\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦢\"\n  , \"description\": \"swan\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"swan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦉\"\n  , \"description\": \"owl\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"owl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦤\"\n  , \"description\": \"dodo\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dodo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪶\"\n  , \"description\": \"feather\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"feather\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦩\"\n  , \"description\": \"flamingo\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"flamingo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦚\"\n  , \"description\": \"peacock\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"peacock\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦜\"\n  , \"description\": \"parrot\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"parrot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐸\"\n  , \"description\": \"frog\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"frog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐊\"\n  , \"description\": \"crocodile\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"crocodile\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐢\"\n  , \"description\": \"turtle\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"turtle\"\n    ]\n  , \"tags\": [\n      \"slow\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦎\"\n  , \"description\": \"lizard\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"lizard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🐍\"\n  , \"description\": \"snake\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"snake\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐲\"\n  , \"description\": \"dragon face\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dragon_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐉\"\n  , \"description\": \"dragon\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dragon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦕\"\n  , \"description\": \"sauropod\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"sauropod\"\n    ]\n  , \"tags\": [\n      \"dinosaur\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦖\"\n  , \"description\": \"T-Rex\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"t-rex\"\n    ]\n  , \"tags\": [\n      \"dinosaur\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🐳\"\n  , \"description\": \"spouting whale\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"whale\"\n    ]\n  , \"tags\": [\n      \"sea\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐋\"\n  , \"description\": \"whale\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"whale2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐬\"\n  , \"description\": \"dolphin\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"dolphin\"\n    , \"flipper\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦭\"\n  , \"description\": \"seal\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"seal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🐟\"\n  , \"description\": \"fish\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"fish\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐠\"\n  , \"description\": \"tropical fish\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"tropical_fish\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐡\"\n  , \"description\": \"blowfish\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"blowfish\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦈\"\n  , \"description\": \"shark\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"shark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🐙\"\n  , \"description\": \"octopus\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"octopus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐚\"\n  , \"description\": \"spiral shell\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"shell\"\n    ]\n  , \"tags\": [\n      \"sea\"\n    , \"beach\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐌\"\n  , \"description\": \"snail\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"snail\"\n    ]\n  , \"tags\": [\n      \"slow\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦋\"\n  , \"description\": \"butterfly\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"butterfly\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🐛\"\n  , \"description\": \"bug\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bug\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐜\"\n  , \"description\": \"ant\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"ant\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🐝\"\n  , \"description\": \"honeybee\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bee\"\n    , \"honeybee\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪲\"\n  , \"description\": \"beetle\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"beetle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🐞\"\n  , \"description\": \"lady beetle\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"lady_beetle\"\n    ]\n  , \"tags\": [\n      \"bug\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🦗\"\n  , \"description\": \"cricket\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cricket\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪳\"\n  , \"description\": \"cockroach\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cockroach\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🕷️\"\n  , \"description\": \"spider\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"spider\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕸️\"\n  , \"description\": \"spider web\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"spider_web\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦂\"\n  , \"description\": \"scorpion\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"scorpion\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦟\"\n  , \"description\": \"mosquito\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"mosquito\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪰\"\n  , \"description\": \"fly\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"fly\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪱\"\n  , \"description\": \"worm\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"worm\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🦠\"\n  , \"description\": \"microbe\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"microbe\"\n    ]\n  , \"tags\": [\n      \"germ\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"💐\"\n  , \"description\": \"bouquet\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"bouquet\"\n    ]\n  , \"tags\": [\n      \"flowers\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌸\"\n  , \"description\": \"cherry blossom\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cherry_blossom\"\n    ]\n  , \"tags\": [\n      \"flower\"\n    , \"spring\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💮\"\n  , \"description\": \"white flower\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"white_flower\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏵️\"\n  , \"description\": \"rosette\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rosette\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌹\"\n  , \"description\": \"rose\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"rose\"\n    ]\n  , \"tags\": [\n      \"flower\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥀\"\n  , \"description\": \"wilted flower\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"wilted_flower\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🌺\"\n  , \"description\": \"hibiscus\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"hibiscus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌻\"\n  , \"description\": \"sunflower\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"sunflower\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌼\"\n  , \"description\": \"blossom\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"blossom\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌷\"\n  , \"description\": \"tulip\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"tulip\"\n    ]\n  , \"tags\": [\n      \"flower\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌱\"\n  , \"description\": \"seedling\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"seedling\"\n    ]\n  , \"tags\": [\n      \"plant\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪴\"\n  , \"description\": \"potted plant\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"potted_plant\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🌲\"\n  , \"description\": \"evergreen tree\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"evergreen_tree\"\n    ]\n  , \"tags\": [\n      \"wood\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌳\"\n  , \"description\": \"deciduous tree\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"deciduous_tree\"\n    ]\n  , \"tags\": [\n      \"wood\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌴\"\n  , \"description\": \"palm tree\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"palm_tree\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌵\"\n  , \"description\": \"cactus\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"cactus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌾\"\n  , \"description\": \"sheaf of rice\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"ear_of_rice\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌿\"\n  , \"description\": \"herb\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"herb\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☘️\"\n  , \"description\": \"shamrock\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"shamrock\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🍀\"\n  , \"description\": \"four leaf clover\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"four_leaf_clover\"\n    ]\n  , \"tags\": [\n      \"luck\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍁\"\n  , \"description\": \"maple leaf\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"maple_leaf\"\n    ]\n  , \"tags\": [\n      \"canada\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍂\"\n  , \"description\": \"fallen leaf\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"fallen_leaf\"\n    ]\n  , \"tags\": [\n      \"autumn\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍃\"\n  , \"description\": \"leaf fluttering in wind\"\n  , \"category\": \"Animals & Nature\"\n  , \"aliases\": [\n      \"leaves\"\n    ]\n  , \"tags\": [\n      \"leaf\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍇\"\n  , \"description\": \"grapes\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"grapes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍈\"\n  , \"description\": \"melon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"melon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍉\"\n  , \"description\": \"watermelon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"watermelon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍊\"\n  , \"description\": \"tangerine\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tangerine\"\n    , \"orange\"\n    , \"mandarin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍋\"\n  , \"description\": \"lemon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"lemon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍌\"\n  , \"description\": \"banana\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"banana\"\n    ]\n  , \"tags\": [\n      \"fruit\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍍\"\n  , \"description\": \"pineapple\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pineapple\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥭\"\n  , \"description\": \"mango\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"mango\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🍎\"\n  , \"description\": \"red apple\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"apple\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍏\"\n  , \"description\": \"green apple\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"green_apple\"\n    ]\n  , \"tags\": [\n      \"fruit\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍐\"\n  , \"description\": \"pear\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍑\"\n  , \"description\": \"peach\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"peach\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍒\"\n  , \"description\": \"cherries\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cherries\"\n    ]\n  , \"tags\": [\n      \"fruit\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍓\"\n  , \"description\": \"strawberry\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"strawberry\"\n    ]\n  , \"tags\": [\n      \"fruit\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🫐\"\n  , \"description\": \"blueberries\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"blueberries\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥝\"\n  , \"description\": \"kiwi fruit\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"kiwi_fruit\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍅\"\n  , \"description\": \"tomato\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tomato\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🫒\"\n  , \"description\": \"olive\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"olive\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥥\"\n  , \"description\": \"coconut\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"coconut\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥑\"\n  , \"description\": \"avocado\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"avocado\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍆\"\n  , \"description\": \"eggplant\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"eggplant\"\n    ]\n  , \"tags\": [\n      \"aubergine\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥔\"\n  , \"description\": \"potato\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"potato\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥕\"\n  , \"description\": \"carrot\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"carrot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🌽\"\n  , \"description\": \"ear of corn\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"corn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌶️\"\n  , \"description\": \"hot pepper\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"hot_pepper\"\n    ]\n  , \"tags\": [\n      \"spicy\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🫑\"\n  , \"description\": \"bell pepper\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bell_pepper\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥒\"\n  , \"description\": \"cucumber\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cucumber\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥬\"\n  , \"description\": \"leafy green\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"leafy_green\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥦\"\n  , \"description\": \"broccoli\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"broccoli\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧄\"\n  , \"description\": \"garlic\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"garlic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧅\"\n  , \"description\": \"onion\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"onion\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🍄\"\n  , \"description\": \"mushroom\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"mushroom\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥜\"\n  , \"description\": \"peanuts\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"peanuts\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🌰\"\n  , \"description\": \"chestnut\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"chestnut\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍞\"\n  , \"description\": \"bread\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bread\"\n    ]\n  , \"tags\": [\n      \"toast\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥐\"\n  , \"description\": \"croissant\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"croissant\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥖\"\n  , \"description\": \"baguette bread\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"baguette_bread\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🫓\"\n  , \"description\": \"flatbread\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"flatbread\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥨\"\n  , \"description\": \"pretzel\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pretzel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥯\"\n  , \"description\": \"bagel\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bagel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥞\"\n  , \"description\": \"pancakes\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pancakes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🧇\"\n  , \"description\": \"waffle\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"waffle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧀\"\n  , \"description\": \"cheese wedge\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cheese\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🍖\"\n  , \"description\": \"meat on bone\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"meat_on_bone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍗\"\n  , \"description\": \"poultry leg\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"poultry_leg\"\n    ]\n  , \"tags\": [\n      \"meat\"\n    , \"chicken\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥩\"\n  , \"description\": \"cut of meat\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cut_of_meat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥓\"\n  , \"description\": \"bacon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bacon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍔\"\n  , \"description\": \"hamburger\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"hamburger\"\n    ]\n  , \"tags\": [\n      \"burger\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍟\"\n  , \"description\": \"french fries\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fries\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍕\"\n  , \"description\": \"pizza\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pizza\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌭\"\n  , \"description\": \"hot dog\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"hotdog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🥪\"\n  , \"description\": \"sandwich\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"sandwich\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🌮\"\n  , \"description\": \"taco\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"taco\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌯\"\n  , \"description\": \"burrito\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"burrito\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🫔\"\n  , \"description\": \"tamale\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tamale\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥙\"\n  , \"description\": \"stuffed flatbread\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"stuffed_flatbread\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🧆\"\n  , \"description\": \"falafel\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"falafel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🥚\"\n  , \"description\": \"egg\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"egg\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍳\"\n  , \"description\": \"cooking\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fried_egg\"\n    ]\n  , \"tags\": [\n      \"breakfast\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥘\"\n  , \"description\": \"shallow pan of food\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"shallow_pan_of_food\"\n    ]\n  , \"tags\": [\n      \"paella\"\n    , \"curry\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍲\"\n  , \"description\": \"pot of food\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"stew\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🫕\"\n  , \"description\": \"fondue\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fondue\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🥣\"\n  , \"description\": \"bowl with spoon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bowl_with_spoon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥗\"\n  , \"description\": \"green salad\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"green_salad\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🍿\"\n  , \"description\": \"popcorn\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"popcorn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🧈\"\n  , \"description\": \"butter\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"butter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧂\"\n  , \"description\": \"salt\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"salt\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥫\"\n  , \"description\": \"canned food\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"canned_food\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🍱\"\n  , \"description\": \"bento box\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bento\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍘\"\n  , \"description\": \"rice cracker\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"rice_cracker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍙\"\n  , \"description\": \"rice ball\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"rice_ball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍚\"\n  , \"description\": \"cooked rice\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"rice\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍛\"\n  , \"description\": \"curry rice\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"curry\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍜\"\n  , \"description\": \"steaming bowl\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"ramen\"\n    ]\n  , \"tags\": [\n      \"noodle\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍝\"\n  , \"description\": \"spaghetti\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"spaghetti\"\n    ]\n  , \"tags\": [\n      \"pasta\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍠\"\n  , \"description\": \"roasted sweet potato\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"sweet_potato\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍢\"\n  , \"description\": \"oden\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"oden\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍣\"\n  , \"description\": \"sushi\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"sushi\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍤\"\n  , \"description\": \"fried shrimp\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fried_shrimp\"\n    ]\n  , \"tags\": [\n      \"tempura\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍥\"\n  , \"description\": \"fish cake with swirl\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fish_cake\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥮\"\n  , \"description\": \"moon cake\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"moon_cake\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🍡\"\n  , \"description\": \"dango\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"dango\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥟\"\n  , \"description\": \"dumpling\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"dumpling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥠\"\n  , \"description\": \"fortune cookie\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fortune_cookie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥡\"\n  , \"description\": \"takeout box\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"takeout_box\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦀\"\n  , \"description\": \"crab\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"crab\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦞\"\n  , \"description\": \"lobster\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"lobster\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦐\"\n  , \"description\": \"shrimp\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"shrimp\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦑\"\n  , \"description\": \"squid\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"squid\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦪\"\n  , \"description\": \"oyster\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"oyster\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🍦\"\n  , \"description\": \"soft ice cream\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"icecream\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍧\"\n  , \"description\": \"shaved ice\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"shaved_ice\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍨\"\n  , \"description\": \"ice cream\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"ice_cream\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍩\"\n  , \"description\": \"doughnut\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"doughnut\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍪\"\n  , \"description\": \"cookie\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cookie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎂\"\n  , \"description\": \"birthday cake\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"birthday\"\n    ]\n  , \"tags\": [\n      \"party\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍰\"\n  , \"description\": \"shortcake\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cake\"\n    ]\n  , \"tags\": [\n      \"dessert\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧁\"\n  , \"description\": \"cupcake\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cupcake\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥧\"\n  , \"description\": \"pie\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"pie\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🍫\"\n  , \"description\": \"chocolate bar\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"chocolate_bar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍬\"\n  , \"description\": \"candy\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"candy\"\n    ]\n  , \"tags\": [\n      \"sweet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍭\"\n  , \"description\": \"lollipop\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"lollipop\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍮\"\n  , \"description\": \"custard\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"custard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍯\"\n  , \"description\": \"honey pot\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"honey_pot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍼\"\n  , \"description\": \"baby bottle\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"baby_bottle\"\n    ]\n  , \"tags\": [\n      \"milk\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥛\"\n  , \"description\": \"glass of milk\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"milk_glass\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"☕\"\n  , \"description\": \"hot beverage\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"coffee\"\n    ]\n  , \"tags\": [\n      \"cafe\"\n    , \"espresso\"\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🫖\"\n  , \"description\": \"teapot\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"teapot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🍵\"\n  , \"description\": \"teacup without handle\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tea\"\n    ]\n  , \"tags\": [\n      \"green\"\n    , \"breakfast\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍶\"\n  , \"description\": \"sake\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"sake\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍾\"\n  , \"description\": \"bottle with popping cork\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"champagne\"\n    ]\n  , \"tags\": [\n      \"bottle\"\n    , \"bubbly\"\n    , \"celebration\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🍷\"\n  , \"description\": \"wine glass\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"wine_glass\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍸\"\n  , \"description\": \"cocktail glass\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cocktail\"\n    ]\n  , \"tags\": [\n      \"drink\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍹\"\n  , \"description\": \"tropical drink\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tropical_drink\"\n    ]\n  , \"tags\": [\n      \"summer\"\n    , \"vacation\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍺\"\n  , \"description\": \"beer mug\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"beer\"\n    ]\n  , \"tags\": [\n      \"drink\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🍻\"\n  , \"description\": \"clinking beer mugs\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"beers\"\n    ]\n  , \"tags\": [\n      \"drinks\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥂\"\n  , \"description\": \"clinking glasses\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"clinking_glasses\"\n    ]\n  , \"tags\": [\n      \"cheers\"\n    , \"toast\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥃\"\n  , \"description\": \"tumbler glass\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"tumbler_glass\"\n    ]\n  , \"tags\": [\n      \"whisky\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥤\"\n  , \"description\": \"cup with straw\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"cup_with_straw\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧋\"\n  , \"description\": \"bubble tea\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"bubble_tea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧃\"\n  , \"description\": \"beverage box\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"beverage_box\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧉\"\n  , \"description\": \"mate\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"mate\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧊\"\n  , \"description\": \"ice\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"ice_cube\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🥢\"\n  , \"description\": \"chopsticks\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"chopsticks\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🍽️\"\n  , \"description\": \"fork and knife with plate\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"plate_with_cutlery\"\n    ]\n  , \"tags\": [\n      \"dining\"\n    , \"dinner\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🍴\"\n  , \"description\": \"fork and knife\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"fork_and_knife\"\n    ]\n  , \"tags\": [\n      \"cutlery\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥄\"\n  , \"description\": \"spoon\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"spoon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🔪\"\n  , \"description\": \"kitchen knife\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"hocho\"\n    , \"knife\"\n    ]\n  , \"tags\": [\n      \"cut\"\n    , \"chop\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏺\"\n  , \"description\": \"amphora\"\n  , \"category\": \"Food & Drink\"\n  , \"aliases\": [\n      \"amphora\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌍\"\n  , \"description\": \"globe showing Europe-Africa\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"earth_africa\"\n    ]\n  , \"tags\": [\n      \"globe\"\n    , \"world\"\n    , \"international\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌎\"\n  , \"description\": \"globe showing Americas\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"earth_americas\"\n    ]\n  , \"tags\": [\n      \"globe\"\n    , \"world\"\n    , \"international\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌏\"\n  , \"description\": \"globe showing Asia-Australia\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"earth_asia\"\n    ]\n  , \"tags\": [\n      \"globe\"\n    , \"world\"\n    , \"international\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌐\"\n  , \"description\": \"globe with meridians\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"globe_with_meridians\"\n    ]\n  , \"tags\": [\n      \"world\"\n    , \"global\"\n    , \"international\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗺️\"\n  , \"description\": \"world map\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"world_map\"\n    ]\n  , \"tags\": [\n      \"travel\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗾\"\n  , \"description\": \"map of Japan\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"japan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧭\"\n  , \"description\": \"compass\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"compass\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🏔️\"\n  , \"description\": \"snow-capped mountain\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mountain_snow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛰️\"\n  , \"description\": \"mountain\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mountain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌋\"\n  , \"description\": \"volcano\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"volcano\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗻\"\n  , \"description\": \"mount fuji\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mount_fuji\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏕️\"\n  , \"description\": \"camping\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"camping\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏖️\"\n  , \"description\": \"beach with umbrella\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"beach_umbrella\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏜️\"\n  , \"description\": \"desert\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"desert\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏝️\"\n  , \"description\": \"desert island\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"desert_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏞️\"\n  , \"description\": \"national park\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"national_park\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏟️\"\n  , \"description\": \"stadium\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"stadium\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏛️\"\n  , \"description\": \"classical building\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"classical_building\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏗️\"\n  , \"description\": \"building construction\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"building_construction\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🧱\"\n  , \"description\": \"brick\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bricks\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪨\"\n  , \"description\": \"rock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"rock\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪵\"\n  , \"description\": \"wood\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"wood\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🛖\"\n  , \"description\": \"hut\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hut\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🏘️\"\n  , \"description\": \"houses\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"houses\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏚️\"\n  , \"description\": \"derelict house\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"derelict_house\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏠\"\n  , \"description\": \"house\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"house\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏡\"\n  , \"description\": \"house with garden\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"house_with_garden\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏢\"\n  , \"description\": \"office building\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"office\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏣\"\n  , \"description\": \"Japanese post office\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"post_office\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏤\"\n  , \"description\": \"post office\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"european_post_office\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏥\"\n  , \"description\": \"hospital\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hospital\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏦\"\n  , \"description\": \"bank\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bank\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏨\"\n  , \"description\": \"hotel\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hotel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏩\"\n  , \"description\": \"love hotel\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"love_hotel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏪\"\n  , \"description\": \"convenience store\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"convenience_store\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏫\"\n  , \"description\": \"school\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"school\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏬\"\n  , \"description\": \"department store\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"department_store\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏭\"\n  , \"description\": \"factory\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"factory\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏯\"\n  , \"description\": \"Japanese castle\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"japanese_castle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏰\"\n  , \"description\": \"castle\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"european_castle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💒\"\n  , \"description\": \"wedding\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"wedding\"\n    ]\n  , \"tags\": [\n      \"marriage\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗼\"\n  , \"description\": \"Tokyo tower\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"tokyo_tower\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗽\"\n  , \"description\": \"Statue of Liberty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"statue_of_liberty\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛪\"\n  , \"description\": \"church\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"church\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕌\"\n  , \"description\": \"mosque\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mosque\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛕\"\n  , \"description\": \"hindu temple\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hindu_temple\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🕍\"\n  , \"description\": \"synagogue\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"synagogue\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛩️\"\n  , \"description\": \"shinto shrine\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"shinto_shrine\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕋\"\n  , \"description\": \"kaaba\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"kaaba\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛲\"\n  , \"description\": \"fountain\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"fountain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛺\"\n  , \"description\": \"tent\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"tent\"\n    ]\n  , \"tags\": [\n      \"camping\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌁\"\n  , \"description\": \"foggy\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"foggy\"\n    ]\n  , \"tags\": [\n      \"karl\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌃\"\n  , \"description\": \"night with stars\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"night_with_stars\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏙️\"\n  , \"description\": \"cityscape\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cityscape\"\n    ]\n  , \"tags\": [\n      \"skyline\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌄\"\n  , \"description\": \"sunrise over mountains\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sunrise_over_mountains\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌅\"\n  , \"description\": \"sunrise\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sunrise\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌆\"\n  , \"description\": \"cityscape at dusk\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"city_sunset\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌇\"\n  , \"description\": \"sunset\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"city_sunrise\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌉\"\n  , \"description\": \"bridge at night\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bridge_at_night\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♨️\"\n  , \"description\": \"hot springs\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hotsprings\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎠\"\n  , \"description\": \"carousel horse\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"carousel_horse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎡\"\n  , \"description\": \"ferris wheel\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ferris_wheel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎢\"\n  , \"description\": \"roller coaster\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"roller_coaster\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💈\"\n  , \"description\": \"barber pole\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"barber\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎪\"\n  , \"description\": \"circus tent\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"circus_tent\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚂\"\n  , \"description\": \"locomotive\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"steam_locomotive\"\n    ]\n  , \"tags\": [\n      \"train\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚃\"\n  , \"description\": \"railway car\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"railway_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚄\"\n  , \"description\": \"high-speed train\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bullettrain_side\"\n    ]\n  , \"tags\": [\n      \"train\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚅\"\n  , \"description\": \"bullet train\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bullettrain_front\"\n    ]\n  , \"tags\": [\n      \"train\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚆\"\n  , \"description\": \"train\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"train2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚇\"\n  , \"description\": \"metro\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"metro\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚈\"\n  , \"description\": \"light rail\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"light_rail\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚉\"\n  , \"description\": \"station\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"station\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚊\"\n  , \"description\": \"tram\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"tram\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚝\"\n  , \"description\": \"monorail\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"monorail\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚞\"\n  , \"description\": \"mountain railway\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mountain_railway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚋\"\n  , \"description\": \"tram car\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"train\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚌\"\n  , \"description\": \"bus\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚍\"\n  , \"description\": \"oncoming bus\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"oncoming_bus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚎\"\n  , \"description\": \"trolleybus\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"trolleybus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚐\"\n  , \"description\": \"minibus\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"minibus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚑\"\n  , \"description\": \"ambulance\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ambulance\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚒\"\n  , \"description\": \"fire engine\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"fire_engine\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚓\"\n  , \"description\": \"police car\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"police_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚔\"\n  , \"description\": \"oncoming police car\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"oncoming_police_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚕\"\n  , \"description\": \"taxi\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"taxi\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚖\"\n  , \"description\": \"oncoming taxi\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"oncoming_taxi\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚗\"\n  , \"description\": \"automobile\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"car\"\n    , \"red_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚘\"\n  , \"description\": \"oncoming automobile\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"oncoming_automobile\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚙\"\n  , \"description\": \"sport utility vehicle\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"blue_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛻\"\n  , \"description\": \"pickup truck\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"pickup_truck\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🚚\"\n  , \"description\": \"delivery truck\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"truck\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚛\"\n  , \"description\": \"articulated lorry\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"articulated_lorry\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚜\"\n  , \"description\": \"tractor\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"tractor\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏎️\"\n  , \"description\": \"racing car\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"racing_car\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏍️\"\n  , \"description\": \"motorcycle\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"motorcycle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛵\"\n  , \"description\": \"motor scooter\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"motor_scooter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🦽\"\n  , \"description\": \"manual wheelchair\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"manual_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🦼\"\n  , \"description\": \"motorized wheelchair\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"motorized_wheelchair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🛺\"\n  , \"description\": \"auto rickshaw\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"auto_rickshaw\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🚲\"\n  , \"description\": \"bicycle\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bike\"\n    ]\n  , \"tags\": [\n      \"bicycle\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛴\"\n  , \"description\": \"kick scooter\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"kick_scooter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🛹\"\n  , \"description\": \"skateboard\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"skateboard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🛼\"\n  , \"description\": \"roller skate\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"roller_skate\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🚏\"\n  , \"description\": \"bus stop\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"busstop\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛣️\"\n  , \"description\": \"motorway\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"motorway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛤️\"\n  , \"description\": \"railway track\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"railway_track\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛢️\"\n  , \"description\": \"oil drum\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"oil_drum\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛽\"\n  , \"description\": \"fuel pump\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"fuelpump\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚨\"\n  , \"description\": \"police car light\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"rotating_light\"\n    ]\n  , \"tags\": [\n      \"911\"\n    , \"emergency\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚥\"\n  , \"description\": \"horizontal traffic light\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"traffic_light\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚦\"\n  , \"description\": \"vertical traffic light\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"vertical_traffic_light\"\n    ]\n  , \"tags\": [\n      \"semaphore\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛑\"\n  , \"description\": \"stop sign\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"stop_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🚧\"\n  , \"description\": \"construction\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"construction\"\n    ]\n  , \"tags\": [\n      \"wip\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚓\"\n  , \"description\": \"anchor\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"anchor\"\n    ]\n  , \"tags\": [\n      \"ship\"\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛵\"\n  , \"description\": \"sailboat\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"boat\"\n    , \"sailboat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛶\"\n  , \"description\": \"canoe\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"canoe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🚤\"\n  , \"description\": \"speedboat\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"speedboat\"\n    ]\n  , \"tags\": [\n      \"ship\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛳️\"\n  , \"description\": \"passenger ship\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"passenger_ship\"\n    ]\n  , \"tags\": [\n      \"cruise\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛴️\"\n  , \"description\": \"ferry\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ferry\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛥️\"\n  , \"description\": \"motor boat\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"motor_boat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🚢\"\n  , \"description\": \"ship\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ship\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✈️\"\n  , \"description\": \"airplane\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"airplane\"\n    ]\n  , \"tags\": [\n      \"flight\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛩️\"\n  , \"description\": \"small airplane\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"small_airplane\"\n    ]\n  , \"tags\": [\n      \"flight\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛫\"\n  , \"description\": \"airplane departure\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"flight_departure\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛬\"\n  , \"description\": \"airplane arrival\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"flight_arrival\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🪂\"\n  , \"description\": \"parachute\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"parachute\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"💺\"\n  , \"description\": \"seat\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"seat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚁\"\n  , \"description\": \"helicopter\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"helicopter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚟\"\n  , \"description\": \"suspension railway\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"suspension_railway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚠\"\n  , \"description\": \"mountain cableway\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mountain_cableway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚡\"\n  , \"description\": \"aerial tramway\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"aerial_tramway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛰️\"\n  , \"description\": \"satellite\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"artificial_satellite\"\n    ]\n  , \"tags\": [\n      \"orbit\"\n    , \"space\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🚀\"\n  , \"description\": \"rocket\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"rocket\"\n    ]\n  , \"tags\": [\n      \"ship\"\n    , \"launch\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛸\"\n  , \"description\": \"flying saucer\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"flying_saucer\"\n    ]\n  , \"tags\": [\n      \"ufo\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🛎️\"\n  , \"description\": \"bellhop bell\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"bellhop_bell\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🧳\"\n  , \"description\": \"luggage\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"luggage\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"⌛\"\n  , \"description\": \"hourglass done\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hourglass\"\n    ]\n  , \"tags\": [\n      \"time\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏳\"\n  , \"description\": \"hourglass not done\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"hourglass_flowing_sand\"\n    ]\n  , \"tags\": [\n      \"time\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⌚\"\n  , \"description\": \"watch\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"watch\"\n    ]\n  , \"tags\": [\n      \"time\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏰\"\n  , \"description\": \"alarm clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"alarm_clock\"\n    ]\n  , \"tags\": [\n      \"morning\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏱️\"\n  , \"description\": \"stopwatch\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"stopwatch\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⏲️\"\n  , \"description\": \"timer clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"timer_clock\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕰️\"\n  , \"description\": \"mantelpiece clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"mantelpiece_clock\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕛\"\n  , \"description\": \"twelve o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock12\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕧\"\n  , \"description\": \"twelve-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock1230\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕐\"\n  , \"description\": \"one o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock1\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕜\"\n  , \"description\": \"one-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock130\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕑\"\n  , \"description\": \"two o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕝\"\n  , \"description\": \"two-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock230\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕒\"\n  , \"description\": \"three o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock3\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕞\"\n  , \"description\": \"three-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock330\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕓\"\n  , \"description\": \"four o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock4\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕟\"\n  , \"description\": \"four-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock430\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕔\"\n  , \"description\": \"five o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock5\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕠\"\n  , \"description\": \"five-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock530\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕕\"\n  , \"description\": \"six o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock6\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕡\"\n  , \"description\": \"six-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock630\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕖\"\n  , \"description\": \"seven o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock7\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕢\"\n  , \"description\": \"seven-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock730\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕗\"\n  , \"description\": \"eight o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock8\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕣\"\n  , \"description\": \"eight-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock830\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕘\"\n  , \"description\": \"nine o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock9\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕤\"\n  , \"description\": \"nine-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock930\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕙\"\n  , \"description\": \"ten o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock10\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕥\"\n  , \"description\": \"ten-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock1030\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕚\"\n  , \"description\": \"eleven o’clock\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock11\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕦\"\n  , \"description\": \"eleven-thirty\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"clock1130\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌑\"\n  , \"description\": \"new moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"new_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌒\"\n  , \"description\": \"waxing crescent moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"waxing_crescent_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌓\"\n  , \"description\": \"first quarter moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"first_quarter_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌔\"\n  , \"description\": \"waxing gibbous moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"moon\"\n    , \"waxing_gibbous_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌕\"\n  , \"description\": \"full moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"full_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌖\"\n  , \"description\": \"waning gibbous moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"waning_gibbous_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌗\"\n  , \"description\": \"last quarter moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"last_quarter_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌘\"\n  , \"description\": \"waning crescent moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"waning_crescent_moon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌙\"\n  , \"description\": \"crescent moon\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"crescent_moon\"\n    ]\n  , \"tags\": [\n      \"night\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌚\"\n  , \"description\": \"new moon face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"new_moon_with_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌛\"\n  , \"description\": \"first quarter moon face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"first_quarter_moon_with_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌜\"\n  , \"description\": \"last quarter moon face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"last_quarter_moon_with_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌡️\"\n  , \"description\": \"thermometer\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"thermometer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☀️\"\n  , \"description\": \"sun\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sunny\"\n    ]\n  , \"tags\": [\n      \"weather\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌝\"\n  , \"description\": \"full moon face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"full_moon_with_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌞\"\n  , \"description\": \"sun with face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sun_with_face\"\n    ]\n  , \"tags\": [\n      \"summer\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪐\"\n  , \"description\": \"ringed planet\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ringed_planet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"⭐\"\n  , \"description\": \"star\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"star\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌟\"\n  , \"description\": \"glowing star\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"star2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌠\"\n  , \"description\": \"shooting star\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"stars\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌌\"\n  , \"description\": \"milky way\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"milky_way\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☁️\"\n  , \"description\": \"cloud\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cloud\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛅\"\n  , \"description\": \"sun behind cloud\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"partly_sunny\"\n    ]\n  , \"tags\": [\n      \"weather\"\n    , \"cloud\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛈️\"\n  , \"description\": \"cloud with lightning and rain\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cloud_with_lightning_and_rain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌤️\"\n  , \"description\": \"sun behind small cloud\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sun_behind_small_cloud\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌥️\"\n  , \"description\": \"sun behind large cloud\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sun_behind_large_cloud\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌦️\"\n  , \"description\": \"sun behind rain cloud\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"sun_behind_rain_cloud\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌧️\"\n  , \"description\": \"cloud with rain\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cloud_with_rain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌨️\"\n  , \"description\": \"cloud with snow\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cloud_with_snow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌩️\"\n  , \"description\": \"cloud with lightning\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cloud_with_lightning\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌪️\"\n  , \"description\": \"tornado\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"tornado\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌫️\"\n  , \"description\": \"fog\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"fog\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌬️\"\n  , \"description\": \"wind face\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"wind_face\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🌀\"\n  , \"description\": \"cyclone\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"cyclone\"\n    ]\n  , \"tags\": [\n      \"swirl\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌈\"\n  , \"description\": \"rainbow\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"rainbow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌂\"\n  , \"description\": \"closed umbrella\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"closed_umbrella\"\n    ]\n  , \"tags\": [\n      \"weather\"\n    , \"rain\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☂️\"\n  , \"description\": \"umbrella\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"open_umbrella\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☔\"\n  , \"description\": \"umbrella with rain drops\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"umbrella\"\n    ]\n  , \"tags\": [\n      \"rain\"\n    , \"weather\"\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛱️\"\n  , \"description\": \"umbrella on ground\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"parasol_on_ground\"\n    ]\n  , \"tags\": [\n      \"beach_umbrella\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⚡\"\n  , \"description\": \"high voltage\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"zap\"\n    ]\n  , \"tags\": [\n      \"lightning\"\n    , \"thunder\"\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❄️\"\n  , \"description\": \"snowflake\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"snowflake\"\n    ]\n  , \"tags\": [\n      \"winter\"\n    , \"cold\"\n    , \"weather\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☃️\"\n  , \"description\": \"snowman\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"snowman_with_snow\"\n    ]\n  , \"tags\": [\n      \"winter\"\n    , \"christmas\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⛄\"\n  , \"description\": \"snowman without snow\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"snowman\"\n    ]\n  , \"tags\": [\n      \"winter\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☄️\"\n  , \"description\": \"comet\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"comet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔥\"\n  , \"description\": \"fire\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"fire\"\n    ]\n  , \"tags\": [\n      \"burn\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💧\"\n  , \"description\": \"droplet\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"droplet\"\n    ]\n  , \"tags\": [\n      \"water\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🌊\"\n  , \"description\": \"water wave\"\n  , \"category\": \"Travel & Places\"\n  , \"aliases\": [\n      \"ocean\"\n    ]\n  , \"tags\": [\n      \"sea\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎃\"\n  , \"description\": \"jack-o-lantern\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"jack_o_lantern\"\n    ]\n  , \"tags\": [\n      \"halloween\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎄\"\n  , \"description\": \"Christmas tree\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"christmas_tree\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎆\"\n  , \"description\": \"fireworks\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"fireworks\"\n    ]\n  , \"tags\": [\n      \"festival\"\n    , \"celebration\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎇\"\n  , \"description\": \"sparkler\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"sparkler\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧨\"\n  , \"description\": \"firecracker\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"firecracker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"✨\"\n  , \"description\": \"sparkles\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"sparkles\"\n    ]\n  , \"tags\": [\n      \"shiny\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎈\"\n  , \"description\": \"balloon\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"balloon\"\n    ]\n  , \"tags\": [\n      \"party\"\n    , \"birthday\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎉\"\n  , \"description\": \"party popper\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"tada\"\n    ]\n  , \"tags\": [\n      \"hooray\"\n    , \"party\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎊\"\n  , \"description\": \"confetti ball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"confetti_ball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎋\"\n  , \"description\": \"tanabata tree\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"tanabata_tree\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎍\"\n  , \"description\": \"pine decoration\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"bamboo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎎\"\n  , \"description\": \"Japanese dolls\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"dolls\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎏\"\n  , \"description\": \"carp streamer\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"flags\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎐\"\n  , \"description\": \"wind chime\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"wind_chime\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎑\"\n  , \"description\": \"moon viewing ceremony\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"rice_scene\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧧\"\n  , \"description\": \"red envelope\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"red_envelope\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎀\"\n  , \"description\": \"ribbon\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ribbon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎁\"\n  , \"description\": \"wrapped gift\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"gift\"\n    ]\n  , \"tags\": [\n      \"present\"\n    , \"birthday\"\n    , \"christmas\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎗️\"\n  , \"description\": \"reminder ribbon\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"reminder_ribbon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎟️\"\n  , \"description\": \"admission tickets\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"tickets\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎫\"\n  , \"description\": \"ticket\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ticket\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎖️\"\n  , \"description\": \"military medal\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"medal_military\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏆\"\n  , \"description\": \"trophy\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"trophy\"\n    ]\n  , \"tags\": [\n      \"award\"\n    , \"contest\"\n    , \"winner\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏅\"\n  , \"description\": \"sports medal\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"medal_sports\"\n    ]\n  , \"tags\": [\n      \"gold\"\n    , \"winner\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🥇\"\n  , \"description\": \"1st place medal\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"1st_place_medal\"\n    ]\n  , \"tags\": [\n      \"gold\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥈\"\n  , \"description\": \"2nd place medal\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"2nd_place_medal\"\n    ]\n  , \"tags\": [\n      \"silver\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥉\"\n  , \"description\": \"3rd place medal\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"3rd_place_medal\"\n    ]\n  , \"tags\": [\n      \"bronze\"\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"⚽\"\n  , \"description\": \"soccer ball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"soccer\"\n    ]\n  , \"tags\": [\n      \"sports\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚾\"\n  , \"description\": \"baseball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"baseball\"\n    ]\n  , \"tags\": [\n      \"sports\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥎\"\n  , \"description\": \"softball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"softball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🏀\"\n  , \"description\": \"basketball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"basketball\"\n    ]\n  , \"tags\": [\n      \"sports\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏐\"\n  , \"description\": \"volleyball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"volleyball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏈\"\n  , \"description\": \"american football\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"football\"\n    ]\n  , \"tags\": [\n      \"sports\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏉\"\n  , \"description\": \"rugby football\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"rugby_football\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎾\"\n  , \"description\": \"tennis\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"tennis\"\n    ]\n  , \"tags\": [\n      \"sports\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥏\"\n  , \"description\": \"flying disc\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"flying_disc\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎳\"\n  , \"description\": \"bowling\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"bowling\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏏\"\n  , \"description\": \"cricket game\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"cricket_game\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏑\"\n  , \"description\": \"field hockey\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"field_hockey\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏒\"\n  , \"description\": \"ice hockey\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ice_hockey\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🥍\"\n  , \"description\": \"lacrosse\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"lacrosse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🏓\"\n  , \"description\": \"ping pong\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ping_pong\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏸\"\n  , \"description\": \"badminton\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"badminton\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🥊\"\n  , \"description\": \"boxing glove\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"boxing_glove\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥋\"\n  , \"description\": \"martial arts uniform\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"martial_arts_uniform\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🥅\"\n  , \"description\": \"goal net\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"goal_net\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"⛳\"\n  , \"description\": \"flag in hole\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"golf\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛸️\"\n  , \"description\": \"ice skate\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ice_skate\"\n    ]\n  , \"tags\": [\n      \"skating\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎣\"\n  , \"description\": \"fishing pole\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"fishing_pole_and_fish\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🤿\"\n  , \"description\": \"diving mask\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"diving_mask\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🎽\"\n  , \"description\": \"running shirt\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"running_shirt_with_sash\"\n    ]\n  , \"tags\": [\n      \"marathon\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎿\"\n  , \"description\": \"skis\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"ski\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛷\"\n  , \"description\": \"sled\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"sled\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥌\"\n  , \"description\": \"curling stone\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"curling_stone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎯\"\n  , \"description\": \"bullseye\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"dart\"\n    ]\n  , \"tags\": [\n      \"target\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪀\"\n  , \"description\": \"yo-yo\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"yo_yo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🪁\"\n  , \"description\": \"kite\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"kite\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🎱\"\n  , \"description\": \"pool 8 ball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"8ball\"\n    ]\n  , \"tags\": [\n      \"pool\"\n    , \"billiards\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔮\"\n  , \"description\": \"crystal ball\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"crystal_ball\"\n    ]\n  , \"tags\": [\n      \"fortune\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪄\"\n  , \"description\": \"magic wand\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"magic_wand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧿\"\n  , \"description\": \"nazar amulet\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"nazar_amulet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎮\"\n  , \"description\": \"video game\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"video_game\"\n    ]\n  , \"tags\": [\n      \"play\"\n    , \"controller\"\n    , \"console\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕹️\"\n  , \"description\": \"joystick\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"joystick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎰\"\n  , \"description\": \"slot machine\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"slot_machine\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎲\"\n  , \"description\": \"game die\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"game_die\"\n    ]\n  , \"tags\": [\n      \"dice\"\n    , \"gambling\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧩\"\n  , \"description\": \"puzzle piece\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"jigsaw\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧸\"\n  , \"description\": \"teddy bear\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"teddy_bear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪅\"\n  , \"description\": \"piñata\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"pinata\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪆\"\n  , \"description\": \"nesting dolls\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"nesting_dolls\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"♠️\"\n  , \"description\": \"spade suit\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"spades\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♥️\"\n  , \"description\": \"heart suit\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"hearts\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♦️\"\n  , \"description\": \"diamond suit\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"diamonds\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♣️\"\n  , \"description\": \"club suit\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"clubs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♟️\"\n  , \"description\": \"chess pawn\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"chess_pawn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🃏\"\n  , \"description\": \"joker\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"black_joker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🀄\"\n  , \"description\": \"mahjong red dragon\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"mahjong\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎴\"\n  , \"description\": \"flower playing cards\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"flower_playing_cards\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎭\"\n  , \"description\": \"performing arts\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"performing_arts\"\n    ]\n  , \"tags\": [\n      \"theater\"\n    , \"drama\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🖼️\"\n  , \"description\": \"framed picture\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"framed_picture\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎨\"\n  , \"description\": \"artist palette\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"art\"\n    ]\n  , \"tags\": [\n      \"design\"\n    , \"paint\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧵\"\n  , \"description\": \"thread\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"thread\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪡\"\n  , \"description\": \"sewing needle\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"sewing_needle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧶\"\n  , \"description\": \"yarn\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"yarn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪢\"\n  , \"description\": \"knot\"\n  , \"category\": \"Activities\"\n  , \"aliases\": [\n      \"knot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"👓\"\n  , \"description\": \"glasses\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"eyeglasses\"\n    ]\n  , \"tags\": [\n      \"glasses\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕶️\"\n  , \"description\": \"sunglasses\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dark_sunglasses\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🥽\"\n  , \"description\": \"goggles\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"goggles\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥼\"\n  , \"description\": \"lab coat\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"lab_coat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🦺\"\n  , \"description\": \"safety vest\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"safety_vest\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"👔\"\n  , \"description\": \"necktie\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"necktie\"\n    ]\n  , \"tags\": [\n      \"shirt\"\n    , \"formal\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👕\"\n  , \"description\": \"t-shirt\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shirt\"\n    , \"tshirt\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👖\"\n  , \"description\": \"jeans\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"jeans\"\n    ]\n  , \"tags\": [\n      \"pants\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧣\"\n  , \"description\": \"scarf\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"scarf\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧤\"\n  , \"description\": \"gloves\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"gloves\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧥\"\n  , \"description\": \"coat\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"coat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧦\"\n  , \"description\": \"socks\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"socks\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"👗\"\n  , \"description\": \"dress\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dress\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👘\"\n  , \"description\": \"kimono\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"kimono\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥻\"\n  , \"description\": \"sari\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"sari\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🩱\"\n  , \"description\": \"one-piece swimsuit\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"one_piece_swimsuit\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🩲\"\n  , \"description\": \"briefs\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"swim_brief\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🩳\"\n  , \"description\": \"shorts\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shorts\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"👙\"\n  , \"description\": \"bikini\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bikini\"\n    ]\n  , \"tags\": [\n      \"beach\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👚\"\n  , \"description\": \"woman’s clothes\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"womans_clothes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👛\"\n  , \"description\": \"purse\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"purse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👜\"\n  , \"description\": \"handbag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"handbag\"\n    ]\n  , \"tags\": [\n      \"bag\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👝\"\n  , \"description\": \"clutch bag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pouch\"\n    ]\n  , \"tags\": [\n      \"bag\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛍️\"\n  , \"description\": \"shopping bags\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shopping\"\n    ]\n  , \"tags\": [\n      \"bags\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎒\"\n  , \"description\": \"backpack\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"school_satchel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🩴\"\n  , \"description\": \"thong sandal\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"thong_sandal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"👞\"\n  , \"description\": \"man’s shoe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mans_shoe\"\n    , \"shoe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👟\"\n  , \"description\": \"running shoe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"athletic_shoe\"\n    ]\n  , \"tags\": [\n      \"sneaker\"\n    , \"sport\"\n    , \"running\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🥾\"\n  , \"description\": \"hiking boot\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"hiking_boot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🥿\"\n  , \"description\": \"flat shoe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"flat_shoe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"👠\"\n  , \"description\": \"high-heeled shoe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"high_heel\"\n    ]\n  , \"tags\": [\n      \"shoe\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👡\"\n  , \"description\": \"woman’s sandal\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"sandal\"\n    ]\n  , \"tags\": [\n      \"shoe\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🩰\"\n  , \"description\": \"ballet shoes\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"ballet_shoes\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"👢\"\n  , \"description\": \"woman’s boot\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"boot\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👑\"\n  , \"description\": \"crown\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"crown\"\n    ]\n  , \"tags\": [\n      \"king\"\n    , \"queen\"\n    , \"royal\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"👒\"\n  , \"description\": \"woman’s hat\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"womans_hat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎩\"\n  , \"description\": \"top hat\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"tophat\"\n    ]\n  , \"tags\": [\n      \"hat\"\n    , \"classy\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎓\"\n  , \"description\": \"graduation cap\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mortar_board\"\n    ]\n  , \"tags\": [\n      \"education\"\n    , \"college\"\n    , \"university\"\n    , \"graduation\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧢\"\n  , \"description\": \"billed cap\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"billed_cap\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪖\"\n  , \"description\": \"military helmet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"military_helmet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"⛑️\"\n  , \"description\": \"rescue worker’s helmet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"rescue_worker_helmet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📿\"\n  , \"description\": \"prayer beads\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"prayer_beads\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💄\"\n  , \"description\": \"lipstick\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"lipstick\"\n    ]\n  , \"tags\": [\n      \"makeup\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💍\"\n  , \"description\": \"ring\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"ring\"\n    ]\n  , \"tags\": [\n      \"wedding\"\n    , \"marriage\"\n    , \"engaged\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💎\"\n  , \"description\": \"gem stone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"gem\"\n    ]\n  , \"tags\": [\n      \"diamond\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔇\"\n  , \"description\": \"muted speaker\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mute\"\n    ]\n  , \"tags\": [\n      \"sound\"\n    , \"volume\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔈\"\n  , \"description\": \"speaker low volume\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"speaker\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔉\"\n  , \"description\": \"speaker medium volume\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"sound\"\n    ]\n  , \"tags\": [\n      \"volume\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔊\"\n  , \"description\": \"speaker high volume\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"loud_sound\"\n    ]\n  , \"tags\": [\n      \"volume\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📢\"\n  , \"description\": \"loudspeaker\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"loudspeaker\"\n    ]\n  , \"tags\": [\n      \"announcement\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📣\"\n  , \"description\": \"megaphone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mega\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📯\"\n  , \"description\": \"postal horn\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"postal_horn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔔\"\n  , \"description\": \"bell\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bell\"\n    ]\n  , \"tags\": [\n      \"sound\"\n    , \"notification\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔕\"\n  , \"description\": \"bell with slash\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"no_bell\"\n    ]\n  , \"tags\": [\n      \"volume\"\n    , \"off\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎼\"\n  , \"description\": \"musical score\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"musical_score\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎵\"\n  , \"description\": \"musical note\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"musical_note\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎶\"\n  , \"description\": \"musical notes\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"notes\"\n    ]\n  , \"tags\": [\n      \"music\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎙️\"\n  , \"description\": \"studio microphone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"studio_microphone\"\n    ]\n  , \"tags\": [\n      \"podcast\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎚️\"\n  , \"description\": \"level slider\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"level_slider\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎛️\"\n  , \"description\": \"control knobs\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"control_knobs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎤\"\n  , \"description\": \"microphone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"microphone\"\n    ]\n  , \"tags\": [\n      \"sing\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎧\"\n  , \"description\": \"headphone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"headphones\"\n    ]\n  , \"tags\": [\n      \"music\"\n    , \"earphones\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📻\"\n  , \"description\": \"radio\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"radio\"\n    ]\n  , \"tags\": [\n      \"podcast\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎷\"\n  , \"description\": \"saxophone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"saxophone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪗\"\n  , \"description\": \"accordion\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"accordion\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🎸\"\n  , \"description\": \"guitar\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"guitar\"\n    ]\n  , \"tags\": [\n      \"rock\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎹\"\n  , \"description\": \"musical keyboard\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"musical_keyboard\"\n    ]\n  , \"tags\": [\n      \"piano\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎺\"\n  , \"description\": \"trumpet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"trumpet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎻\"\n  , \"description\": \"violin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"violin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪕\"\n  , \"description\": \"banjo\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"banjo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🥁\"\n  , \"description\": \"drum\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"drum\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🪘\"\n  , \"description\": \"long drum\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"long_drum\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"📱\"\n  , \"description\": \"mobile phone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"iphone\"\n    ]\n  , \"tags\": [\n      \"smartphone\"\n    , \"mobile\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📲\"\n  , \"description\": \"mobile phone with arrow\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"calling\"\n    ]\n  , \"tags\": [\n      \"call\"\n    , \"incoming\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☎️\"\n  , \"description\": \"telephone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"phone\"\n    , \"telephone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📞\"\n  , \"description\": \"telephone receiver\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"telephone_receiver\"\n    ]\n  , \"tags\": [\n      \"phone\"\n    , \"call\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📟\"\n  , \"description\": \"pager\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pager\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📠\"\n  , \"description\": \"fax machine\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"fax\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔋\"\n  , \"description\": \"battery\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"battery\"\n    ]\n  , \"tags\": [\n      \"power\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔌\"\n  , \"description\": \"electric plug\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"electric_plug\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💻\"\n  , \"description\": \"laptop\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"computer\"\n    ]\n  , \"tags\": [\n      \"desktop\"\n    , \"screen\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🖥️\"\n  , \"description\": \"desktop computer\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"desktop_computer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖨️\"\n  , \"description\": \"printer\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"printer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⌨️\"\n  , \"description\": \"keyboard\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"keyboard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖱️\"\n  , \"description\": \"computer mouse\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"computer_mouse\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖲️\"\n  , \"description\": \"trackball\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"trackball\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💽\"\n  , \"description\": \"computer disk\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"minidisc\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💾\"\n  , \"description\": \"floppy disk\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"floppy_disk\"\n    ]\n  , \"tags\": [\n      \"save\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💿\"\n  , \"description\": \"optical disk\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"cd\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📀\"\n  , \"description\": \"dvd\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dvd\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧮\"\n  , \"description\": \"abacus\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"abacus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎥\"\n  , \"description\": \"movie camera\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"movie_camera\"\n    ]\n  , \"tags\": [\n      \"film\"\n    , \"video\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎞️\"\n  , \"description\": \"film frames\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"film_strip\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📽️\"\n  , \"description\": \"film projector\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"film_projector\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🎬\"\n  , \"description\": \"clapper board\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"clapper\"\n    ]\n  , \"tags\": [\n      \"film\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📺\"\n  , \"description\": \"television\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"tv\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📷\"\n  , \"description\": \"camera\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"camera\"\n    ]\n  , \"tags\": [\n      \"photo\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📸\"\n  , \"description\": \"camera with flash\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"camera_flash\"\n    ]\n  , \"tags\": [\n      \"photo\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📹\"\n  , \"description\": \"video camera\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"video_camera\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📼\"\n  , \"description\": \"videocassette\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"vhs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔍\"\n  , \"description\": \"magnifying glass tilted left\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mag\"\n    ]\n  , \"tags\": [\n      \"search\"\n    , \"zoom\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔎\"\n  , \"description\": \"magnifying glass tilted right\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mag_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🕯️\"\n  , \"description\": \"candle\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"candle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💡\"\n  , \"description\": \"light bulb\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bulb\"\n    ]\n  , \"tags\": [\n      \"idea\"\n    , \"light\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔦\"\n  , \"description\": \"flashlight\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"flashlight\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏮\"\n  , \"description\": \"red paper lantern\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"izakaya_lantern\"\n    , \"lantern\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪔\"\n  , \"description\": \"diya lamp\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"diya_lamp\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"📔\"\n  , \"description\": \"notebook with decorative cover\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"notebook_with_decorative_cover\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📕\"\n  , \"description\": \"closed book\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"closed_book\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📖\"\n  , \"description\": \"open book\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"book\"\n    , \"open_book\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📗\"\n  , \"description\": \"green book\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"green_book\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📘\"\n  , \"description\": \"blue book\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"blue_book\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📙\"\n  , \"description\": \"orange book\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"orange_book\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📚\"\n  , \"description\": \"books\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"books\"\n    ]\n  , \"tags\": [\n      \"library\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📓\"\n  , \"description\": \"notebook\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"notebook\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📒\"\n  , \"description\": \"ledger\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"ledger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📃\"\n  , \"description\": \"page with curl\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"page_with_curl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📜\"\n  , \"description\": \"scroll\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"scroll\"\n    ]\n  , \"tags\": [\n      \"document\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📄\"\n  , \"description\": \"page facing up\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"page_facing_up\"\n    ]\n  , \"tags\": [\n      \"document\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📰\"\n  , \"description\": \"newspaper\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"newspaper\"\n    ]\n  , \"tags\": [\n      \"press\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗞️\"\n  , \"description\": \"rolled-up newspaper\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"newspaper_roll\"\n    ]\n  , \"tags\": [\n      \"press\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📑\"\n  , \"description\": \"bookmark tabs\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bookmark_tabs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔖\"\n  , \"description\": \"bookmark\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bookmark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏷️\"\n  , \"description\": \"label\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"label\"\n    ]\n  , \"tags\": [\n      \"tag\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"💰\"\n  , \"description\": \"money bag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"moneybag\"\n    ]\n  , \"tags\": [\n      \"dollar\"\n    , \"cream\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪙\"\n  , \"description\": \"coin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"coin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"💴\"\n  , \"description\": \"yen banknote\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"yen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💵\"\n  , \"description\": \"dollar banknote\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dollar\"\n    ]\n  , \"tags\": [\n      \"money\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💶\"\n  , \"description\": \"euro banknote\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"euro\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💷\"\n  , \"description\": \"pound banknote\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pound\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💸\"\n  , \"description\": \"money with wings\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"money_with_wings\"\n    ]\n  , \"tags\": [\n      \"dollar\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💳\"\n  , \"description\": \"credit card\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"credit_card\"\n    ]\n  , \"tags\": [\n      \"subscription\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🧾\"\n  , \"description\": \"receipt\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"receipt\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"💹\"\n  , \"description\": \"chart increasing with yen\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"chart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✉️\"\n  , \"description\": \"envelope\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"envelope\"\n    ]\n  , \"tags\": [\n      \"letter\"\n    , \"email\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📧\"\n  , \"description\": \"e-mail\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"email\"\n    , \"e-mail\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📨\"\n  , \"description\": \"incoming envelope\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"incoming_envelope\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📩\"\n  , \"description\": \"envelope with arrow\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"envelope_with_arrow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📤\"\n  , \"description\": \"outbox tray\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"outbox_tray\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📥\"\n  , \"description\": \"inbox tray\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"inbox_tray\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📦\"\n  , \"description\": \"package\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"package\"\n    ]\n  , \"tags\": [\n      \"shipping\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📫\"\n  , \"description\": \"closed mailbox with raised flag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mailbox\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📪\"\n  , \"description\": \"closed mailbox with lowered flag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mailbox_closed\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📬\"\n  , \"description\": \"open mailbox with raised flag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mailbox_with_mail\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📭\"\n  , \"description\": \"open mailbox with lowered flag\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mailbox_with_no_mail\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📮\"\n  , \"description\": \"postbox\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"postbox\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗳️\"\n  , \"description\": \"ballot box with ballot\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"ballot_box\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"✏️\"\n  , \"description\": \"pencil\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pencil2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✒️\"\n  , \"description\": \"black nib\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"black_nib\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🖋️\"\n  , \"description\": \"fountain pen\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"fountain_pen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖊️\"\n  , \"description\": \"pen\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖌️\"\n  , \"description\": \"paintbrush\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"paintbrush\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🖍️\"\n  , \"description\": \"crayon\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"crayon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📝\"\n  , \"description\": \"memo\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"memo\"\n    , \"pencil\"\n    ]\n  , \"tags\": [\n      \"document\"\n    , \"note\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💼\"\n  , \"description\": \"briefcase\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"briefcase\"\n    ]\n  , \"tags\": [\n      \"business\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📁\"\n  , \"description\": \"file folder\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"file_folder\"\n    ]\n  , \"tags\": [\n      \"directory\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📂\"\n  , \"description\": \"open file folder\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"open_file_folder\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗂️\"\n  , \"description\": \"card index dividers\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"card_index_dividers\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📅\"\n  , \"description\": \"calendar\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"date\"\n    ]\n  , \"tags\": [\n      \"calendar\"\n    , \"schedule\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📆\"\n  , \"description\": \"tear-off calendar\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"calendar\"\n    ]\n  , \"tags\": [\n      \"schedule\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗒️\"\n  , \"description\": \"spiral notepad\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"spiral_notepad\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗓️\"\n  , \"description\": \"spiral calendar\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"spiral_calendar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📇\"\n  , \"description\": \"card index\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"card_index\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📈\"\n  , \"description\": \"chart increasing\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"chart_with_upwards_trend\"\n    ]\n  , \"tags\": [\n      \"graph\"\n    , \"metrics\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📉\"\n  , \"description\": \"chart decreasing\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"chart_with_downwards_trend\"\n    ]\n  , \"tags\": [\n      \"graph\"\n    , \"metrics\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📊\"\n  , \"description\": \"bar chart\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bar_chart\"\n    ]\n  , \"tags\": [\n      \"stats\"\n    , \"metrics\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📋\"\n  , \"description\": \"clipboard\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"clipboard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📌\"\n  , \"description\": \"pushpin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pushpin\"\n    ]\n  , \"tags\": [\n      \"location\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📍\"\n  , \"description\": \"round pushpin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"round_pushpin\"\n    ]\n  , \"tags\": [\n      \"location\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📎\"\n  , \"description\": \"paperclip\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"paperclip\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🖇️\"\n  , \"description\": \"linked paperclips\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"paperclips\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"📏\"\n  , \"description\": \"straight ruler\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"straight_ruler\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📐\"\n  , \"description\": \"triangular ruler\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"triangular_ruler\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✂️\"\n  , \"description\": \"scissors\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"scissors\"\n    ]\n  , \"tags\": [\n      \"cut\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗃️\"\n  , \"description\": \"card file box\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"card_file_box\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗄️\"\n  , \"description\": \"file cabinet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"file_cabinet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗑️\"\n  , \"description\": \"wastebasket\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"wastebasket\"\n    ]\n  , \"tags\": [\n      \"trash\"\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔒\"\n  , \"description\": \"locked\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"lock\"\n    ]\n  , \"tags\": [\n      \"security\"\n    , \"private\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔓\"\n  , \"description\": \"unlocked\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"unlock\"\n    ]\n  , \"tags\": [\n      \"security\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔏\"\n  , \"description\": \"locked with pen\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"lock_with_ink_pen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔐\"\n  , \"description\": \"locked with key\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"closed_lock_with_key\"\n    ]\n  , \"tags\": [\n      \"security\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔑\"\n  , \"description\": \"key\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"key\"\n    ]\n  , \"tags\": [\n      \"lock\"\n    , \"password\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🗝️\"\n  , \"description\": \"old key\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"old_key\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔨\"\n  , \"description\": \"hammer\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"hammer\"\n    ]\n  , \"tags\": [\n      \"tool\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪓\"\n  , \"description\": \"axe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"axe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"⛏️\"\n  , \"description\": \"pick\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⚒️\"\n  , \"description\": \"hammer and pick\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"hammer_and_pick\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛠️\"\n  , \"description\": \"hammer and wrench\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"hammer_and_wrench\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗡️\"\n  , \"description\": \"dagger\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dagger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⚔️\"\n  , \"description\": \"crossed swords\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"crossed_swords\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔫\"\n  , \"description\": \"water pistol\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"gun\"\n    ]\n  , \"tags\": [\n      \"shoot\"\n    , \"weapon\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪃\"\n  , \"description\": \"boomerang\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"boomerang\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🏹\"\n  , \"description\": \"bow and arrow\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bow_and_arrow\"\n    ]\n  , \"tags\": [\n      \"archery\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛡️\"\n  , \"description\": \"shield\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shield\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🪚\"\n  , \"description\": \"carpentry saw\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"carpentry_saw\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🔧\"\n  , \"description\": \"wrench\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"wrench\"\n    ]\n  , \"tags\": [\n      \"tool\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪛\"\n  , \"description\": \"screwdriver\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"screwdriver\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🔩\"\n  , \"description\": \"nut and bolt\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"nut_and_bolt\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚙️\"\n  , \"description\": \"gear\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"gear\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗜️\"\n  , \"description\": \"clamp\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"clamp\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⚖️\"\n  , \"description\": \"balance scale\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"balance_scale\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🦯\"\n  , \"description\": \"white cane\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"probing_cane\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🔗\"\n  , \"description\": \"link\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"link\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛓️\"\n  , \"description\": \"chains\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"chains\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🪝\"\n  , \"description\": \"hook\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"hook\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧰\"\n  , \"description\": \"toolbox\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"toolbox\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧲\"\n  , \"description\": \"magnet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"magnet\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪜\"\n  , \"description\": \"ladder\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"ladder\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"⚗️\"\n  , \"description\": \"alembic\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"alembic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🧪\"\n  , \"description\": \"test tube\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"test_tube\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧫\"\n  , \"description\": \"petri dish\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"petri_dish\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧬\"\n  , \"description\": \"dna\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"dna\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🔬\"\n  , \"description\": \"microscope\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"microscope\"\n    ]\n  , \"tags\": [\n      \"science\"\n    , \"laboratory\"\n    , \"investigate\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔭\"\n  , \"description\": \"telescope\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"telescope\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📡\"\n  , \"description\": \"satellite antenna\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"satellite\"\n    ]\n  , \"tags\": [\n      \"signal\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💉\"\n  , \"description\": \"syringe\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"syringe\"\n    ]\n  , \"tags\": [\n      \"health\"\n    , \"hospital\"\n    , \"needle\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🩸\"\n  , \"description\": \"drop of blood\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"drop_of_blood\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"💊\"\n  , \"description\": \"pill\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"pill\"\n    ]\n  , \"tags\": [\n      \"health\"\n    , \"medicine\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🩹\"\n  , \"description\": \"adhesive bandage\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"adhesive_bandage\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🩺\"\n  , \"description\": \"stethoscope\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"stethoscope\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🚪\"\n  , \"description\": \"door\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"door\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛗\"\n  , \"description\": \"elevator\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"elevator\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪞\"\n  , \"description\": \"mirror\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mirror\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪟\"\n  , \"description\": \"window\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"window\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🛏️\"\n  , \"description\": \"bed\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bed\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🛋️\"\n  , \"description\": \"couch and lamp\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"couch_and_lamp\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🪑\"\n  , \"description\": \"chair\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"chair\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🚽\"\n  , \"description\": \"toilet\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"toilet\"\n    ]\n  , \"tags\": [\n      \"wc\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪠\"\n  , \"description\": \"plunger\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"plunger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🚿\"\n  , \"description\": \"shower\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shower\"\n    ]\n  , \"tags\": [\n      \"bath\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛁\"\n  , \"description\": \"bathtub\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bathtub\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪤\"\n  , \"description\": \"mouse trap\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"mouse_trap\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🪒\"\n  , \"description\": \"razor\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"razor\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🧴\"\n  , \"description\": \"lotion bottle\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"lotion_bottle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧷\"\n  , \"description\": \"safety pin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"safety_pin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧹\"\n  , \"description\": \"broom\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"broom\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧺\"\n  , \"description\": \"basket\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"basket\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧻\"\n  , \"description\": \"roll of paper\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"roll_of_paper\"\n    ]\n  , \"tags\": [\n      \"toilet\"\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪣\"\n  , \"description\": \"bucket\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"bucket\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧼\"\n  , \"description\": \"soap\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"soap\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🪥\"\n  , \"description\": \"toothbrush\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"toothbrush\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🧽\"\n  , \"description\": \"sponge\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"sponge\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🧯\"\n  , \"description\": \"fire extinguisher\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"fire_extinguisher\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🛒\"\n  , \"description\": \"shopping cart\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"shopping_cart\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"9.0\"\n  , \"ios_version\": \"10.2\"\n  }\n, {\n    \"emoji\": \"🚬\"\n  , \"description\": \"cigarette\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"smoking\"\n    ]\n  , \"tags\": [\n      \"cigarette\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚰️\"\n  , \"description\": \"coffin\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"coffin\"\n    ]\n  , \"tags\": [\n      \"funeral\"\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🪦\"\n  , \"description\": \"headstone\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"headstone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"⚱️\"\n  , \"description\": \"funeral urn\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"funeral_urn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🗿\"\n  , \"description\": \"moai\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"moyai\"\n    ]\n  , \"tags\": [\n      \"stone\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🪧\"\n  , \"description\": \"placard\"\n  , \"category\": \"Objects\"\n  , \"aliases\": [\n      \"placard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🏧\"\n  , \"description\": \"ATM sign\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"atm\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚮\"\n  , \"description\": \"litter in bin sign\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"put_litter_in_its_place\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚰\"\n  , \"description\": \"potable water\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"potable_water\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♿\"\n  , \"description\": \"wheelchair symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"wheelchair\"\n    ]\n  , \"tags\": [\n      \"accessibility\"\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚹\"\n  , \"description\": \"men’s room\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"mens\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚺\"\n  , \"description\": \"women’s room\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"womens\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚻\"\n  , \"description\": \"restroom\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"restroom\"\n    ]\n  , \"tags\": [\n      \"toilet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚼\"\n  , \"description\": \"baby symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"baby_symbol\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚾\"\n  , \"description\": \"water closet\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"wc\"\n    ]\n  , \"tags\": [\n      \"toilet\"\n    , \"restroom\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛂\"\n  , \"description\": \"passport control\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"passport_control\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛃\"\n  , \"description\": \"customs\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"customs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛄\"\n  , \"description\": \"baggage claim\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"baggage_claim\"\n    ]\n  , \"tags\": [\n      \"airport\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛅\"\n  , \"description\": \"left luggage\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"left_luggage\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚠️\"\n  , \"description\": \"warning\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"warning\"\n    ]\n  , \"tags\": [\n      \"wip\"\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚸\"\n  , \"description\": \"children crossing\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"children_crossing\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛔\"\n  , \"description\": \"no entry\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_entry\"\n    ]\n  , \"tags\": [\n      \"limit\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚫\"\n  , \"description\": \"prohibited\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_entry_sign\"\n    ]\n  , \"tags\": [\n      \"block\"\n    , \"forbidden\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚳\"\n  , \"description\": \"no bicycles\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_bicycles\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚭\"\n  , \"description\": \"no smoking\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_smoking\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚯\"\n  , \"description\": \"no littering\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"do_not_litter\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚱\"\n  , \"description\": \"non-potable water\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"non-potable_water\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚷\"\n  , \"description\": \"no pedestrians\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_pedestrians\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📵\"\n  , \"description\": \"no mobile phones\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"no_mobile_phones\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔞\"\n  , \"description\": \"no one under eighteen\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"underage\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☢️\"\n  , \"description\": \"radioactive\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"radioactive\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☣️\"\n  , \"description\": \"biohazard\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"biohazard\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⬆️\"\n  , \"description\": \"up arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_up\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↗️\"\n  , \"description\": \"up-right arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_upper_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➡️\"\n  , \"description\": \"right arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↘️\"\n  , \"description\": \"down-right arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_lower_right\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⬇️\"\n  , \"description\": \"down arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↙️\"\n  , \"description\": \"down-left arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_lower_left\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⬅️\"\n  , \"description\": \"left arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_left\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↖️\"\n  , \"description\": \"up-left arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_upper_left\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↕️\"\n  , \"description\": \"up-down arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_up_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↔️\"\n  , \"description\": \"left-right arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"left_right_arrow\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↩️\"\n  , \"description\": \"right arrow curving left\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"leftwards_arrow_with_hook\"\n    ]\n  , \"tags\": [\n      \"return\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"↪️\"\n  , \"description\": \"left arrow curving right\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_right_hook\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⤴️\"\n  , \"description\": \"right arrow curving up\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_heading_up\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⤵️\"\n  , \"description\": \"right arrow curving down\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_heading_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔃\"\n  , \"description\": \"clockwise vertical arrows\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrows_clockwise\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔄\"\n  , \"description\": \"counterclockwise arrows button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrows_counterclockwise\"\n    ]\n  , \"tags\": [\n      \"sync\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔙\"\n  , \"description\": \"BACK arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"back\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔚\"\n  , \"description\": \"END arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"end\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔛\"\n  , \"description\": \"ON! arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"on\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔜\"\n  , \"description\": \"SOON arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"soon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔝\"\n  , \"description\": \"TOP arrow\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"top\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🛐\"\n  , \"description\": \"place of worship\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"place_of_worship\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⚛️\"\n  , \"description\": \"atom symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"atom_symbol\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕉️\"\n  , \"description\": \"om\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"om\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"✡️\"\n  , \"description\": \"star of David\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"star_of_david\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☸️\"\n  , \"description\": \"wheel of dharma\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"wheel_of_dharma\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☯️\"\n  , \"description\": \"yin yang\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"yin_yang\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"✝️\"\n  , \"description\": \"latin cross\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"latin_cross\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☦️\"\n  , \"description\": \"orthodox cross\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"orthodox_cross\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☪️\"\n  , \"description\": \"star and crescent\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"star_and_crescent\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"☮️\"\n  , \"description\": \"peace symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"peace_symbol\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🕎\"\n  , \"description\": \"menorah\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"menorah\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔯\"\n  , \"description\": \"dotted six-pointed star\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"six_pointed_star\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♈\"\n  , \"description\": \"Aries\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"aries\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♉\"\n  , \"description\": \"Taurus\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"taurus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♊\"\n  , \"description\": \"Gemini\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"gemini\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♋\"\n  , \"description\": \"Cancer\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"cancer\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♌\"\n  , \"description\": \"Leo\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"leo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♍\"\n  , \"description\": \"Virgo\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"virgo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♎\"\n  , \"description\": \"Libra\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"libra\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♏\"\n  , \"description\": \"Scorpio\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"scorpius\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♐\"\n  , \"description\": \"Sagittarius\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"sagittarius\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♑\"\n  , \"description\": \"Capricorn\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"capricorn\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♒\"\n  , \"description\": \"Aquarius\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"aquarius\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♓\"\n  , \"description\": \"Pisces\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"pisces\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⛎\"\n  , \"description\": \"Ophiuchus\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ophiuchus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔀\"\n  , \"description\": \"shuffle tracks button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"twisted_rightwards_arrows\"\n    ]\n  , \"tags\": [\n      \"shuffle\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔁\"\n  , \"description\": \"repeat button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"repeat\"\n    ]\n  , \"tags\": [\n      \"loop\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔂\"\n  , \"description\": \"repeat single button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"repeat_one\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"▶️\"\n  , \"description\": \"play button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_forward\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏩\"\n  , \"description\": \"fast-forward button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"fast_forward\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏭️\"\n  , \"description\": \"next track button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"next_track_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⏯️\"\n  , \"description\": \"play or pause button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"play_or_pause_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"◀️\"\n  , \"description\": \"reverse button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_backward\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏪\"\n  , \"description\": \"fast reverse button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"rewind\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏮️\"\n  , \"description\": \"last track button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"previous_track_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔼\"\n  , \"description\": \"upwards button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_up_small\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏫\"\n  , \"description\": \"fast up button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_double_up\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔽\"\n  , \"description\": \"downwards button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_down_small\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏬\"\n  , \"description\": \"fast down button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"arrow_double_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⏸️\"\n  , \"description\": \"pause button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"pause_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⏹️\"\n  , \"description\": \"stop button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"stop_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⏺️\"\n  , \"description\": \"record button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"record_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"⏏️\"\n  , \"description\": \"eject button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"eject_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🎦\"\n  , \"description\": \"cinema\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"cinema\"\n    ]\n  , \"tags\": [\n      \"film\"\n    , \"movie\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔅\"\n  , \"description\": \"dim button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"low_brightness\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔆\"\n  , \"description\": \"bright button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"high_brightness\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📶\"\n  , \"description\": \"antenna bars\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"signal_strength\"\n    ]\n  , \"tags\": [\n      \"wifi\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📳\"\n  , \"description\": \"vibration mode\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"vibration_mode\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📴\"\n  , \"description\": \"mobile phone off\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"mobile_phone_off\"\n    ]\n  , \"tags\": [\n      \"mute\"\n    , \"off\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♀️\"\n  , \"description\": \"female sign\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"female_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"♂️\"\n  , \"description\": \"male sign\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"male_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"⚧️\"\n  , \"description\": \"transgender symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"transgender_symbol\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"✖️\"\n  , \"description\": \"multiply\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_multiplication_x\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➕\"\n  , \"description\": \"plus\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_plus_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➖\"\n  , \"description\": \"minus\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_minus_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➗\"\n  , \"description\": \"divide\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_division_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"♾️\"\n  , \"description\": \"infinity\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"infinity\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"‼️\"\n  , \"description\": \"double exclamation mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"bangbang\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⁉️\"\n  , \"description\": \"exclamation question mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"interrobang\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❓\"\n  , \"description\": \"red question mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"question\"\n    ]\n  , \"tags\": [\n      \"confused\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❔\"\n  , \"description\": \"white question mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"grey_question\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❕\"\n  , \"description\": \"white exclamation mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"grey_exclamation\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❗\"\n  , \"description\": \"red exclamation mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"exclamation\"\n    , \"heavy_exclamation_mark\"\n    ]\n  , \"tags\": [\n      \"bang\"\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"〰️\"\n  , \"description\": \"wavy dash\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"wavy_dash\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💱\"\n  , \"description\": \"currency exchange\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"currency_exchange\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💲\"\n  , \"description\": \"heavy dollar sign\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_dollar_sign\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚕️\"\n  , \"description\": \"medical symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"medical_symbol\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"♻️\"\n  , \"description\": \"recycling symbol\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"recycle\"\n    ]\n  , \"tags\": [\n      \"environment\"\n    , \"green\"\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚜️\"\n  , \"description\": \"fleur-de-lis\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"fleur_de_lis\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🔱\"\n  , \"description\": \"trident emblem\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"trident\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"📛\"\n  , \"description\": \"name badge\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"name_badge\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔰\"\n  , \"description\": \"Japanese symbol for beginner\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"beginner\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⭕\"\n  , \"description\": \"hollow red circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"o\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✅\"\n  , \"description\": \"check mark button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_check_mark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"☑️\"\n  , \"description\": \"check box with check\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ballot_box_with_check\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✔️\"\n  , \"description\": \"check mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"heavy_check_mark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❌\"\n  , \"description\": \"cross mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"x\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❎\"\n  , \"description\": \"cross mark button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"negative_squared_cross_mark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➰\"\n  , \"description\": \"curly loop\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"curly_loop\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"➿\"\n  , \"description\": \"double curly loop\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"loop\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"〽️\"\n  , \"description\": \"part alternation mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"part_alternation_mark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✳️\"\n  , \"description\": \"eight-spoked asterisk\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"eight_spoked_asterisk\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"✴️\"\n  , \"description\": \"eight-pointed star\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"eight_pointed_black_star\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"❇️\"\n  , \"description\": \"sparkle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"sparkle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"©️\"\n  , \"description\": \"copyright\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"copyright\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"®️\"\n  , \"description\": \"registered\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"registered\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"™️\"\n  , \"description\": \"trade mark\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"tm\"\n    ]\n  , \"tags\": [\n      \"trademark\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"#️⃣\"\n  , \"description\": \"keycap: #\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"hash\"\n    ]\n  , \"tags\": [\n      \"number\"\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"*️⃣\"\n  , \"description\": \"keycap: *\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"asterisk\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"0️⃣\"\n  , \"description\": \"keycap: 0\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"zero\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"1️⃣\"\n  , \"description\": \"keycap: 1\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"one\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"2️⃣\"\n  , \"description\": \"keycap: 2\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"two\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"3️⃣\"\n  , \"description\": \"keycap: 3\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"three\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"4️⃣\"\n  , \"description\": \"keycap: 4\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"four\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"5️⃣\"\n  , \"description\": \"keycap: 5\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"five\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"6️⃣\"\n  , \"description\": \"keycap: 6\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"six\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"7️⃣\"\n  , \"description\": \"keycap: 7\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"seven\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"8️⃣\"\n  , \"description\": \"keycap: 8\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"eight\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"9️⃣\"\n  , \"description\": \"keycap: 9\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"nine\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔟\"\n  , \"description\": \"keycap: 10\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"keycap_ten\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔠\"\n  , \"description\": \"input latin uppercase\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"capital_abcd\"\n    ]\n  , \"tags\": [\n      \"letters\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔡\"\n  , \"description\": \"input latin lowercase\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"abcd\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔢\"\n  , \"description\": \"input numbers\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"1234\"\n    ]\n  , \"tags\": [\n      \"numbers\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔣\"\n  , \"description\": \"input symbols\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"symbols\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔤\"\n  , \"description\": \"input latin letters\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"abc\"\n    ]\n  , \"tags\": [\n      \"alphabet\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🅰️\"\n  , \"description\": \"A button (blood type)\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"a\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆎\"\n  , \"description\": \"AB button (blood type)\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ab\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🅱️\"\n  , \"description\": \"B button (blood type)\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"b\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆑\"\n  , \"description\": \"CL button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"cl\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆒\"\n  , \"description\": \"COOL button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"cool\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆓\"\n  , \"description\": \"FREE button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"free\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"ℹ️\"\n  , \"description\": \"information\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"information_source\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆔\"\n  , \"description\": \"ID button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"id\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"Ⓜ️\"\n  , \"description\": \"circled M\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"m\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆕\"\n  , \"description\": \"NEW button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"new\"\n    ]\n  , \"tags\": [\n      \"fresh\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆖\"\n  , \"description\": \"NG button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ng\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🅾️\"\n  , \"description\": \"O button (blood type)\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"o2\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆗\"\n  , \"description\": \"OK button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ok\"\n    ]\n  , \"tags\": [\n      \"yes\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🅿️\"\n  , \"description\": \"P button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"parking\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆘\"\n  , \"description\": \"SOS button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"sos\"\n    ]\n  , \"tags\": [\n      \"help\"\n    , \"emergency\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆙\"\n  , \"description\": \"UP! button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"up\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🆚\"\n  , \"description\": \"VS button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"vs\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈁\"\n  , \"description\": \"Japanese “here” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"koko\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈂️\"\n  , \"description\": \"Japanese “service charge” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"sa\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈷️\"\n  , \"description\": \"Japanese “monthly amount” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u6708\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈶\"\n  , \"description\": \"Japanese “not free of charge” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u6709\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈯\"\n  , \"description\": \"Japanese “reserved” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u6307\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🉐\"\n  , \"description\": \"Japanese “bargain” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"ideograph_advantage\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈹\"\n  , \"description\": \"Japanese “discount” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u5272\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈚\"\n  , \"description\": \"Japanese “free of charge” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u7121\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈲\"\n  , \"description\": \"Japanese “prohibited” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u7981\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🉑\"\n  , \"description\": \"Japanese “acceptable” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"accept\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈸\"\n  , \"description\": \"Japanese “application” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u7533\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈴\"\n  , \"description\": \"Japanese “passing grade” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u5408\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈳\"\n  , \"description\": \"Japanese “vacancy” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u7a7a\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"㊗️\"\n  , \"description\": \"Japanese “congratulations” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"congratulations\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"㊙️\"\n  , \"description\": \"Japanese “secret” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"secret\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈺\"\n  , \"description\": \"Japanese “open for business” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u55b6\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🈵\"\n  , \"description\": \"Japanese “no vacancy” button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"u6e80\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔴\"\n  , \"description\": \"red circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"red_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🟠\"\n  , \"description\": \"orange circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"orange_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟡\"\n  , \"description\": \"yellow circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"yellow_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟢\"\n  , \"description\": \"green circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"green_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🔵\"\n  , \"description\": \"blue circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"large_blue_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🟣\"\n  , \"description\": \"purple circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"purple_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟤\"\n  , \"description\": \"brown circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"brown_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"⚫\"\n  , \"description\": \"black circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⚪\"\n  , \"description\": \"white circle\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_circle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"4.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🟥\"\n  , \"description\": \"red square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"red_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟧\"\n  , \"description\": \"orange square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"orange_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟨\"\n  , \"description\": \"yellow square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"yellow_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟩\"\n  , \"description\": \"green square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"green_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟦\"\n  , \"description\": \"blue square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"blue_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟪\"\n  , \"description\": \"purple square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"purple_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"🟫\"\n  , \"description\": \"brown square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"brown_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"12.0\"\n  , \"ios_version\": \"13.0\"\n  }\n, {\n    \"emoji\": \"⬛\"\n  , \"description\": \"black large square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_large_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"⬜\"\n  , \"description\": \"white large square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_large_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"5.1\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"◼️\"\n  , \"description\": \"black medium square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_medium_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"◻️\"\n  , \"description\": \"white medium square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_medium_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"◾\"\n  , \"description\": \"black medium-small square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_medium_small_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"◽\"\n  , \"description\": \"white medium-small square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_medium_small_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"3.2\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"▪️\"\n  , \"description\": \"black small square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_small_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"▫️\"\n  , \"description\": \"white small square\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_small_square\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔶\"\n  , \"description\": \"large orange diamond\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"large_orange_diamond\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔷\"\n  , \"description\": \"large blue diamond\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"large_blue_diamond\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔸\"\n  , \"description\": \"small orange diamond\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"small_orange_diamond\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔹\"\n  , \"description\": \"small blue diamond\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"small_blue_diamond\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔺\"\n  , \"description\": \"red triangle pointed up\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"small_red_triangle\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔻\"\n  , \"description\": \"red triangle pointed down\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"small_red_triangle_down\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"💠\"\n  , \"description\": \"diamond with a dot\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"diamond_shape_with_a_dot_inside\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔘\"\n  , \"description\": \"radio button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"radio_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔳\"\n  , \"description\": \"white square button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"white_square_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🔲\"\n  , \"description\": \"black square button\"\n  , \"category\": \"Symbols\"\n  , \"aliases\": [\n      \"black_square_button\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏁\"\n  , \"description\": \"chequered flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"checkered_flag\"\n    ]\n  , \"tags\": [\n      \"milestone\"\n    , \"finish\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🚩\"\n  , \"description\": \"triangular flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"triangular_flag_on_post\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🎌\"\n  , \"description\": \"crossed flags\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"crossed_flags\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🏴\"\n  , \"description\": \"black flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"black_flag\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏳️\"\n  , \"description\": \"white flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"white_flag\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"7.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🏳️‍🌈\"\n  , \"description\": \"rainbow flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"rainbow_flag\"\n    ]\n  , \"tags\": [\n      \"pride\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"10.0\"\n  }\n, {\n    \"emoji\": \"🏳️‍⚧️\"\n  , \"description\": \"transgender flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"transgender_flag\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"13.0\"\n  , \"ios_version\": \"14.0\"\n  }\n, {\n    \"emoji\": \"🏴‍☠️\"\n  , \"description\": \"pirate flag\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"pirate_flag\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇦🇨\"\n  , \"description\": \"flag: Ascension Island\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ascension_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇦🇩\"\n  , \"description\": \"flag: Andorra\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"andorra\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇪\"\n  , \"description\": \"flag: United Arab Emirates\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"united_arab_emirates\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇫\"\n  , \"description\": \"flag: Afghanistan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"afghanistan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇬\"\n  , \"description\": \"flag: Antigua & Barbuda\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"antigua_barbuda\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇮\"\n  , \"description\": \"flag: Anguilla\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"anguilla\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇱\"\n  , \"description\": \"flag: Albania\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"albania\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇲\"\n  , \"description\": \"flag: Armenia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"armenia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇴\"\n  , \"description\": \"flag: Angola\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"angola\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇶\"\n  , \"description\": \"flag: Antarctica\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"antarctica\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇦🇷\"\n  , \"description\": \"flag: Argentina\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"argentina\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇸\"\n  , \"description\": \"flag: American Samoa\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"american_samoa\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇹\"\n  , \"description\": \"flag: Austria\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"austria\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇺\"\n  , \"description\": \"flag: Australia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"australia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇼\"\n  , \"description\": \"flag: Aruba\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"aruba\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇦🇽\"\n  , \"description\": \"flag: Åland Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"aland_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇦🇿\"\n  , \"description\": \"flag: Azerbaijan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"azerbaijan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇦\"\n  , \"description\": \"flag: Bosnia & Herzegovina\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bosnia_herzegovina\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇧\"\n  , \"description\": \"flag: Barbados\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"barbados\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇩\"\n  , \"description\": \"flag: Bangladesh\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bangladesh\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇪\"\n  , \"description\": \"flag: Belgium\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"belgium\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇫\"\n  , \"description\": \"flag: Burkina Faso\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"burkina_faso\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇬\"\n  , \"description\": \"flag: Bulgaria\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bulgaria\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇭\"\n  , \"description\": \"flag: Bahrain\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bahrain\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇮\"\n  , \"description\": \"flag: Burundi\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"burundi\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇯\"\n  , \"description\": \"flag: Benin\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"benin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇱\"\n  , \"description\": \"flag: St. Barthélemy\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_barthelemy\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇧🇲\"\n  , \"description\": \"flag: Bermuda\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bermuda\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇳\"\n  , \"description\": \"flag: Brunei\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"brunei\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇴\"\n  , \"description\": \"flag: Bolivia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bolivia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇶\"\n  , \"description\": \"flag: Caribbean Netherlands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"caribbean_netherlands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇧🇷\"\n  , \"description\": \"flag: Brazil\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"brazil\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇸\"\n  , \"description\": \"flag: Bahamas\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bahamas\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇹\"\n  , \"description\": \"flag: Bhutan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bhutan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇻\"\n  , \"description\": \"flag: Bouvet Island\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"bouvet_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇧🇼\"\n  , \"description\": \"flag: Botswana\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"botswana\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇾\"\n  , \"description\": \"flag: Belarus\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"belarus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇧🇿\"\n  , \"description\": \"flag: Belize\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"belize\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇦\"\n  , \"description\": \"flag: Canada\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"canada\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇨\"\n  , \"description\": \"flag: Cocos (Keeling) Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cocos_islands\"\n    ]\n  , \"tags\": [\n      \"keeling\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇨🇩\"\n  , \"description\": \"flag: Congo - Kinshasa\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"congo_kinshasa\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇫\"\n  , \"description\": \"flag: Central African Republic\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"central_african_republic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇬\"\n  , \"description\": \"flag: Congo - Brazzaville\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"congo_brazzaville\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇭\"\n  , \"description\": \"flag: Switzerland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"switzerland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇮\"\n  , \"description\": \"flag: Côte d’Ivoire\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cote_divoire\"\n    ]\n  , \"tags\": [\n      \"ivory\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇰\"\n  , \"description\": \"flag: Cook Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cook_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇱\"\n  , \"description\": \"flag: Chile\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"chile\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇲\"\n  , \"description\": \"flag: Cameroon\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cameroon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇳\"\n  , \"description\": \"flag: China\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cn\"\n    ]\n  , \"tags\": [\n      \"china\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇨🇴\"\n  , \"description\": \"flag: Colombia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"colombia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇵\"\n  , \"description\": \"flag: Clipperton Island\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"clipperton_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇨🇷\"\n  , \"description\": \"flag: Costa Rica\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"costa_rica\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇺\"\n  , \"description\": \"flag: Cuba\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cuba\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇻\"\n  , \"description\": \"flag: Cape Verde\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cape_verde\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇼\"\n  , \"description\": \"flag: Curaçao\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"curacao\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇽\"\n  , \"description\": \"flag: Christmas Island\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"christmas_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇨🇾\"\n  , \"description\": \"flag: Cyprus\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cyprus\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇨🇿\"\n  , \"description\": \"flag: Czechia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"czech_republic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇩🇪\"\n  , \"description\": \"flag: Germany\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"de\"\n    ]\n  , \"tags\": [\n      \"flag\"\n    , \"germany\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇩🇬\"\n  , \"description\": \"flag: Diego Garcia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"diego_garcia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇩🇯\"\n  , \"description\": \"flag: Djibouti\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"djibouti\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇩🇰\"\n  , \"description\": \"flag: Denmark\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"denmark\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇩🇲\"\n  , \"description\": \"flag: Dominica\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"dominica\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇩🇴\"\n  , \"description\": \"flag: Dominican Republic\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"dominican_republic\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇩🇿\"\n  , \"description\": \"flag: Algeria\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"algeria\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇦\"\n  , \"description\": \"flag: Ceuta & Melilla\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ceuta_melilla\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇪🇨\"\n  , \"description\": \"flag: Ecuador\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ecuador\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇪\"\n  , \"description\": \"flag: Estonia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"estonia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇬\"\n  , \"description\": \"flag: Egypt\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"egypt\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇭\"\n  , \"description\": \"flag: Western Sahara\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"western_sahara\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇪🇷\"\n  , \"description\": \"flag: Eritrea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"eritrea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇸\"\n  , \"description\": \"flag: Spain\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"es\"\n    ]\n  , \"tags\": [\n      \"spain\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇪🇹\"\n  , \"description\": \"flag: Ethiopia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ethiopia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇪🇺\"\n  , \"description\": \"flag: European Union\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"eu\"\n    , \"european_union\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇫🇮\"\n  , \"description\": \"flag: Finland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"finland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇫🇯\"\n  , \"description\": \"flag: Fiji\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"fiji\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇫🇰\"\n  , \"description\": \"flag: Falkland Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"falkland_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇫🇲\"\n  , \"description\": \"flag: Micronesia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"micronesia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇫🇴\"\n  , \"description\": \"flag: Faroe Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"faroe_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇫🇷\"\n  , \"description\": \"flag: France\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"fr\"\n    ]\n  , \"tags\": [\n      \"france\"\n    , \"french\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇬🇦\"\n  , \"description\": \"flag: Gabon\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"gabon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇧\"\n  , \"description\": \"flag: United Kingdom\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"gb\"\n    , \"uk\"\n    ]\n  , \"tags\": [\n      \"flag\"\n    , \"british\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇬🇩\"\n  , \"description\": \"flag: Grenada\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"grenada\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇪\"\n  , \"description\": \"flag: Georgia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"georgia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇫\"\n  , \"description\": \"flag: French Guiana\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"french_guiana\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇬\"\n  , \"description\": \"flag: Guernsey\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guernsey\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇬🇭\"\n  , \"description\": \"flag: Ghana\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ghana\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇮\"\n  , \"description\": \"flag: Gibraltar\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"gibraltar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇱\"\n  , \"description\": \"flag: Greenland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"greenland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇬🇲\"\n  , \"description\": \"flag: Gambia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"gambia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇳\"\n  , \"description\": \"flag: Guinea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guinea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇵\"\n  , \"description\": \"flag: Guadeloupe\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guadeloupe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇬🇶\"\n  , \"description\": \"flag: Equatorial Guinea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"equatorial_guinea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇷\"\n  , \"description\": \"flag: Greece\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"greece\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇸\"\n  , \"description\": \"flag: South Georgia & South Sandwich Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"south_georgia_south_sandwich_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇬🇹\"\n  , \"description\": \"flag: Guatemala\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guatemala\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇺\"\n  , \"description\": \"flag: Guam\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guam\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇼\"\n  , \"description\": \"flag: Guinea-Bissau\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guinea_bissau\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇬🇾\"\n  , \"description\": \"flag: Guyana\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"guyana\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇭🇰\"\n  , \"description\": \"flag: Hong Kong SAR China\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"hong_kong\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇭🇲\"\n  , \"description\": \"flag: Heard & McDonald Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"heard_mcdonald_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇭🇳\"\n  , \"description\": \"flag: Honduras\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"honduras\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇭🇷\"\n  , \"description\": \"flag: Croatia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"croatia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇭🇹\"\n  , \"description\": \"flag: Haiti\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"haiti\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇭🇺\"\n  , \"description\": \"flag: Hungary\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"hungary\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇨\"\n  , \"description\": \"flag: Canary Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"canary_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇮🇩\"\n  , \"description\": \"flag: Indonesia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"indonesia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇪\"\n  , \"description\": \"flag: Ireland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ireland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇱\"\n  , \"description\": \"flag: Israel\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"israel\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇲\"\n  , \"description\": \"flag: Isle of Man\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"isle_of_man\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇮🇳\"\n  , \"description\": \"flag: India\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"india\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇴\"\n  , \"description\": \"flag: British Indian Ocean Territory\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"british_indian_ocean_territory\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇮🇶\"\n  , \"description\": \"flag: Iraq\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"iraq\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇷\"\n  , \"description\": \"flag: Iran\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"iran\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇸\"\n  , \"description\": \"flag: Iceland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"iceland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇮🇹\"\n  , \"description\": \"flag: Italy\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"it\"\n    ]\n  , \"tags\": [\n      \"italy\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇯🇪\"\n  , \"description\": \"flag: Jersey\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"jersey\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇯🇲\"\n  , \"description\": \"flag: Jamaica\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"jamaica\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇯🇴\"\n  , \"description\": \"flag: Jordan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"jordan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇯🇵\"\n  , \"description\": \"flag: Japan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"jp\"\n    ]\n  , \"tags\": [\n      \"japan\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇰🇪\"\n  , \"description\": \"flag: Kenya\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kenya\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇬\"\n  , \"description\": \"flag: Kyrgyzstan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kyrgyzstan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇭\"\n  , \"description\": \"flag: Cambodia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cambodia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇮\"\n  , \"description\": \"flag: Kiribati\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kiribati\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇲\"\n  , \"description\": \"flag: Comoros\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"comoros\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇳\"\n  , \"description\": \"flag: St. Kitts & Nevis\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_kitts_nevis\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇵\"\n  , \"description\": \"flag: North Korea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"north_korea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇷\"\n  , \"description\": \"flag: South Korea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kr\"\n    ]\n  , \"tags\": [\n      \"korea\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇰🇼\"\n  , \"description\": \"flag: Kuwait\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kuwait\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇾\"\n  , \"description\": \"flag: Cayman Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"cayman_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇰🇿\"\n  , \"description\": \"flag: Kazakhstan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kazakhstan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇦\"\n  , \"description\": \"flag: Laos\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"laos\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇧\"\n  , \"description\": \"flag: Lebanon\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"lebanon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇨\"\n  , \"description\": \"flag: St. Lucia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_lucia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇮\"\n  , \"description\": \"flag: Liechtenstein\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"liechtenstein\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇰\"\n  , \"description\": \"flag: Sri Lanka\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sri_lanka\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇷\"\n  , \"description\": \"flag: Liberia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"liberia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇸\"\n  , \"description\": \"flag: Lesotho\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"lesotho\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇹\"\n  , \"description\": \"flag: Lithuania\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"lithuania\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇺\"\n  , \"description\": \"flag: Luxembourg\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"luxembourg\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇻\"\n  , \"description\": \"flag: Latvia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"latvia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇱🇾\"\n  , \"description\": \"flag: Libya\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"libya\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇦\"\n  , \"description\": \"flag: Morocco\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"morocco\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇨\"\n  , \"description\": \"flag: Monaco\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"monaco\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇲🇩\"\n  , \"description\": \"flag: Moldova\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"moldova\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇪\"\n  , \"description\": \"flag: Montenegro\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"montenegro\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇫\"\n  , \"description\": \"flag: St. Martin\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_martin\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇲🇬\"\n  , \"description\": \"flag: Madagascar\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"madagascar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇭\"\n  , \"description\": \"flag: Marshall Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"marshall_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇲🇰\"\n  , \"description\": \"flag: North Macedonia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"macedonia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇱\"\n  , \"description\": \"flag: Mali\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mali\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇲\"\n  , \"description\": \"flag: Myanmar (Burma)\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"myanmar\"\n    ]\n  , \"tags\": [\n      \"burma\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇳\"\n  , \"description\": \"flag: Mongolia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mongolia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇴\"\n  , \"description\": \"flag: Macao SAR China\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"macau\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇵\"\n  , \"description\": \"flag: Northern Mariana Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"northern_mariana_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇶\"\n  , \"description\": \"flag: Martinique\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"martinique\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇲🇷\"\n  , \"description\": \"flag: Mauritania\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mauritania\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇸\"\n  , \"description\": \"flag: Montserrat\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"montserrat\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇹\"\n  , \"description\": \"flag: Malta\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"malta\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇺\"\n  , \"description\": \"flag: Mauritius\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mauritius\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇲🇻\"\n  , \"description\": \"flag: Maldives\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"maldives\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇼\"\n  , \"description\": \"flag: Malawi\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"malawi\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇽\"\n  , \"description\": \"flag: Mexico\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mexico\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇾\"\n  , \"description\": \"flag: Malaysia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"malaysia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇲🇿\"\n  , \"description\": \"flag: Mozambique\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mozambique\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇦\"\n  , \"description\": \"flag: Namibia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"namibia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇨\"\n  , \"description\": \"flag: New Caledonia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"new_caledonia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇪\"\n  , \"description\": \"flag: Niger\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"niger\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇫\"\n  , \"description\": \"flag: Norfolk Island\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"norfolk_island\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇳🇬\"\n  , \"description\": \"flag: Nigeria\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"nigeria\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇮\"\n  , \"description\": \"flag: Nicaragua\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"nicaragua\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇱\"\n  , \"description\": \"flag: Netherlands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"netherlands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇴\"\n  , \"description\": \"flag: Norway\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"norway\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇵\"\n  , \"description\": \"flag: Nepal\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"nepal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇷\"\n  , \"description\": \"flag: Nauru\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"nauru\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇳🇺\"\n  , \"description\": \"flag: Niue\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"niue\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇳🇿\"\n  , \"description\": \"flag: New Zealand\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"new_zealand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇴🇲\"\n  , \"description\": \"flag: Oman\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"oman\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇦\"\n  , \"description\": \"flag: Panama\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"panama\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇪\"\n  , \"description\": \"flag: Peru\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"peru\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇫\"\n  , \"description\": \"flag: French Polynesia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"french_polynesia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇵🇬\"\n  , \"description\": \"flag: Papua New Guinea\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"papua_new_guinea\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇭\"\n  , \"description\": \"flag: Philippines\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"philippines\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇰\"\n  , \"description\": \"flag: Pakistan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"pakistan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇱\"\n  , \"description\": \"flag: Poland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"poland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇲\"\n  , \"description\": \"flag: St. Pierre & Miquelon\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_pierre_miquelon\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇵🇳\"\n  , \"description\": \"flag: Pitcairn Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"pitcairn_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇵🇷\"\n  , \"description\": \"flag: Puerto Rico\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"puerto_rico\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇸\"\n  , \"description\": \"flag: Palestinian Territories\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"palestinian_territories\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇹\"\n  , \"description\": \"flag: Portugal\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"portugal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇼\"\n  , \"description\": \"flag: Palau\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"palau\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇵🇾\"\n  , \"description\": \"flag: Paraguay\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"paraguay\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇶🇦\"\n  , \"description\": \"flag: Qatar\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"qatar\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇷🇪\"\n  , \"description\": \"flag: Réunion\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"reunion\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇷🇴\"\n  , \"description\": \"flag: Romania\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"romania\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇷🇸\"\n  , \"description\": \"flag: Serbia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"serbia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇷🇺\"\n  , \"description\": \"flag: Russia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ru\"\n    ]\n  , \"tags\": [\n      \"russia\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇷🇼\"\n  , \"description\": \"flag: Rwanda\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"rwanda\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇦\"\n  , \"description\": \"flag: Saudi Arabia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"saudi_arabia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇧\"\n  , \"description\": \"flag: Solomon Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"solomon_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇨\"\n  , \"description\": \"flag: Seychelles\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"seychelles\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇩\"\n  , \"description\": \"flag: Sudan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sudan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇪\"\n  , \"description\": \"flag: Sweden\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sweden\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇬\"\n  , \"description\": \"flag: Singapore\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"singapore\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇭\"\n  , \"description\": \"flag: St. Helena\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_helena\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇸🇮\"\n  , \"description\": \"flag: Slovenia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"slovenia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇯\"\n  , \"description\": \"flag: Svalbard & Jan Mayen\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"svalbard_jan_mayen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇸🇰\"\n  , \"description\": \"flag: Slovakia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"slovakia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇱\"\n  , \"description\": \"flag: Sierra Leone\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sierra_leone\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇲\"\n  , \"description\": \"flag: San Marino\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"san_marino\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇳\"\n  , \"description\": \"flag: Senegal\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"senegal\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇴\"\n  , \"description\": \"flag: Somalia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"somalia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇷\"\n  , \"description\": \"flag: Suriname\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"suriname\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇸\"\n  , \"description\": \"flag: South Sudan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"south_sudan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇹\"\n  , \"description\": \"flag: São Tomé & Príncipe\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sao_tome_principe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇻\"\n  , \"description\": \"flag: El Salvador\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"el_salvador\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇽\"\n  , \"description\": \"flag: Sint Maarten\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"sint_maarten\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇾\"\n  , \"description\": \"flag: Syria\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"syria\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇸🇿\"\n  , \"description\": \"flag: Eswatini\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"swaziland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇦\"\n  , \"description\": \"flag: Tristan da Cunha\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tristan_da_cunha\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇹🇨\"\n  , \"description\": \"flag: Turks & Caicos Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"turks_caicos_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇩\"\n  , \"description\": \"flag: Chad\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"chad\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇹🇫\"\n  , \"description\": \"flag: French Southern Territories\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"french_southern_territories\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇬\"\n  , \"description\": \"flag: Togo\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"togo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇭\"\n  , \"description\": \"flag: Thailand\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"thailand\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇯\"\n  , \"description\": \"flag: Tajikistan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tajikistan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇰\"\n  , \"description\": \"flag: Tokelau\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tokelau\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇹🇱\"\n  , \"description\": \"flag: Timor-Leste\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"timor_leste\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇲\"\n  , \"description\": \"flag: Turkmenistan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"turkmenistan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇳\"\n  , \"description\": \"flag: Tunisia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tunisia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇴\"\n  , \"description\": \"flag: Tonga\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tonga\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇷\"\n  , \"description\": \"flag: Turkey\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tr\"\n    ]\n  , \"tags\": [\n      \"turkey\"\n    ]\n  , \"unicode_version\": \"8.0\"\n  , \"ios_version\": \"9.1\"\n  }\n, {\n    \"emoji\": \"🇹🇹\"\n  , \"description\": \"flag: Trinidad & Tobago\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"trinidad_tobago\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇻\"\n  , \"description\": \"flag: Tuvalu\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tuvalu\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇹🇼\"\n  , \"description\": \"flag: Taiwan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"taiwan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇹🇿\"\n  , \"description\": \"flag: Tanzania\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"tanzania\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇺🇦\"\n  , \"description\": \"flag: Ukraine\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"ukraine\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇺🇬\"\n  , \"description\": \"flag: Uganda\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"uganda\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇺🇲\"\n  , \"description\": \"flag: U.S. Outlying Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"us_outlying_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇺🇳\"\n  , \"description\": \"flag: United Nations\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"united_nations\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🇺🇸\"\n  , \"description\": \"flag: United States\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"us\"\n    ]\n  , \"tags\": [\n      \"flag\"\n    , \"united\"\n    , \"america\"\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"6.0\"\n  }\n, {\n    \"emoji\": \"🇺🇾\"\n  , \"description\": \"flag: Uruguay\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"uruguay\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇺🇿\"\n  , \"description\": \"flag: Uzbekistan\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"uzbekistan\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇦\"\n  , \"description\": \"flag: Vatican City\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"vatican_city\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇻🇨\"\n  , \"description\": \"flag: St. Vincent & Grenadines\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"st_vincent_grenadines\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇪\"\n  , \"description\": \"flag: Venezuela\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"venezuela\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇬\"\n  , \"description\": \"flag: British Virgin Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"british_virgin_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇮\"\n  , \"description\": \"flag: U.S. Virgin Islands\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"us_virgin_islands\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇳\"\n  , \"description\": \"flag: Vietnam\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"vietnam\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇻🇺\"\n  , \"description\": \"flag: Vanuatu\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"vanuatu\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇼🇫\"\n  , \"description\": \"flag: Wallis & Futuna\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"wallis_futuna\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇼🇸\"\n  , \"description\": \"flag: Samoa\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"samoa\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇽🇰\"\n  , \"description\": \"flag: Kosovo\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"kosovo\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇾🇪\"\n  , \"description\": \"flag: Yemen\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"yemen\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇾🇹\"\n  , \"description\": \"flag: Mayotte\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"mayotte\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"9.0\"\n  }\n, {\n    \"emoji\": \"🇿🇦\"\n  , \"description\": \"flag: South Africa\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"south_africa\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇿🇲\"\n  , \"description\": \"flag: Zambia\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"zambia\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🇿🇼\"\n  , \"description\": \"flag: Zimbabwe\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"zimbabwe\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"6.0\"\n  , \"ios_version\": \"8.3\"\n  }\n, {\n    \"emoji\": \"🏴󠁧󠁢󠁥󠁮󠁧󠁿\"\n  , \"description\": \"flag: England\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"england\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🏴󠁧󠁢󠁳󠁣󠁴󠁿\"\n  , \"description\": \"flag: Scotland\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"scotland\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n, {\n    \"emoji\": \"🏴󠁧󠁢󠁷󠁬󠁳󠁿\"\n  , \"description\": \"flag: Wales\"\n  , \"category\": \"Flags\"\n  , \"aliases\": [\n      \"wales\"\n    ]\n  , \"tags\": [\n    ]\n  , \"unicode_version\": \"11.0\"\n  , \"ios_version\": \"12.1\"\n  }\n]\n"
  },
  {
    "path": "scripts/postinst.sh",
    "content": "#!/bin/sh\nset -e\n\n# Restart systemd service if it was already running. Note that \"deb-systemd-invoke try-restart\" will\n# only act if the service is already running. If it's not running, it's a no-op.\n#\nif [ \"$1\" = \"configure\" ] || [ \"$1\" -ge 1 ]; then\n  if [ -d /run/systemd/system ]; then\n    # Create ntfy user/group\n    groupadd -f ntfy\n    id ntfy >/dev/null 2>&1 || useradd --system --no-create-home -g ntfy ntfy\n    chown ntfy:ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy\n    chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy\n\n    # Hack to change permissions on cache file\n    configfile=\"/etc/ntfy/server.yml\"\n    if [ -f \"$configfile\" ]; then\n      cachefile=\"$(cat \"$configfile\" | perl -n -e'/^\\s*cache-file: [\"'\"'\"']?([^\"'\"'\"']+)[\"'\"'\"']?/ && print $1')\" # Oh my, see #47\n      if [ -n \"$cachefile\" ]; then\n        chown ntfy:ntfy \"$cachefile\" || true\n        chmod 600 \"$cachefile\" || true\n      fi\n    fi\n\n    # Restart services\n    systemctl --system daemon-reload >/dev/null || true\n    if systemctl is-active -q ntfy.service; then\n      echo \"Restarting ntfy.service ...\"\n      if [ -x /usr/bin/deb-systemd-invoke ]; then\n        deb-systemd-invoke try-restart ntfy.service >/dev/null || true\n      else\n        systemctl restart ntfy.service >/dev/null || true\n      fi\n    fi\n    if systemctl is-active -q ntfy-client.service; then\n      echo \"Restarting ntfy-client.service (system) ...\"\n      if [ -x /usr/bin/deb-systemd-invoke ]; then\n        deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true\n      else\n        systemctl restart ntfy-client.service >/dev/null || true\n      fi\n    fi\n  fi\nfi\n"
  },
  {
    "path": "scripts/postrm.sh",
    "content": "#!/bin/sh\nset -e\n\n# Delete the config if package is purged\nif [ \"$1\" = \"purge\" ] || [ \"$1\" = \"0\" ]; then\n  id ntfy >/dev/null 2>&1 && userdel ntfy\n  rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml\n  rmdir /etc/ntfy || true\nfi\n\n"
  },
  {
    "path": "scripts/preinst.sh",
    "content": "#!/bin/sh\nset -e\n\nif [ \"$1\" = \"install\" ] || [ \"$1\" = \"upgrade\" ] || [ \"$1\" -ge 1 ]; then\n  # Migration of old to new config file name\n  oldconfigfile=\"/etc/ntfy/config.yml\"\n  configfile=\"/etc/ntfy/server.yml\"\n  if [ -f \"$oldconfigfile\" ] && [ ! -f \"$configfile\" ]; then\n    mv \"$oldconfigfile\" \"$configfile\" || true\n  fi\nfi\n"
  },
  {
    "path": "scripts/prerm.sh",
    "content": "#!/bin/sh\nset -e\n\n# Stop systemd service\nif [ -d /run/systemd/system ]; then\n  if [ \"$1\" = \"remove\" ] || [ \"$1\" = \"0\" ]; then\n    echo \"Stopping ntfy.service ...\"\n    if [ -x /usr/bin/deb-systemd-invoke ]; then\n      deb-systemd-invoke stop 'ntfy.service' >/dev/null || true\n    else\n      systemctl stop ntfy >/dev/null 2>&1 || true\n    fi\n  fi\nfi\n"
  },
  {
    "path": "server/actions.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\tactionIDLength = 10\n\tactionEOF      = rune(0)\n\tactionsMax     = 3\n)\n\nconst (\n\tactionView      = \"view\"\n\tactionBroadcast = \"broadcast\"\n\tactionHTTP      = \"http\"\n\tactionCopy      = \"copy\"\n)\n\nvar (\n\tactionsAll       = []string{actionView, actionBroadcast, actionHTTP, actionCopy}\n\tactionsWithURL   = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()\n\tactionsWithValue = []string{actionCopy}             // Must be distinct from actionsWithURL, see populateAction()\n\tactionsKeyRegex  = regexp.MustCompile(`^([-.\\w]+)\\s*=\\s*`)\n)\n\ntype actionParser struct {\n\tinput string\n\tpos   int\n}\n\n// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.\n// It supports both a JSON representation (if the string begins with \"[\", see parseActionsFromJSON),\n// and the \"simple\" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).\nfunc parseActions(s string) (actions []*model.Action, err error) {\n\t// Parse JSON or simple format\n\ts = strings.TrimSpace(s)\n\tif strings.HasPrefix(s, \"[\") {\n\t\tactions, err = parseActionsFromJSON(s)\n\t} else {\n\t\tactions, err = parseActionsFromSimple(s)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Add ID field, ensure correct uppercase/lowercase\n\tfor i := range actions {\n\t\tactions[i].ID = util.RandomString(actionIDLength)\n\t\tactions[i].Action = strings.ToLower(actions[i].Action)\n\t\tactions[i].Method = strings.ToUpper(actions[i].Method)\n\t}\n\n\t// Validate\n\tif len(actions) > actionsMax {\n\t\treturn nil, fmt.Errorf(\"only %d actions allowed\", actionsMax)\n\t}\n\tfor _, action := range actions {\n\t\tif !util.Contains(actionsAll, action.Action) {\n\t\t\treturn nil, fmt.Errorf(\"parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'\", action.Action)\n\t\t} else if action.Label == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"parameter 'label' is required\")\n\t\t} else if util.Contains(actionsWithURL, action.Action) && action.URL == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"parameter 'url' is required for action '%s'\", action.Action)\n\t\t} else if util.Contains(actionsWithValue, action.Action) && action.Value == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"parameter 'value' is required for action '%s'\", action.Action)\n\t\t} else if action.Action == actionHTTP && util.Contains([]string{\"GET\", \"HEAD\"}, action.Method) && action.Body != \"\" {\n\t\t\treturn nil, fmt.Errorf(\"parameter 'body' cannot be set if method is %s\", action.Method)\n\t\t}\n\t}\n\n\treturn actions, nil\n}\n\n// parseActionsFromJSON converts a JSON array into an array of actions\nfunc parseActionsFromJSON(s string) ([]*model.Action, error) {\n\tactions := make([]*model.Action, 0)\n\tif err := json.Unmarshal([]byte(s), &actions); err != nil {\n\t\treturn nil, fmt.Errorf(\"JSON error: %w\", err)\n\t}\n\treturn actions, nil\n}\n\n// parseActionsFromSimple parses the \"simple\" actions string (as described in\n// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.\n//\n// It can parse an actions string like this:\n//\n//\tview, \"Look ma, commas and \\\"quotes\\\" too\", url=https://..; action=broadcast, ...\n//\n// It works by advancing the position (\"pos\") through the input string (\"input\").\n//\n// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which\n// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),\n// though it does not use state functions at all.\n//\n// Other resources:\n//\n//\thttps://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html\n//\thttps://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go\n//\thttps://github.com/benbjohnson/sql-parser/blob/master/scanner.go\n//\thttps://blog.gopheracademy.com/advent-2014/parsers-lexers/\nfunc parseActionsFromSimple(s string) ([]*model.Action, error) {\n\tif !utf8.ValidString(s) {\n\t\treturn nil, errors.New(\"invalid utf-8 string\")\n\t}\n\tparser := &actionParser{\n\t\tpos:   0,\n\t\tinput: s,\n\t}\n\treturn parser.Parse()\n}\n\n// Parse loops trough parseAction() until the end of the string is reached\nfunc (p *actionParser) Parse() ([]*model.Action, error) {\n\tactions := make([]*model.Action, 0)\n\tfor !p.eof() {\n\t\ta, err := p.parseAction()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tactions = append(actions, a)\n\t}\n\treturn actions, nil\n}\n\n// parseAction parses the individual sections of an action using parseSection into key/value pairs,\n// and then uses populateAction to interpret the keys/values. The function terminates\n// when EOF or \";\" is reached.\nfunc (p *actionParser) parseAction() (*model.Action, error) {\n\ta := model.NewAction()\n\tsection := 0\n\tfor {\n\t\tkey, value, last, err := p.parseSection()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := populateAction(a, section, key, value); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tp.slurpSpaces()\n\t\tif last {\n\t\t\treturn a, nil\n\t\t}\n\t\tsection++\n\t}\n}\n\n// populateAction is the \"business logic\" of the parser. It applies the key/value\n// pair to the action instance.\nfunc populateAction(newAction *model.Action, section int, key, value string) error {\n\t// Auto-expand keys based on their index\n\tif key == \"\" && section == 0 {\n\t\tkey = \"action\"\n\t} else if key == \"\" && section == 1 {\n\t\tkey = \"label\"\n\t} else if key == \"\" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {\n\t\tkey = \"url\"\n\t} else if key == \"\" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {\n\t\tkey = \"value\"\n\t}\n\n\t// Validate\n\tif key == \"\" {\n\t\treturn fmt.Errorf(\"term '%s' unknown\", value)\n\t}\n\n\t// Populate\n\tif strings.HasPrefix(key, \"headers.\") {\n\t\tnewAction.Headers[strings.TrimPrefix(key, \"headers.\")] = value\n\t} else if strings.HasPrefix(key, \"extras.\") {\n\t\tnewAction.Extras[strings.TrimPrefix(key, \"extras.\")] = value\n\t} else {\n\t\tswitch strings.ToLower(key) {\n\t\tcase \"action\":\n\t\t\tnewAction.Action = value\n\t\tcase \"label\":\n\t\t\tnewAction.Label = value\n\t\tcase \"clear\":\n\t\t\tlvalue := strings.ToLower(value)\n\t\t\tif !util.Contains([]string{\"true\", \"yes\", \"1\", \"false\", \"no\", \"0\"}, lvalue) {\n\t\t\t\treturn fmt.Errorf(\"parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)\", value)\n\t\t\t}\n\t\t\tnewAction.Clear = lvalue == \"true\" || lvalue == \"yes\" || lvalue == \"1\"\n\t\tcase \"url\":\n\t\t\tnewAction.URL = value\n\t\tcase \"method\":\n\t\t\tnewAction.Method = value\n\t\tcase \"body\":\n\t\t\tnewAction.Body = value\n\t\tcase \"value\":\n\t\t\tnewAction.Value = value\n\t\tcase \"intent\":\n\t\t\tnewAction.Intent = value\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"key '%s' unknown\", key)\n\t\t}\n\t}\n\treturn nil\n}\n\n// parseSection parses a section (\"key=value\") and returns a key/value pair. It terminates\n// when EOF or \",\" is reached.\nfunc (p *actionParser) parseSection() (key string, value string, last bool, err error) {\n\tp.slurpSpaces()\n\tkey = p.parseKey()\n\tr, w := p.peek()\n\tif isSectionEnd(r) {\n\t\tp.pos += w\n\t\tlast = isLastSection(r)\n\t\treturn\n\t} else if r == '\"' || r == '\\'' {\n\t\tvalue, last, err = p.parseQuotedValue(r)\n\t\treturn\n\t}\n\tvalue, last = p.parseValue()\n\treturn\n}\n\n// parseKey uses a regex to determine whether the current position is a key definition (\"key =\")\n// and returns the key if it is, or an empty string otherwise.\nfunc (p *actionParser) parseKey() string {\n\tmatches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])\n\tif len(matches) == 2 {\n\t\tp.pos += len(matches[0])\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// parseValue reads the input until EOF, \",\" or \";\" and returns the value string. Unlike parseQuotedValue,\n// this function does not support \",\" or \";\" in the value itself, and spaces in the beginning and end of the\n// string are trimmed.\nfunc (p *actionParser) parseValue() (value string, last bool) {\n\tstart := p.pos\n\tfor {\n\t\tr, w := p.peek()\n\t\tif isSectionEnd(r) {\n\t\t\tlast = isLastSection(r)\n\t\t\tvalue = strings.TrimSpace(p.input[start:p.pos])\n\t\t\tp.pos += w\n\t\t\treturn\n\t\t}\n\t\tp.pos += w\n\t}\n}\n\n// parseQuotedValue reads the input until it finds an unescaped end quote character (\"), and then\n// advances the position beyond the section end. It supports quoting strings using backslash (\\).\nfunc (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {\n\tp.pos++\n\tstart := p.pos\n\tvar prev rune\n\tfor {\n\t\tr, w := p.peek()\n\t\tif r == actionEOF {\n\t\t\terr = fmt.Errorf(\"unexpected end of input, quote started at position %d\", start)\n\t\t\treturn\n\t\t} else if r == quote && prev != '\\\\' {\n\t\t\tvalue = strings.ReplaceAll(p.input[start:p.pos], \"\\\\\"+string(quote), string(quote)) // \\\" -> \"\n\t\t\tp.pos += w\n\n\t\t\t// Advance until section end (after \",\" or \";\")\n\t\t\tp.slurpSpaces()\n\t\t\tr, w := p.peek()\n\t\t\tlast = isLastSection(r)\n\t\t\tif !isSectionEnd(r) {\n\t\t\t\terr = fmt.Errorf(\"unexpected character '%c' at position %d\", r, p.pos)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tp.pos += w\n\t\t\treturn\n\t\t}\n\t\tprev = r\n\t\tp.pos += w\n\t}\n}\n\n// slurpSpaces reads all space characters and advances the position\nfunc (p *actionParser) slurpSpaces() {\n\tfor {\n\t\tr, w := p.peek()\n\t\tif r == actionEOF || !isSpace(r) {\n\t\t\treturn\n\t\t}\n\t\tp.pos += w\n\t}\n}\n\n// peek returns the next run and its width\nfunc (p *actionParser) peek() (rune, int) {\n\tif p.eof() {\n\t\treturn actionEOF, 0\n\t}\n\treturn utf8.DecodeRuneInString(p.input[p.pos:])\n}\n\n// eof returns true if the end of the input has been reached\nfunc (p *actionParser) eof() bool {\n\treturn p.pos >= len(p.input)\n}\n\nfunc isSpace(r rune) bool {\n\treturn r == ' ' || r == '\\t' || r == '\\r' || r == '\\n'\n}\n\nfunc isSectionEnd(r rune) bool {\n\treturn r == actionEOF || r == ';' || r == ','\n}\n\nfunc isLastSection(r rune) bool {\n\treturn r == actionEOF || r == ';'\n}\n"
  },
  {
    "path": "server/actions_test.go",
    "content": "package server\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestParseActions(t *testing.T) {\n\tactions, err := parseActions(\"[]\")\n\trequire.Nil(t, err)\n\trequire.Empty(t, actions)\n\n\t// Basic test\n\tactions, err = parseActions(\"action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 2, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, \"Open door\", actions[0].Label)\n\trequire.Equal(t, \"https://door.lan/open\", actions[0].URL)\n\trequire.Equal(t, \"view\", actions[1].Action)\n\trequire.Equal(t, \"Show portal\", actions[1].Label)\n\trequire.Equal(t, \"https://door.lan\", actions[1].URL)\n\n\t// JSON\n\tactions, err = parseActions(`[{\"action\":\"http\",\"label\":\"Open door\",\"url\":\"https://door.lan/open\"}, {\"action\":\"view\",\"label\":\"Show portal\",\"url\":\"https://door.lan\"}]`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 2, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, \"Open door\", actions[0].Label)\n\trequire.Equal(t, \"https://door.lan/open\", actions[0].URL)\n\trequire.Equal(t, \"view\", actions[1].Action)\n\trequire.Equal(t, \"Show portal\", actions[1].Label)\n\trequire.Equal(t, \"https://door.lan\", actions[1].URL)\n\n\t// Other params\n\tactions, err = parseActions(\"action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, \"Open door\", actions[0].Label)\n\trequire.Equal(t, \"https://door.lan/open\", actions[0].URL)\n\trequire.Equal(t, \"PUT\", actions[0].Method)\n\trequire.Equal(t, \"this is a body\", actions[0].Body)\n\n\t// Extras with underscores\n\tactions, err = parseActions(\"action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"broadcast\", actions[0].Action)\n\trequire.Equal(t, \"Do a thing\", actions[0].Label)\n\trequire.Equal(t, 2, len(actions[0].Extras))\n\trequire.Equal(t, \"some command\", actions[0].Extras[\"command\"])\n\trequire.Equal(t, \"a parameter\", actions[0].Extras[\"some_param\"])\n\n\t// Broadcast action with intent\n\tactions, err = parseActions(\"action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"broadcast\", actions[0].Action)\n\trequire.Equal(t, \"Do a thing\", actions[0].Label)\n\trequire.Equal(t, \"io.heckel.ntfy.TEST_INTENT\", actions[0].Intent)\n\n\t// Headers with dashes\n\tactions, err = parseActions(\"action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, \"Send request\", actions[0].Label)\n\trequire.Equal(t, 2, len(actions[0].Headers))\n\trequire.Equal(t, \"application/json\", actions[0].Headers[\"Content-Type\"])\n\trequire.Equal(t, \"Basic sdasffsf\", actions[0].Headers[\"Authorization\"])\n\n\t// Quotes\n\tactions, err = parseActions(`action=http, \"Look ma, \\\"quotes\\\"; and semicolons\", url=http://example.com`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)\n\trequire.Equal(t, `http://example.com`, actions[0].URL)\n\n\t// Single quotes\n\tactions, err = parseActions(`action=http, '\"quotes\" and \\'single quotes\\'', url=http://example.com`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `\"quotes\" and 'single quotes'`, actions[0].Label)\n\trequire.Equal(t, `http://example.com`, actions[0].URL)\n\n\t// Single quotes (JSON)\n\tactions, err = parseActions(`action=http, Post it, url=http://example.com, body='{\"temperature\": 65}'`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, \"Post it\", actions[0].Label)\n\trequire.Equal(t, `http://example.com`, actions[0].URL)\n\trequire.Equal(t, `{\"temperature\": 65}`, actions[0].Body)\n\n\t// Out of order\n\tactions, err = parseActions(`label=\"Out of order!\" , action=\"http\", url=http://example.com`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `Out of order!`, actions[0].Label)\n\trequire.Equal(t, `http://example.com`, actions[0].URL)\n\n\t// Spaces\n\tactions, err = parseActions(`action = http, label = 'this is a label', url = \"http://google.com\"`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `this is a label`, actions[0].Label)\n\trequire.Equal(t, `http://google.com`, actions[0].URL)\n\n\t// Non-ASCII\n\tactions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = \"http://google.com\"`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)\n\trequire.Equal(t, `http://google.com`, actions[0].URL)\n\n\t// Multiple actions, awkward spacing\n\tactions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, \" yo \", https://x.org, clear=true`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 2, len(actions))\n\trequire.Equal(t, \"http\", actions[0].Action)\n\trequire.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)\n\trequire.Equal(t, `https://ntfy.sh`, actions[0].URL)\n\trequire.Equal(t, false, actions[0].Clear)\n\trequire.Equal(t, \"view\", actions[1].Action)\n\trequire.Equal(t, \" yo \", actions[1].Label)\n\trequire.Equal(t, `https://x.org`, actions[1].URL)\n\trequire.Equal(t, true, actions[1].Clear)\n\n\t// Copy action (simple format)\n\tactions, err = parseActions(\"copy, Copy code, 1234\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"copy\", actions[0].Action)\n\trequire.Equal(t, \"Copy code\", actions[0].Label)\n\trequire.Equal(t, \"1234\", actions[0].Value)\n\n\t// Copy action (JSON)\n\tactions, err = parseActions(`[{\"action\":\"copy\",\"label\":\"Copy OTP\",\"value\":\"567890\"}]`)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"copy\", actions[0].Action)\n\trequire.Equal(t, \"Copy OTP\", actions[0].Label)\n\trequire.Equal(t, \"567890\", actions[0].Value)\n\n\t// Copy action with clear\n\tactions, err = parseActions(\"copy, Copy code, 1234, clear=true\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"copy\", actions[0].Action)\n\trequire.Equal(t, \"Copy code\", actions[0].Label)\n\trequire.Equal(t, \"1234\", actions[0].Value)\n\trequire.Equal(t, true, actions[0].Clear)\n\n\t// Copy action with explicit value key\n\tactions, err = parseActions(\"action=copy, label=Copy token, clear=true, value=abc-123-def\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 1, len(actions))\n\trequire.Equal(t, \"copy\", actions[0].Action)\n\trequire.Equal(t, \"Copy token\", actions[0].Label)\n\trequire.Equal(t, \"abc-123-def\", actions[0].Value)\n\trequire.True(t, actions[0].Clear)\n\n\t// Copy action without value (error)\n\t_, err = parseActions(\"copy, Copy code\")\n\trequire.EqualError(t, err, \"parameter 'value' is required for action 'copy'\")\n\n\t// Invalid syntax\n\t_, err = parseActions(`label=\"Out of order!\" x, action=\"http\", url=http://example.com`)\n\trequire.EqualError(t, err, \"unexpected character 'x' at position 22\")\n\n\t_, err = parseActions(`label=\"\", action=\"http\", url=http://example.com`)\n\trequire.EqualError(t, err, \"parameter 'label' is required\")\n\n\t_, err = parseActions(`label=, action=\"http\", url=http://example.com`)\n\trequire.EqualError(t, err, \"parameter 'label' is required\")\n\n\t_, err = parseActions(`label=\"xx\", action=\"http\", url=http://example.com, what is this anyway`)\n\trequire.EqualError(t, err, \"term 'what is this anyway' unknown\")\n\n\t_, err = parseActions(`fdsfdsf`)\n\trequire.EqualError(t, err, \"parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'\")\n\n\t_, err = parseActions(`aaa=a, \"bbb, 'ccc, ddd, eee \"`)\n\trequire.EqualError(t, err, \"key 'aaa' unknown\")\n\n\t_, err = parseActions(`action=http, label=\"omg the end quote is missing`)\n\trequire.EqualError(t, err, \"unexpected end of input, quote started at position 20\")\n\n\t_, err = parseActions(`;;;;`)\n\trequire.EqualError(t, err, \"only 3 actions allowed\")\n\n\t_, err = parseActions(`,,,,,,;;`)\n\trequire.EqualError(t, err, \"term '' unknown\")\n\n\t_, err = parseActions(`''\";,;\"`)\n\trequire.EqualError(t, err, \"unexpected character '\\\"' at position 2\")\n\n\t_, err = parseActions(`action=http, label=a label, body=somebody`)\n\trequire.EqualError(t, err, \"parameter 'url' is required for action 'http'\")\n\n\t_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)\n\trequire.EqualError(t, err, \"parameter 'body' cannot be set if method is HEAD\")\n\n\t_, err = parseActions(`[ invalid json ]`)\n\trequire.EqualError(t, err, \"JSON error: invalid character 'i' looking for beginning of value\")\n\n\t_, err = parseActions(`[ { \"some\": \"object\" } ]`)\n\trequire.EqualError(t, err, \"parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'\")\n\n\t_, err = parseActions(\"\\x00\\x01\\xFFx\\xFE\")\n\trequire.EqualError(t, err, \"invalid utf-8 string\")\n\n\t_, err = parseActions(`http, label, http://x.org, clear=x`)\n\trequire.EqualError(t, err, \"parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)\")\n\n}\n"
  },
  {
    "path": "server/config.go",
    "content": "package server\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"net/netip\"\n\t\"reflect\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/user\"\n)\n\n// Defines default config settings (excluding limits, see below)\nconst (\n\tDefaultListenHTTP                           = \":80\"\n\tDefaultCacheDuration                        = 12 * time.Hour\n\tDefaultCacheBatchTimeout                    = time.Duration(0)\n\tDefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)\n\tDefaultManagerInterval                      = time.Minute\n\tDefaultDelayedSenderInterval                = 10 * time.Second\n\tDefaultMessageDelayMin                      = 10 * time.Second\n\tDefaultMessageDelayMax                      = 3 * 24 * time.Hour\n\tDefaultFirebaseKeepaliveInterval            = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery\n\tDefaultFirebasePollInterval                 = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)\n\tDefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns \"quota exceeded\"\n\tDefaultStripePriceCacheDuration             = 3 * time.Hour    // Time to keep Stripe prices cached in memory before a refresh is needed\n)\n\n// Platform-specific default paths (set in config_unix.go or config_windows.go)\nvar (\n\tDefaultConfigFile  string\n\tDefaultTemplateDir string\n)\n\n// Defines default Web Push settings\nconst (\n\tDefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour\n\tDefaultWebPushExpiryDuration        = 60 * 24 * time.Hour\n)\n\n// Defines all global and per-visitor limits\n// - message size limit: the max number of bytes for a message\n// - total topic limit: max number of topics overall\n// - various attachment limits\nconst (\n\tDefaultMessageSizeLimit         = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message\n\tDefaultTotalTopicLimit          = 15000\n\tDefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB\n\tDefaultAttachmentFileSizeLimit  = int64(15 * 1024 * 1024)       // 15 MB\n\tDefaultAttachmentExpiryDuration = 3 * time.Hour\n)\n\n// Defines all per-visitor limits\n// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP\n// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 5 seconds)\n// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)\n// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server\n// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server\nconst (\n\tDefaultVisitorSubscriptionLimit             = 30\n\tDefaultVisitorRequestLimitBurst             = 60\n\tDefaultVisitorRequestLimitReplenish         = 5 * time.Second\n\tDefaultVisitorMessageDailyLimit             = 0\n\tDefaultVisitorEmailLimitBurst               = 16\n\tDefaultVisitorEmailLimitReplenish           = time.Hour\n\tDefaultVisitorAccountCreationLimitBurst     = 3\n\tDefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour\n\tDefaultVisitorAuthFailureLimitBurst         = 30\n\tDefaultVisitorAuthFailureLimitReplenish     = time.Minute\n\tDefaultVisitorAttachmentTotalSizeLimit      = 100 * 1024 * 1024 // 100 MB\n\tDefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB\n\tDefaultVisitorPrefixBitsIPv4                = 32                // Use the entire IPv4 address for rate limiting\n\tDefaultVisitorPrefixBitsIPv6                = 64                // Use /64 for IPv6 rate limiting\n)\n\nvar (\n\t// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)\n\tDefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)\n\n\t// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be\n\t// extended using the server.yml config. If updated, also update in Android and web app.\n\tDefaultDisallowedTopics = []string{\"docs\", \"static\", \"file\", \"app\", \"metrics\", \"account\", \"settings\", \"signup\", \"login\", \"v1\"}\n)\n\n// Config is the main config struct for the application. Use New to instantiate a default config struct.\ntype Config struct {\n\tFile                                 string // Config file, only used for testing\n\tBaseURL                              string\n\tListenHTTP                           string\n\tListenHTTPS                          string\n\tListenUnix                           string\n\tListenUnixMode                       fs.FileMode\n\tKeyFile                              string\n\tCertFile                             string\n\tDatabaseURL                          string   // PostgreSQL connection string (e.g. \"postgres://user:pass@host:5432/ntfy\")\n\tDatabaseReplicaURLs                  []string // PostgreSQL read replica connection strings\n\tFirebaseKeyFile                      string\n\tCacheFile                            string\n\tCacheDuration                        time.Duration\n\tCacheStartupQueries                  string\n\tCacheBatchSize                       int\n\tCacheBatchTimeout                    time.Duration\n\tAuthFile                             string\n\tAuthStartupQueries                   string\n\tAuthDefault                          user.Permission\n\tAuthUsers                            []*user.User\n\tAuthAccess                           map[string][]*user.Grant\n\tAuthTokens                           map[string][]*user.Token\n\tAuthBcryptCost                       int\n\tAuthStatsQueueWriterInterval         time.Duration\n\tAttachmentCacheDir                   string\n\tAttachmentTotalSizeLimit             int64\n\tAttachmentFileSizeLimit              int64\n\tAttachmentExpiryDuration             time.Duration\n\tTemplateDir                          string // Directory to load named templates from\n\tKeepaliveInterval                    time.Duration\n\tManagerInterval                      time.Duration\n\tDisallowedTopics                     []string\n\tWebRoot                              string // empty to disable\n\tDelayedSenderInterval                time.Duration\n\tFirebaseKeepaliveInterval            time.Duration\n\tFirebasePollInterval                 time.Duration\n\tFirebaseQuotaExceededPenaltyDuration time.Duration\n\tUpstreamBaseURL                      string\n\tUpstreamAccessToken                  string\n\tSMTPSenderAddr                       string\n\tSMTPSenderUser                       string\n\tSMTPSenderPass                       string\n\tSMTPSenderFrom                       string\n\tSMTPServerListen                     string\n\tSMTPServerDomain                     string\n\tSMTPServerAddrPrefix                 string\n\tTwilioAccount                        string\n\tTwilioAuthToken                      string\n\tTwilioPhoneNumber                    string\n\tTwilioCallsBaseURL                   string\n\tTwilioVerifyBaseURL                  string\n\tTwilioVerifyService                  string\n\tTwilioCallFormat                     *template.Template\n\tMetricsEnable                        bool\n\tMetricsListenHTTP                    string\n\tProfileListenHTTP                    string\n\tMessageDelayMin                      time.Duration\n\tMessageDelayMax                      time.Duration\n\tMessageSizeLimit                     int\n\tTotalTopicLimit                      int\n\tTotalAttachmentSizeLimit             int64\n\tVisitorSubscriptionLimit             int\n\tVisitorAttachmentTotalSizeLimit      int64\n\tVisitorAttachmentDailyBandwidthLimit int64\n\tVisitorRequestLimitBurst             int\n\tVisitorRequestLimitReplenish         time.Duration\n\tVisitorRequestExemptPrefixes         []netip.Prefix\n\tVisitorMessageDailyLimit             int\n\tVisitorEmailLimitBurst               int\n\tVisitorEmailLimitReplenish           time.Duration\n\tVisitorAccountCreationLimitBurst     int\n\tVisitorAccountCreationLimitReplenish time.Duration\n\tVisitorAuthFailureLimitBurst         int\n\tVisitorAuthFailureLimitReplenish     time.Duration\n\tVisitorStatsResetTime                time.Time      // Time of the day at which to reset visitor stats\n\tVisitorSubscriberRateLimiting        bool           // Enable subscriber-based rate limiting for UnifiedPush topics\n\tVisitorPrefixBitsIPv4                int            // Number of bits for IPv4 rate limiting (default: 32)\n\tVisitorPrefixBitsIPv6                int            // Number of bits for IPv6 rate limiting (default: 64)\n\tBehindProxy                          bool           // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)\n\tProxyForwardedHeader                 string         // The header field to read the real/client IP address from, if BehindProxy is true, defaults to \"X-Forwarded-For\" (IPv4 and IPv6 supported)\n\tProxyTrustedPrefixes                 []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true\n\tStripeSecretKey                      string\n\tStripeWebhookKey                     string\n\tStripePriceCacheDuration             time.Duration\n\tBillingContact                       string\n\tEnableSignup                         bool // Enable creation of accounts via API and UI\n\tEnableLogin                          bool\n\tRequireLogin                         bool\n\tEnableReservations                   bool // Allow users with role \"user\" to own/reserve topics\n\tEnableMetrics                        bool\n\tAccessControlAllowOrigin             string // CORS header field to restrict access from web clients\n\tWebPushPrivateKey                    string\n\tWebPushPublicKey                     string\n\tWebPushFile                          string\n\tWebPushEmailAddress                  string\n\tWebPushStartupQueries                string\n\tWebPushExpiryDuration                time.Duration\n\tWebPushExpiryWarningDuration         time.Duration\n\tBuildVersion                         string // Injected by App\n\tBuildDate                            string // Injected by App\n\tBuildCommit                          string // Injected by App\n}\n\n// NewConfig instantiates a default new server config\nfunc NewConfig() *Config {\n\treturn &Config{\n\t\tFile:                                 DefaultConfigFile, // Only used for testing\n\t\tBaseURL:                              \"\",\n\t\tListenHTTP:                           DefaultListenHTTP,\n\t\tListenHTTPS:                          \"\",\n\t\tListenUnix:                           \"\",\n\t\tListenUnixMode:                       0,\n\t\tKeyFile:                              \"\",\n\t\tCertFile:                             \"\",\n\t\tDatabaseURL:                          \"\",\n\t\tFirebaseKeyFile:                      \"\",\n\t\tCacheFile:                            \"\",\n\t\tCacheDuration:                        DefaultCacheDuration,\n\t\tCacheStartupQueries:                  \"\",\n\t\tCacheBatchSize:                       0,\n\t\tCacheBatchTimeout:                    0,\n\t\tAuthFile:                             \"\",\n\t\tAuthStartupQueries:                   \"\",\n\t\tAuthDefault:                          user.PermissionReadWrite,\n\t\tAuthBcryptCost:                       user.DefaultUserPasswordBcryptCost,\n\t\tAuthStatsQueueWriterInterval:         user.DefaultUserStatsQueueWriterInterval,\n\t\tAttachmentCacheDir:                   \"\",\n\t\tAttachmentTotalSizeLimit:             DefaultAttachmentTotalSizeLimit,\n\t\tAttachmentFileSizeLimit:              DefaultAttachmentFileSizeLimit,\n\t\tAttachmentExpiryDuration:             DefaultAttachmentExpiryDuration,\n\t\tTemplateDir:                          DefaultTemplateDir,\n\t\tKeepaliveInterval:                    DefaultKeepaliveInterval,\n\t\tManagerInterval:                      DefaultManagerInterval,\n\t\tDisallowedTopics:                     DefaultDisallowedTopics,\n\t\tWebRoot:                              \"/\",\n\t\tDelayedSenderInterval:                DefaultDelayedSenderInterval,\n\t\tFirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,\n\t\tFirebasePollInterval:                 DefaultFirebasePollInterval,\n\t\tFirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,\n\t\tUpstreamBaseURL:                      \"\",\n\t\tUpstreamAccessToken:                  \"\",\n\t\tSMTPSenderAddr:                       \"\",\n\t\tSMTPSenderUser:                       \"\",\n\t\tSMTPSenderPass:                       \"\",\n\t\tSMTPSenderFrom:                       \"\",\n\t\tSMTPServerListen:                     \"\",\n\t\tSMTPServerDomain:                     \"\",\n\t\tSMTPServerAddrPrefix:                 \"\",\n\t\tTwilioCallsBaseURL:                   \"https://api.twilio.com\", // Override for tests\n\t\tTwilioAccount:                        \"\",\n\t\tTwilioAuthToken:                      \"\",\n\t\tTwilioPhoneNumber:                    \"\",\n\t\tTwilioVerifyBaseURL:                  \"https://verify.twilio.com\", // Override for tests\n\t\tTwilioVerifyService:                  \"\",\n\t\tTwilioCallFormat:                     nil,\n\t\tMessageSizeLimit:                     DefaultMessageSizeLimit,\n\t\tMessageDelayMin:                      DefaultMessageDelayMin,\n\t\tMessageDelayMax:                      DefaultMessageDelayMax,\n\t\tTotalTopicLimit:                      DefaultTotalTopicLimit,\n\t\tTotalAttachmentSizeLimit:             0,\n\t\tVisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,\n\t\tVisitorSubscriberRateLimiting:        false,\n\t\tVisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit,\n\t\tVisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,\n\t\tVisitorRequestLimitBurst:             DefaultVisitorRequestLimitBurst,\n\t\tVisitorRequestLimitReplenish:         DefaultVisitorRequestLimitReplenish,\n\t\tVisitorRequestExemptPrefixes:         make([]netip.Prefix, 0),\n\t\tVisitorMessageDailyLimit:             DefaultVisitorMessageDailyLimit,\n\t\tVisitorEmailLimitBurst:               DefaultVisitorEmailLimitBurst,\n\t\tVisitorEmailLimitReplenish:           DefaultVisitorEmailLimitReplenish,\n\t\tVisitorAccountCreationLimitBurst:     DefaultVisitorAccountCreationLimitBurst,\n\t\tVisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,\n\t\tVisitorAuthFailureLimitBurst:         DefaultVisitorAuthFailureLimitBurst,\n\t\tVisitorAuthFailureLimitReplenish:     DefaultVisitorAuthFailureLimitReplenish,\n\t\tVisitorStatsResetTime:                DefaultVisitorStatsResetTime,\n\t\tVisitorPrefixBitsIPv4:                DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address\n\t\tVisitorPrefixBitsIPv6:                DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6\n\t\tBehindProxy:                          false,                        // If true, the server will trust the proxy client IP header to determine the client IP address\n\t\tProxyForwardedHeader:                 \"X-Forwarded-For\",            // Default header for reverse proxy client IPs\n\t\tStripeSecretKey:                      \"\",\n\t\tStripeWebhookKey:                     \"\",\n\t\tStripePriceCacheDuration:             DefaultStripePriceCacheDuration,\n\t\tBillingContact:                       \"\",\n\t\tEnableSignup:                         false,\n\t\tEnableLogin:                          false,\n\t\tEnableReservations:                   false,\n\t\tRequireLogin:                         false,\n\t\tAccessControlAllowOrigin:             \"*\",\n\t\tWebPushPrivateKey:                    \"\",\n\t\tWebPushPublicKey:                     \"\",\n\t\tWebPushFile:                          \"\",\n\t\tWebPushEmailAddress:                  \"\",\n\t\tWebPushExpiryDuration:                DefaultWebPushExpiryDuration,\n\t\tWebPushExpiryWarningDuration:         DefaultWebPushExpiryWarningDuration,\n\t\tBuildVersion:                         \"\",\n\t\tBuildDate:                            \"\",\n\t\tBuildCommit:                          \"\",\n\t}\n}\n\n// Hash computes an SHA-256 hash of the configuration. This is used to detect\n// configuration changes for the web app version check feature. It uses reflection\n// to include all JSON-serializable fields automatically.\nfunc (c *Config) Hash() string {\n\tv := reflect.ValueOf(*c)\n\tt := v.Type()\n\tvar result string\n\tfor i := 0; i < v.NumField(); i++ {\n\t\tfield := v.Field(i)\n\t\tfieldName := t.Field(i).Name\n\t\t// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)\n\t\tif b, err := json.Marshal(field.Interface()); err == nil {\n\t\t\tresult += fmt.Sprintf(\"%s:%s|\", fieldName, string(b))\n\t\t}\n\t}\n\treturn fmt.Sprintf(\"%x\", sha256.Sum256([]byte(result)))\n}\n"
  },
  {
    "path": "server/config_test.go",
    "content": "package server_test\n\nimport (\n\t\"github.com/stretchr/testify/assert\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"testing\"\n)\n\nfunc TestConfig_New(t *testing.T) {\n\tc := server.NewConfig()\n\tassert.Equal(t, \":80\", c.ListenHTTP)\n\tassert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)\n}\n"
  },
  {
    "path": "server/config_unix.go",
    "content": "//go:build !windows\n\npackage server\n\nfunc init() {\n\tDefaultConfigFile = \"/etc/ntfy/server.yml\"\n\tDefaultTemplateDir = \"/etc/ntfy/templates\"\n}\n"
  },
  {
    "path": "server/config_windows.go",
    "content": "//go:build windows\n\npackage server\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc init() {\n\tprogramData := os.Getenv(\"ProgramData\")\n\tif programData == \"\" {\n\t\tprogramData = `C:\\ProgramData`\n\t}\n\tDefaultConfigFile = filepath.Join(programData, \"ntfy\", \"server.yml\")\n\tDefaultTemplateDir = filepath.Join(programData, \"ntfy\", \"templates\")\n}\n"
  },
  {
    "path": "server/errors.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"heckel.io/ntfy/v2/log\"\n)\n\n// errHTTP is a generic HTTP error for any non-200 HTTP error\ntype errHTTP struct {\n\tCode     int    `json:\"code,omitempty\"`\n\tHTTPCode int    `json:\"http\"`\n\tMessage  string `json:\"error\"`\n\tLink     string `json:\"link,omitempty\"`\n\tcontext  log.Context\n}\n\nfunc (e errHTTP) Error() string {\n\treturn e.Message\n}\n\nfunc (e errHTTP) JSON() string {\n\tb, _ := json.Marshal(&e)\n\treturn string(b)\n}\n\nfunc (e errHTTP) Context() log.Context {\n\tcontext := log.Context{\n\t\t\"error\":       e.Message,\n\t\t\"error_code\":  e.Code,\n\t\t\"http_status\": e.HTTPCode,\n\t}\n\tfor k, v := range e.context {\n\t\tcontext[k] = v\n\t}\n\treturn context\n}\n\nfunc (e errHTTP) Wrap(message string, args ...any) *errHTTP {\n\tclone := e.clone()\n\tclone.Message = fmt.Sprintf(\"%s; %s\", clone.Message, fmt.Sprintf(message, args...))\n\treturn &clone\n}\n\nfunc (e errHTTP) With(contexters ...log.Contexter) *errHTTP {\n\tc := e.clone()\n\tif c.context == nil {\n\t\tc.context = make(log.Context)\n\t}\n\tfor _, contexter := range contexters {\n\t\tc.context.Merge(contexter.Context())\n\t}\n\treturn &c\n}\n\nfunc (e errHTTP) Fields(context log.Context) *errHTTP {\n\tc := e.clone()\n\tif c.context == nil {\n\t\tc.context = make(log.Context)\n\t}\n\tc.context.Merge(context)\n\treturn &c\n}\n\nfunc (e errHTTP) clone() errHTTP {\n\tcontext := make(log.Context)\n\tfor k, v := range e.context {\n\t\tcontext[k] = v\n\t}\n\treturn errHTTP{\n\t\tCode:     e.Code,\n\t\tHTTPCode: e.HTTPCode,\n\t\tMessage:  e.Message,\n\t\tLink:     e.Link,\n\t\tcontext:  context,\n\t}\n}\n\n// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket\n// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling\n// WriteHeader on hijacked connections, which causes log spam.\ntype errWebSocketPostUpgrade struct {\n\terr error\n}\n\nfunc (e *errWebSocketPostUpgrade) Error() string {\n\treturn e.err.Error()\n}\n\nfunc (e *errWebSocketPostUpgrade) Unwrap() error {\n\treturn e.err\n}\n\nvar (\n\terrHTTPBadRequest                                = &errHTTP{40000, http.StatusBadRequest, \"invalid request\", \"\", nil}\n\terrHTTPBadRequestEmailDisabled                   = &errHTTP{40001, http.StatusBadRequest, \"e-mail notifications are not enabled\", \"https://ntfy.sh/docs/config/#e-mail-notifications\", nil}\n\terrHTTPBadRequestDelayNoCache                    = &errHTTP{40002, http.StatusBadRequest, \"cannot disable cache for delayed message\", \"\", nil}\n\terrHTTPBadRequestDelayNoEmail                    = &errHTTP{40003, http.StatusBadRequest, \"delayed e-mail notifications are not supported\", \"\", nil}\n\terrHTTPBadRequestDelayCannotParse                = &errHTTP{40004, http.StatusBadRequest, \"invalid delay parameter: unable to parse delay\", \"https://ntfy.sh/docs/publish/#scheduled-delivery\", nil}\n\terrHTTPBadRequestDelayTooSmall                   = &errHTTP{40005, http.StatusBadRequest, \"invalid delay parameter: too small, please refer to the docs\", \"https://ntfy.sh/docs/publish/#scheduled-delivery\", nil}\n\terrHTTPBadRequestDelayTooLarge                   = &errHTTP{40006, http.StatusBadRequest, \"invalid delay parameter: too large, please refer to the docs\", \"https://ntfy.sh/docs/publish/#scheduled-delivery\", nil}\n\terrHTTPBadRequestPriorityInvalid                 = &errHTTP{40007, http.StatusBadRequest, \"invalid priority parameter\", \"https://ntfy.sh/docs/publish/#message-priority\", nil}\n\terrHTTPBadRequestSinceInvalid                    = &errHTTP{40008, http.StatusBadRequest, \"invalid since parameter\", \"https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages\", nil}\n\terrHTTPBadRequestTopicInvalid                    = &errHTTP{40009, http.StatusBadRequest, \"invalid request: topic invalid\", \"\", nil}\n\terrHTTPBadRequestTopicDisallowed                 = &errHTTP{40010, http.StatusBadRequest, \"invalid request: topic name is not allowed\", \"\", nil}\n\terrHTTPBadRequestMessageNotUTF8                  = &errHTTP{40011, http.StatusBadRequest, \"invalid request: message must be UTF-8 encoded\", \"\", nil}\n\terrHTTPBadRequestAttachmentURLInvalid            = &errHTTP{40013, http.StatusBadRequest, \"invalid request: attachment URL is invalid\", \"https://ntfy.sh/docs/publish/#attachments\", nil}\n\terrHTTPBadRequestAttachmentsDisallowed           = &errHTTP{40014, http.StatusBadRequest, \"invalid request: attachments not allowed\", \"https://ntfy.sh/docs/config/#attachments\", nil}\n\terrHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, \"invalid request: attachment expiry before delayed delivery date\", \"https://ntfy.sh/docs/publish/#scheduled-delivery\", nil}\n\terrHTTPBadRequestWebSocketsUpgradeHeaderMissing  = &errHTTP{40016, http.StatusBadRequest, \"invalid request: client not using the websocket protocol\", \"https://ntfy.sh/docs/subscribe/api/#websockets\", nil}\n\terrHTTPBadRequestMessageJSONInvalid              = &errHTTP{40017, http.StatusBadRequest, \"invalid request: request body must be message JSON\", \"https://ntfy.sh/docs/publish/#publish-as-json\", nil}\n\terrHTTPBadRequestActionsInvalid                  = &errHTTP{40018, http.StatusBadRequest, \"invalid request: actions invalid\", \"https://ntfy.sh/docs/publish/#action-buttons\", nil}\n\terrHTTPBadRequestMatrixMessageInvalid            = &errHTTP{40019, http.StatusBadRequest, \"invalid request: Matrix JSON invalid\", \"https://ntfy.sh/docs/publish/#matrix-gateway\", nil}\n\terrHTTPBadRequestIconURLInvalid                  = &errHTTP{40021, http.StatusBadRequest, \"invalid request: icon URL is invalid\", \"https://ntfy.sh/docs/publish/#icons\", nil}\n\terrHTTPBadRequestSignupNotEnabled                = &errHTTP{40022, http.StatusBadRequest, \"invalid request: signup not enabled\", \"https://ntfy.sh/docs/config\", nil}\n\terrHTTPBadRequestNoTokenProvided                 = &errHTTP{40023, http.StatusBadRequest, \"invalid request: no token provided\", \"\", nil}\n\terrHTTPBadRequestJSONInvalid                     = &errHTTP{40024, http.StatusBadRequest, \"invalid request: request body must be valid JSON\", \"\", nil}\n\terrHTTPBadRequestPermissionInvalid               = &errHTTP{40025, http.StatusBadRequest, \"invalid request: incorrect permission string\", \"\", nil}\n\terrHTTPBadRequestIncorrectPasswordConfirmation   = &errHTTP{40026, http.StatusBadRequest, \"invalid request: password confirmation is not correct\", \"\", nil}\n\terrHTTPBadRequestNotAPaidUser                    = &errHTTP{40027, http.StatusBadRequest, \"invalid request: not a paid user\", \"\", nil}\n\terrHTTPBadRequestBillingRequestInvalid           = &errHTTP{40028, http.StatusBadRequest, \"invalid request: not a valid billing request\", \"\", nil}\n\terrHTTPBadRequestBillingSubscriptionExists       = &errHTTP{40029, http.StatusBadRequest, \"invalid request: billing subscription already exists\", \"\", nil}\n\terrHTTPBadRequestTierInvalid                     = &errHTTP{40030, http.StatusBadRequest, \"invalid request: tier does not exist\", \"\", nil}\n\terrHTTPBadRequestUserNotFound                    = &errHTTP{40031, http.StatusBadRequest, \"invalid request: user does not exist\", \"\", nil}\n\terrHTTPBadRequestPhoneCallsDisabled              = &errHTTP{40032, http.StatusBadRequest, \"invalid request: calling is disabled\", \"https://ntfy.sh/docs/config/#phone-calls\", nil}\n\terrHTTPBadRequestPhoneNumberInvalid              = &errHTTP{40033, http.StatusBadRequest, \"invalid request: phone number invalid\", \"https://ntfy.sh/docs/publish/#phone-calls\", nil}\n\terrHTTPBadRequestPhoneNumberNotVerified          = &errHTTP{40034, http.StatusBadRequest, \"invalid request: phone number not verified, or no matching verified numbers found\", \"https://ntfy.sh/docs/publish/#phone-calls\", nil}\n\terrHTTPBadRequestAnonymousCallsNotAllowed        = &errHTTP{40035, http.StatusBadRequest, \"invalid request: anonymous phone calls are not allowed\", \"https://ntfy.sh/docs/publish/#phone-calls\", nil}\n\terrHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, \"invalid request: verification channel must be 'sms' or 'call'\", \"https://ntfy.sh/docs/publish/#phone-calls\", nil}\n\terrHTTPBadRequestDelayNoCall                     = &errHTTP{40037, http.StatusBadRequest, \"invalid request: delayed call notifications are not supported\", \"\", nil}\n\terrHTTPBadRequestWebPushSubscriptionInvalid      = &errHTTP{40038, http.StatusBadRequest, \"invalid request: web push payload malformed\", \"\", nil}\n\terrHTTPBadRequestWebPushEndpointUnknown          = &errHTTP{40039, http.StatusBadRequest, \"invalid request: web push endpoint unknown\", \"\", nil}\n\terrHTTPBadRequestWebPushTopicCountTooHigh        = &errHTTP{40040, http.StatusBadRequest, \"invalid request: too many web push topic subscriptions\", \"\", nil}\n\terrHTTPBadRequestTemplateMessageTooLarge         = &errHTTP{40041, http.StatusBadRequest, \"invalid request: message or title is too large after replacing template\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestTemplateMessageNotJSON          = &errHTTP{40042, http.StatusBadRequest, \"invalid request: message body must be JSON if templating is enabled\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestTemplateInvalid                 = &errHTTP{40043, http.StatusBadRequest, \"invalid request: could not parse template\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, \"invalid request: template contains disallowed function calls, e.g. template, call, or define\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestTemplateExecuteFailed           = &errHTTP{40045, http.StatusBadRequest, \"invalid request: template execution failed\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestInvalidUsername                 = &errHTTP{40046, http.StatusBadRequest, \"invalid request: invalid username\", \"\", nil}\n\terrHTTPBadRequestTemplateFileNotFound            = &errHTTP{40047, http.StatusBadRequest, \"invalid request: template file not found\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestTemplateFileInvalid             = &errHTTP{40048, http.StatusBadRequest, \"invalid request: template file invalid\", \"https://ntfy.sh/docs/publish/#message-templating\", nil}\n\terrHTTPBadRequestSequenceIDInvalid               = &errHTTP{40049, http.StatusBadRequest, \"invalid request: sequence ID invalid\", \"https://ntfy.sh/docs/publish/#updating-deleting-notifications\", nil}\n\terrHTTPBadRequestEmailAddressInvalid             = &errHTTP{40050, http.StatusBadRequest, \"invalid request: invalid e-mail address\", \"https://ntfy.sh/docs/publish/#e-mail-notifications\", nil}\n\terrHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, \"page not found\", \"\", nil}\n\terrHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, \"unauthorized\", \"https://ntfy.sh/docs/publish/#authentication\", nil}\n\terrHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, \"forbidden\", \"https://ntfy.sh/docs/publish/#authentication\", nil}\n\terrHTTPConflictUserExists                        = &errHTTP{40901, http.StatusConflict, \"conflict: user already exists\", \"\", nil}\n\terrHTTPConflictTopicReserved                     = &errHTTP{40902, http.StatusConflict, \"conflict: access control entry for topic or topic pattern already exists\", \"\", nil}\n\terrHTTPConflictSubscriptionExists                = &errHTTP{40903, http.StatusConflict, \"conflict: topic subscription already exists\", \"\", nil}\n\terrHTTPConflictPhoneNumberExists                 = &errHTTP{40904, http.StatusConflict, \"conflict: phone number already exists\", \"\", nil}\n\terrHTTPConflictProvisionedUserChange             = &errHTTP{40905, http.StatusConflict, \"conflict: cannot change or delete provisioned user\", \"\", nil}\n\terrHTTPConflictProvisionedTokenChange            = &errHTTP{40906, http.StatusConflict, \"conflict: cannot change or delete provisioned token\", \"\", nil}\n\terrHTTPGonePhoneVerificationExpired              = &errHTTP{41001, http.StatusGone, \"phone number verification expired or does not exist\", \"\", nil}\n\terrHTTPEntityTooLargeAttachment                  = &errHTTP{41301, http.StatusRequestEntityTooLarge, \"attachment too large, or bandwidth limit reached\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPEntityTooLargeMatrixRequest               = &errHTTP{41302, http.StatusRequestEntityTooLarge, \"Matrix request is larger than the max allowed length\", \"\", nil}\n\terrHTTPEntityTooLargeJSONBody                    = &errHTTP{41303, http.StatusRequestEntityTooLarge, \"JSON body too large\", \"\", nil}\n\terrHTTPTooManyRequestsLimitRequests              = &errHTTP{42901, http.StatusTooManyRequests, \"limit reached: too many requests\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitEmails                = &errHTTP{42902, http.StatusTooManyRequests, \"limit reached: too many emails\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitSubscriptions         = &errHTTP{42903, http.StatusTooManyRequests, \"limit reached: too many active subscriptions\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitTotalTopics           = &errHTTP{42904, http.StatusTooManyRequests, \"limit reached: the total number of topics on the server has been reached, please contact the admin\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitAttachmentBandwidth   = &errHTTP{42905, http.StatusTooManyRequests, \"limit reached: daily bandwidth reached\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitAccountCreation       = &errHTTP{42906, http.StatusTooManyRequests, \"limit reached: too many accounts created\", \"https://ntfy.sh/docs/publish/#limitations\", nil} // FIXME document limit\n\terrHTTPTooManyRequestsLimitReservations          = &errHTTP{42907, http.StatusTooManyRequests, \"limit reached: too many topic reservations for this user\", \"\", nil}\n\terrHTTPTooManyRequestsLimitMessages              = &errHTTP{42908, http.StatusTooManyRequests, \"limit reached: daily message quota reached\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPTooManyRequestsLimitAuthFailure           = &errHTTP{42909, http.StatusTooManyRequests, \"limit reached: too many auth failures\", \"https://ntfy.sh/docs/publish/#limitations\", nil} // FIXME document limit\n\terrHTTPTooManyRequestsLimitCalls                 = &errHTTP{42910, http.StatusTooManyRequests, \"limit reached: daily phone call quota reached\", \"https://ntfy.sh/docs/publish/#limitations\", nil}\n\terrHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, \"internal server error\", \"\", nil}\n\terrHTTPInternalErrorInvalidPath                  = &errHTTP{50002, http.StatusInternalServerError, \"internal server error: invalid path\", \"\", nil}\n\terrHTTPInternalErrorMissingBaseURL               = &errHTTP{50003, http.StatusInternalServerError, \"internal server error: base-url must be be configured for this feature\", \"https://ntfy.sh/docs/config/\", nil}\n\terrHTTPInternalErrorWebPushUnableToPublish       = &errHTTP{50004, http.StatusInternalServerError, \"internal server error: unable to publish web push message\", \"\", nil}\n\terrHTTPInsufficientStorageUnifiedPush            = &errHTTP{50701, http.StatusInsufficientStorage, \"cannot publish to UnifiedPush topic without previously active subscriber\", \"\", nil}\n)\n"
  },
  {
    "path": "server/file_cache.go",
    "content": "package server\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sync\"\n)\n\nvar (\n\tfileIDRegex      = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength))\n\terrInvalidFileID = errors.New(\"invalid file ID\")\n\terrFileExists    = errors.New(\"file exists\")\n)\n\ntype fileCache struct {\n\tdir              string\n\ttotalSizeCurrent int64\n\ttotalSizeLimit   int64\n\tmu               sync.Mutex\n}\n\nfunc newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) {\n\tif err := os.MkdirAll(dir, 0700); err != nil {\n\t\treturn nil, err\n\t}\n\tsize, err := dirSize(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &fileCache{\n\t\tdir:              dir,\n\t\ttotalSizeCurrent: size,\n\t\ttotalSizeLimit:   totalSizeLimit,\n\t}, nil\n}\n\nfunc (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) {\n\tif !fileIDRegex.MatchString(id) {\n\t\treturn 0, errInvalidFileID\n\t}\n\tlog.Tag(tagFileCache).Field(\"message_id\", id).Debug(\"Writing attachment\")\n\tfile := filepath.Join(c.dir, id)\n\tif _, err := os.Stat(file); err == nil {\n\t\treturn 0, errFileExists\n\t}\n\tf, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer f.Close()\n\tlimiters = append(limiters, util.NewFixedLimiter(c.Remaining()))\n\tlimitWriter := util.NewLimitWriter(f, limiters...)\n\tsize, err := io.Copy(limitWriter, in)\n\tif err != nil {\n\t\tos.Remove(file)\n\t\treturn 0, err\n\t}\n\tif err := f.Close(); err != nil {\n\t\tos.Remove(file)\n\t\treturn 0, err\n\t}\n\tc.mu.Lock()\n\tc.totalSizeCurrent += size\n\tmset(metricAttachmentsTotalSize, c.totalSizeCurrent)\n\tc.mu.Unlock()\n\treturn size, nil\n}\n\nfunc (c *fileCache) Remove(ids ...string) error {\n\tfor _, id := range ids {\n\t\tif !fileIDRegex.MatchString(id) {\n\t\t\treturn errInvalidFileID\n\t\t}\n\t\tlog.Tag(tagFileCache).Field(\"message_id\", id).Debug(\"Deleting attachment\")\n\t\tfile := filepath.Join(c.dir, id)\n\t\tif err := os.Remove(file); err != nil {\n\t\t\tlog.Tag(tagFileCache).Field(\"message_id\", id).Err(err).Debug(\"Error deleting attachment\")\n\t\t}\n\t}\n\tsize, err := dirSize(c.dir)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.mu.Lock()\n\tc.totalSizeCurrent = size\n\tc.mu.Unlock()\n\tmset(metricAttachmentsTotalSize, size)\n\treturn nil\n}\n\nfunc (c *fileCache) Size() int64 {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\treturn c.totalSizeCurrent\n}\n\nfunc (c *fileCache) Remaining() int64 {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tremaining := c.totalSizeLimit - c.totalSizeCurrent\n\tif remaining < 0 {\n\t\treturn 0\n\t}\n\treturn remaining\n}\n\nfunc dirSize(dir string) (int64, error) {\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar size int64\n\tfor _, e := range entries {\n\t\tinfo, err := e.Info()\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tsize += info.Size()\n\t}\n\treturn size, nil\n}\n"
  },
  {
    "path": "server/file_cache_test.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nvar (\n\toneKilobyteArray = make([]byte, 1024)\n)\n\nfunc TestFileCache_Write_Success(t *testing.T) {\n\tdir, c := newTestFileCache(t)\n\tsize, err := c.Write(\"abcdefghijkl\", strings.NewReader(\"normal file\"), util.NewFixedLimiter(999))\n\trequire.Nil(t, err)\n\trequire.Equal(t, int64(11), size)\n\trequire.Equal(t, \"normal file\", readFile(t, dir+\"/abcdefghijkl\"))\n\trequire.Equal(t, int64(11), c.Size())\n\trequire.Equal(t, int64(10229), c.Remaining())\n}\n\nfunc TestFileCache_Write_Remove_Success(t *testing.T) {\n\tdir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)\n\tfor i := 0; i < 10; i++ {     // 10x999 = 9990\n\t\tsize, err := c.Write(fmt.Sprintf(\"abcdefghijk%d\", i), bytes.NewReader(make([]byte, 999)))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(999), size)\n\t}\n\trequire.Equal(t, int64(9990), c.Size())\n\trequire.Equal(t, int64(250), c.Remaining())\n\trequire.FileExists(t, dir+\"/abcdefghijk1\")\n\trequire.FileExists(t, dir+\"/abcdefghijk5\")\n\n\trequire.Nil(t, c.Remove(\"abcdefghijk1\", \"abcdefghijk5\"))\n\trequire.NoFileExists(t, dir+\"/abcdefghijk1\")\n\trequire.NoFileExists(t, dir+\"/abcdefghijk5\")\n\trequire.Equal(t, int64(7992), c.Size())\n\trequire.Equal(t, int64(2248), c.Remaining())\n}\n\nfunc TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {\n\tdir, c := newTestFileCache(t)\n\tfor i := 0; i < 10; i++ {\n\t\tsize, err := c.Write(fmt.Sprintf(\"abcdefghijk%d\", i), bytes.NewReader(oneKilobyteArray))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(1024), size)\n\t}\n\t_, err := c.Write(\"abcdefghijkX\", bytes.NewReader(oneKilobyteArray))\n\trequire.Equal(t, util.ErrLimitReached, err)\n\trequire.NoFileExists(t, dir+\"/abcdefghijkX\")\n}\n\nfunc TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {\n\tdir, c := newTestFileCache(t)\n\t_, err := c.Write(\"abcdefghijkl\", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))\n\trequire.Equal(t, util.ErrLimitReached, err)\n\trequire.NoFileExists(t, dir+\"/abcdefghijkl\")\n}\n\nfunc newTestFileCache(t *testing.T) (dir string, cache *fileCache) {\n\tdir = t.TempDir()\n\tcache, err := newFileCache(dir, 10*1024)\n\trequire.Nil(t, err)\n\treturn dir, cache\n}\n\nfunc readFile(t *testing.T, f string) string {\n\tb, err := os.ReadFile(f)\n\trequire.Nil(t, err)\n\treturn string(b)\n}\n"
  },
  {
    "path": "server/log.go",
    "content": "package server\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/emersion/go-smtp\"\n\t\"github.com/gorilla/websocket\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// Log tags\nconst (\n\ttagStartup      = \"startup\"\n\ttagHTTP         = \"http\"\n\ttagPublish      = \"publish\"\n\ttagSubscribe    = \"subscribe\"\n\ttagFirebase     = \"firebase\"\n\ttagSMTP         = \"smtp\"  // Receive email\n\ttagEmail        = \"email\" // Send email\n\ttagTwilio       = \"twilio\"\n\ttagFileCache    = \"file_cache\"\n\ttagMessageCache = \"message_cache\"\n\ttagStripe       = \"stripe\"\n\ttagAccount      = \"account\"\n\ttagManager      = \"manager\"\n\ttagResetter     = \"resetter\"\n\ttagWebsocket    = \"websocket\"\n\ttagMatrix       = \"matrix\"\n\ttagWebPush      = \"webpush\"\n)\n\nvar (\n\tnormalErrorCodes       = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge}\n\trateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}\n)\n\n// logr creates a new log event with HTTP request fields\nfunc logr(r *http.Request) *log.Event {\n\treturn log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten\n}\n\n// logv creates a new log event with visitor fields\nfunc logv(v *visitor) *log.Event {\n\treturn log.With(v)\n}\n\n// logvr creates a new log event with HTTP request and visitor fields\nfunc logvr(v *visitor, r *http.Request) *log.Event {\n\treturn logr(r).With(v)\n}\n\n// logvrm creates a new log event with HTTP request, visitor fields and message fields\nfunc logvrm(v *visitor, r *http.Request, m *model.Message) *log.Event {\n\treturn logvr(v, r).With(m)\n}\n\n// logvrm creates a new log event with visitor fields and message fields\nfunc logvm(v *visitor, m *model.Message) *log.Event {\n\treturn logv(v).With(m)\n}\n\n// logem creates a new log event with email fields\nfunc logem(smtpConn *smtp.Conn) *log.Event {\n\tev := log.Tag(tagSMTP).Field(\"smtp_hostname\", smtpConn.Hostname())\n\tif smtpConn.Conn() != nil {\n\t\tev.Field(\"smtp_remote_addr\", smtpConn.Conn().RemoteAddr().String())\n\t}\n\treturn ev\n}\n\nfunc httpContext(r *http.Request) log.Context {\n\trequestURI := r.RequestURI\n\tif requestURI == \"\" {\n\t\trequestURI = r.URL.Path\n\t}\n\treturn log.Context{\n\t\t\"http_method\": r.Method,\n\t\t\"http_path\":   requestURI,\n\t}\n}\n\nfunc websocketErrorContext(err error) log.Context {\n\tvar c *websocket.CloseError\n\tif errors.As(err, &c) {\n\t\treturn log.Context{\n\t\t\t\"error\":      c.Error(),\n\t\t\t\"error_code\": c.Code,\n\t\t\t\"error_type\": \"websocket.CloseError\",\n\t\t}\n\t}\n\treturn log.Context{\n\t\t\"error\": err.Error(),\n\t}\n}\n\nfunc renderHTTPRequest(r *http.Request) string {\n\tpeekLimit := 4096\n\tlines := fmt.Sprintf(\"%s %s %s\\n\", r.Method, r.URL.RequestURI(), r.Proto)\n\tfor key, values := range r.Header {\n\t\tfor _, value := range values {\n\t\t\tlines += fmt.Sprintf(\"%s: %s\\n\", key, value)\n\t\t}\n\t}\n\tlines += \"\\n\"\n\tbody, err := util.Peek(r.Body, peekLimit)\n\tif err != nil {\n\t\tlines = fmt.Sprintf(\"(could not read body: %s)\\n\", err.Error())\n\t} else if utf8.Valid(body.PeekedBytes) {\n\t\tlines += string(body.PeekedBytes)\n\t\tif body.LimitReached {\n\t\t\tlines += fmt.Sprintf(\" ... (peeked %d bytes)\", peekLimit)\n\t\t}\n\t\tlines += \"\\n\"\n\t} else {\n\t\tif body.LimitReached {\n\t\t\tlines += fmt.Sprintf(\"(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\\n\", peekLimit, body.PeekedBytes)\n\t\t} else {\n\t\t\tlines += fmt.Sprintf(\"(peeked bytes not UTF-8, %d bytes, hex: %x)\\n\", len(body.PeekedBytes), body.PeekedBytes)\n\t\t}\n\t}\n\tr.Body = body // Important: Reset body, so it can be re-read\n\treturn strings.TrimSpace(lines)\n}\n"
  },
  {
    "path": "server/mailer_emoji_map.json",
    "content": "{\n    \"+1\": \"👍\",\n    \"-1\": \"👎\",\n    \"100\": \"💯\",\n    \"1234\": \"🔢\",\n    \"1st_place_medal\": \"🥇\",\n    \"2nd_place_medal\": \"🥈\",\n    \"3rd_place_medal\": \"🥉\",\n    \"8ball\": \"🎱\",\n    \"a\": \"🅰️\",\n    \"ab\": \"🆎\",\n    \"abacus\": \"🧮\",\n    \"abc\": \"🔤\",\n    \"abcd\": \"🔡\",\n    \"accept\": \"🉑\",\n    \"accordion\": \"🪗\",\n    \"adhesive_bandage\": \"🩹\",\n    \"adult\": \"🧑\",\n    \"aerial_tramway\": \"🚡\",\n    \"afghanistan\": \"🇦🇫\",\n    \"airplane\": \"✈️\",\n    \"aland_islands\": \"🇦🇽\",\n    \"alarm_clock\": \"⏰\",\n    \"albania\": \"🇦🇱\",\n    \"alembic\": \"⚗️\",\n    \"algeria\": \"🇩🇿\",\n    \"alien\": \"👽\",\n    \"ambulance\": \"🚑\",\n    \"american_samoa\": \"🇦🇸\",\n    \"amphora\": \"🏺\",\n    \"anatomical_heart\": \"🫀\",\n    \"anchor\": \"⚓\",\n    \"andorra\": \"🇦🇩\",\n    \"angel\": \"👼\",\n    \"anger\": \"💢\",\n    \"angola\": \"🇦🇴\",\n    \"angry\": \"😠\",\n    \"anguilla\": \"🇦🇮\",\n    \"anguished\": \"😧\",\n    \"ant\": \"🐜\",\n    \"antarctica\": \"🇦🇶\",\n    \"antigua_barbuda\": \"🇦🇬\",\n    \"apple\": \"🍎\",\n    \"aquarius\": \"♒\",\n    \"argentina\": \"🇦🇷\",\n    \"aries\": \"♈\",\n    \"armenia\": \"🇦🇲\",\n    \"arrow_backward\": \"◀️\",\n    \"arrow_double_down\": \"⏬\",\n    \"arrow_double_up\": \"⏫\",\n    \"arrow_down\": \"⬇️\",\n    \"arrow_down_small\": \"🔽\",\n    \"arrow_forward\": \"▶️\",\n    \"arrow_heading_down\": \"⤵️\",\n    \"arrow_heading_up\": \"⤴️\",\n    \"arrow_left\": \"⬅️\",\n    \"arrow_lower_left\": \"↙️\",\n    \"arrow_lower_right\": \"↘️\",\n    \"arrow_right\": \"➡️\",\n    \"arrow_right_hook\": \"↪️\",\n    \"arrow_up\": \"⬆️\",\n    \"arrow_up_down\": \"↕️\",\n    \"arrow_up_small\": \"🔼\",\n    \"arrow_upper_left\": \"↖️\",\n    \"arrow_upper_right\": \"↗️\",\n    \"arrows_clockwise\": \"🔃\",\n    \"arrows_counterclockwise\": \"🔄\",\n    \"art\": \"🎨\",\n    \"articulated_lorry\": \"🚛\",\n    \"artificial_satellite\": \"🛰️\",\n    \"artist\": \"🧑‍🎨\",\n    \"aruba\": \"🇦🇼\",\n    \"ascension_island\": \"🇦🇨\",\n    \"asterisk\": \"*️⃣\",\n    \"astonished\": \"😲\",\n    \"astronaut\": \"🧑‍🚀\",\n    \"athletic_shoe\": \"👟\",\n    \"atm\": \"🏧\",\n    \"atom_symbol\": \"⚛️\",\n    \"australia\": \"🇦🇺\",\n    \"austria\": \"🇦🇹\",\n    \"auto_rickshaw\": \"🛺\",\n    \"avocado\": \"🥑\",\n    \"axe\": \"🪓\",\n    \"azerbaijan\": \"🇦🇿\",\n    \"b\": \"🅱️\",\n    \"baby\": \"👶\",\n    \"baby_bottle\": \"🍼\",\n    \"baby_chick\": \"🐤\",\n    \"baby_symbol\": \"🚼\",\n    \"back\": \"🔙\",\n    \"bacon\": \"🥓\",\n    \"badger\": \"🦡\",\n    \"badminton\": \"🏸\",\n    \"bagel\": \"🥯\",\n    \"baggage_claim\": \"🛄\",\n    \"baguette_bread\": \"🥖\",\n    \"bahamas\": \"🇧🇸\",\n    \"bahrain\": \"🇧🇭\",\n    \"balance_scale\": \"⚖️\",\n    \"bald_man\": \"👨‍🦲\",\n    \"bald_woman\": \"👩‍🦲\",\n    \"ballet_shoes\": \"🩰\",\n    \"balloon\": \"🎈\",\n    \"ballot_box\": \"🗳️\",\n    \"ballot_box_with_check\": \"☑️\",\n    \"bamboo\": \"🎍\",\n    \"banana\": \"🍌\",\n    \"bangbang\": \"‼️\",\n    \"bangladesh\": \"🇧🇩\",\n    \"banjo\": \"🪕\",\n    \"bank\": \"🏦\",\n    \"bar_chart\": \"📊\",\n    \"barbados\": \"🇧🇧\",\n    \"barber\": \"💈\",\n    \"baseball\": \"⚾\",\n    \"basket\": \"🧺\",\n    \"basketball\": \"🏀\",\n    \"basketball_man\": \"⛹️‍♂️\",\n    \"basketball_woman\": \"⛹️‍♀️\",\n    \"bat\": \"🦇\",\n    \"bath\": \"🛀\",\n    \"bathtub\": \"🛁\",\n    \"battery\": \"🔋\",\n    \"beach_umbrella\": \"🏖️\",\n    \"bear\": \"🐻\",\n    \"bearded_person\": \"🧔\",\n    \"beaver\": \"🦫\",\n    \"bed\": \"🛏️\",\n    \"bee\": \"🐝\",\n    \"beer\": \"🍺\",\n    \"beers\": \"🍻\",\n    \"beetle\": \"🪲\",\n    \"beginner\": \"🔰\",\n    \"belarus\": \"🇧🇾\",\n    \"belgium\": \"🇧🇪\",\n    \"belize\": \"🇧🇿\",\n    \"bell\": \"🔔\",\n    \"bell_pepper\": \"🫑\",\n    \"bellhop_bell\": \"🛎️\",\n    \"benin\": \"🇧🇯\",\n    \"bento\": \"🍱\",\n    \"bermuda\": \"🇧🇲\",\n    \"beverage_box\": \"🧃\",\n    \"bhutan\": \"🇧🇹\",\n    \"bicyclist\": \"🚴\",\n    \"bike\": \"🚲\",\n    \"biking_man\": \"🚴‍♂️\",\n    \"biking_woman\": \"🚴‍♀️\",\n    \"bikini\": \"👙\",\n    \"billed_cap\": \"🧢\",\n    \"biohazard\": \"☣️\",\n    \"bird\": \"🐦\",\n    \"birthday\": \"🎂\",\n    \"bison\": \"🦬\",\n    \"black_cat\": \"🐈‍⬛\",\n    \"black_circle\": \"⚫\",\n    \"black_flag\": \"🏴\",\n    \"black_heart\": \"🖤\",\n    \"black_joker\": \"🃏\",\n    \"black_large_square\": \"⬛\",\n    \"black_medium_small_square\": \"◾\",\n    \"black_medium_square\": \"◼️\",\n    \"black_nib\": \"✒️\",\n    \"black_small_square\": \"▪️\",\n    \"black_square_button\": \"🔲\",\n    \"blond_haired_man\": \"👱‍♂️\",\n    \"blond_haired_person\": \"👱\",\n    \"blond_haired_woman\": \"👱‍♀️\",\n    \"blonde_woman\": \"👱‍♀️\",\n    \"blossom\": \"🌼\",\n    \"blowfish\": \"🐡\",\n    \"blue_book\": \"📘\",\n    \"blue_car\": \"🚙\",\n    \"blue_heart\": \"💙\",\n    \"blue_square\": \"🟦\",\n    \"blueberries\": \"🫐\",\n    \"blush\": \"😊\",\n    \"boar\": \"🐗\",\n    \"boat\": \"⛵\",\n    \"bolivia\": \"🇧🇴\",\n    \"bomb\": \"💣\",\n    \"bone\": \"🦴\",\n    \"book\": \"📖\",\n    \"bookmark\": \"🔖\",\n    \"bookmark_tabs\": \"📑\",\n    \"books\": \"📚\",\n    \"boom\": \"💥\",\n    \"boomerang\": \"🪃\",\n    \"boot\": \"👢\",\n    \"bosnia_herzegovina\": \"🇧🇦\",\n    \"botswana\": \"🇧🇼\",\n    \"bouncing_ball_man\": \"⛹️‍♂️\",\n    \"bouncing_ball_person\": \"⛹️\",\n    \"bouncing_ball_woman\": \"⛹️‍♀️\",\n    \"bouquet\": \"💐\",\n    \"bouvet_island\": \"🇧🇻\",\n    \"bow\": \"🙇\",\n    \"bow_and_arrow\": \"🏹\",\n    \"bowing_man\": \"🙇‍♂️\",\n    \"bowing_woman\": \"🙇‍♀️\",\n    \"bowl_with_spoon\": \"🥣\",\n    \"bowling\": \"🎳\",\n    \"boxing_glove\": \"🥊\",\n    \"boy\": \"👦\",\n    \"brain\": \"🧠\",\n    \"brazil\": \"🇧🇷\",\n    \"bread\": \"🍞\",\n    \"breast_feeding\": \"🤱\",\n    \"bricks\": \"🧱\",\n    \"bride_with_veil\": \"👰‍♀️\",\n    \"bridge_at_night\": \"🌉\",\n    \"briefcase\": \"💼\",\n    \"british_indian_ocean_territory\": \"🇮🇴\",\n    \"british_virgin_islands\": \"🇻🇬\",\n    \"broccoli\": \"🥦\",\n    \"broken_heart\": \"💔\",\n    \"broom\": \"🧹\",\n    \"brown_circle\": \"🟤\",\n    \"brown_heart\": \"🤎\",\n    \"brown_square\": \"🟫\",\n    \"brunei\": \"🇧🇳\",\n    \"bubble_tea\": \"🧋\",\n    \"bucket\": \"🪣\",\n    \"bug\": \"🐛\",\n    \"building_construction\": \"🏗️\",\n    \"bulb\": \"💡\",\n    \"bulgaria\": \"🇧🇬\",\n    \"bullettrain_front\": \"🚅\",\n    \"bullettrain_side\": \"🚄\",\n    \"burkina_faso\": \"🇧🇫\",\n    \"burrito\": \"🌯\",\n    \"burundi\": \"🇧🇮\",\n    \"bus\": \"🚌\",\n    \"business_suit_levitating\": \"🕴️\",\n    \"busstop\": \"🚏\",\n    \"bust_in_silhouette\": \"👤\",\n    \"busts_in_silhouette\": \"👥\",\n    \"butter\": \"🧈\",\n    \"butterfly\": \"🦋\",\n    \"cactus\": \"🌵\",\n    \"cake\": \"🍰\",\n    \"calendar\": \"📆\",\n    \"call_me_hand\": \"🤙\",\n    \"calling\": \"📲\",\n    \"cambodia\": \"🇰🇭\",\n    \"camel\": \"🐫\",\n    \"camera\": \"📷\",\n    \"camera_flash\": \"📸\",\n    \"cameroon\": \"🇨🇲\",\n    \"camping\": \"🏕️\",\n    \"canada\": \"🇨🇦\",\n    \"canary_islands\": \"🇮🇨\",\n    \"cancer\": \"♋\",\n    \"candle\": \"🕯️\",\n    \"candy\": \"🍬\",\n    \"canned_food\": \"🥫\",\n    \"canoe\": \"🛶\",\n    \"cape_verde\": \"🇨🇻\",\n    \"capital_abcd\": \"🔠\",\n    \"capricorn\": \"♑\",\n    \"car\": \"🚗\",\n    \"card_file_box\": \"🗃️\",\n    \"card_index\": \"📇\",\n    \"card_index_dividers\": \"🗂️\",\n    \"caribbean_netherlands\": \"🇧🇶\",\n    \"carousel_horse\": \"🎠\",\n    \"carpentry_saw\": \"🪚\",\n    \"carrot\": \"🥕\",\n    \"cartwheeling\": \"🤸\",\n    \"cat\": \"🐱\",\n    \"cat2\": \"🐈\",\n    \"cayman_islands\": \"🇰🇾\",\n    \"cd\": \"💿\",\n    \"central_african_republic\": \"🇨🇫\",\n    \"ceuta_melilla\": \"🇪🇦\",\n    \"chad\": \"🇹🇩\",\n    \"chains\": \"⛓️\",\n    \"chair\": \"🪑\",\n    \"champagne\": \"🍾\",\n    \"chart\": \"💹\",\n    \"chart_with_downwards_trend\": \"📉\",\n    \"chart_with_upwards_trend\": \"📈\",\n    \"checkered_flag\": \"🏁\",\n    \"cheese\": \"🧀\",\n    \"cherries\": \"🍒\",\n    \"cherry_blossom\": \"🌸\",\n    \"chess_pawn\": \"♟️\",\n    \"chestnut\": \"🌰\",\n    \"chicken\": \"🐔\",\n    \"child\": \"🧒\",\n    \"children_crossing\": \"🚸\",\n    \"chile\": \"🇨🇱\",\n    \"chipmunk\": \"🐿️\",\n    \"chocolate_bar\": \"🍫\",\n    \"chopsticks\": \"🥢\",\n    \"christmas_island\": \"🇨🇽\",\n    \"christmas_tree\": \"🎄\",\n    \"church\": \"⛪\",\n    \"cinema\": \"🎦\",\n    \"circus_tent\": \"🎪\",\n    \"city_sunrise\": \"🌇\",\n    \"city_sunset\": \"🌆\",\n    \"cityscape\": \"🏙️\",\n    \"cl\": \"🆑\",\n    \"clamp\": \"🗜️\",\n    \"clap\": \"👏\",\n    \"clapper\": \"🎬\",\n    \"classical_building\": \"🏛️\",\n    \"climbing\": \"🧗\",\n    \"climbing_man\": \"🧗‍♂️\",\n    \"climbing_woman\": \"🧗‍♀️\",\n    \"clinking_glasses\": \"🥂\",\n    \"clipboard\": \"📋\",\n    \"clipperton_island\": \"🇨🇵\",\n    \"clock1\": \"🕐\",\n    \"clock10\": \"🕙\",\n    \"clock1030\": \"🕥\",\n    \"clock11\": \"🕚\",\n    \"clock1130\": \"🕦\",\n    \"clock12\": \"🕛\",\n    \"clock1230\": \"🕧\",\n    \"clock130\": \"🕜\",\n    \"clock2\": \"🕑\",\n    \"clock230\": \"🕝\",\n    \"clock3\": \"🕒\",\n    \"clock330\": \"🕞\",\n    \"clock4\": \"🕓\",\n    \"clock430\": \"🕟\",\n    \"clock5\": \"🕔\",\n    \"clock530\": \"🕠\",\n    \"clock6\": \"🕕\",\n    \"clock630\": \"🕡\",\n    \"clock7\": \"🕖\",\n    \"clock730\": \"🕢\",\n    \"clock8\": \"🕗\",\n    \"clock830\": \"🕣\",\n    \"clock9\": \"🕘\",\n    \"clock930\": \"🕤\",\n    \"closed_book\": \"📕\",\n    \"closed_lock_with_key\": \"🔐\",\n    \"closed_umbrella\": \"🌂\",\n    \"cloud\": \"☁️\",\n    \"cloud_with_lightning\": \"🌩️\",\n    \"cloud_with_lightning_and_rain\": \"⛈️\",\n    \"cloud_with_rain\": \"🌧️\",\n    \"cloud_with_snow\": \"🌨️\",\n    \"clown_face\": \"🤡\",\n    \"clubs\": \"♣️\",\n    \"cn\": \"🇨🇳\",\n    \"coat\": \"🧥\",\n    \"cockroach\": \"🪳\",\n    \"cocktail\": \"🍸\",\n    \"coconut\": \"🥥\",\n    \"cocos_islands\": \"🇨🇨\",\n    \"coffee\": \"☕\",\n    \"coffin\": \"⚰️\",\n    \"coin\": \"🪙\",\n    \"cold_face\": \"🥶\",\n    \"cold_sweat\": \"😰\",\n    \"collision\": \"💥\",\n    \"colombia\": \"🇨🇴\",\n    \"comet\": \"☄️\",\n    \"comoros\": \"🇰🇲\",\n    \"compass\": \"🧭\",\n    \"computer\": \"💻\",\n    \"computer_mouse\": \"🖱️\",\n    \"confetti_ball\": \"🎊\",\n    \"confounded\": \"😖\",\n    \"confused\": \"😕\",\n    \"congo_brazzaville\": \"🇨🇬\",\n    \"congo_kinshasa\": \"🇨🇩\",\n    \"congratulations\": \"㊗️\",\n    \"construction\": \"🚧\",\n    \"construction_worker\": \"👷\",\n    \"construction_worker_man\": \"👷‍♂️\",\n    \"construction_worker_woman\": \"👷‍♀️\",\n    \"control_knobs\": \"🎛️\",\n    \"convenience_store\": \"🏪\",\n    \"cook\": \"🧑‍🍳\",\n    \"cook_islands\": \"🇨🇰\",\n    \"cookie\": \"🍪\",\n    \"cool\": \"🆒\",\n    \"cop\": \"👮\",\n    \"copyright\": \"©️\",\n    \"corn\": \"🌽\",\n    \"costa_rica\": \"🇨🇷\",\n    \"cote_divoire\": \"🇨🇮\",\n    \"couch_and_lamp\": \"🛋️\",\n    \"couple\": \"👫\",\n    \"couple_with_heart\": \"💑\",\n    \"couple_with_heart_man_man\": \"👨‍❤️‍👨\",\n    \"couple_with_heart_woman_man\": \"👩‍❤️‍👨\",\n    \"couple_with_heart_woman_woman\": \"👩‍❤️‍👩\",\n    \"couplekiss\": \"💏\",\n    \"couplekiss_man_man\": \"👨‍❤️‍💋‍👨\",\n    \"couplekiss_man_woman\": \"👩‍❤️‍💋‍👨\",\n    \"couplekiss_woman_woman\": \"👩‍❤️‍💋‍👩\",\n    \"cow\": \"🐮\",\n    \"cow2\": \"🐄\",\n    \"cowboy_hat_face\": \"🤠\",\n    \"crab\": \"🦀\",\n    \"crayon\": \"🖍️\",\n    \"credit_card\": \"💳\",\n    \"crescent_moon\": \"🌙\",\n    \"cricket\": \"🦗\",\n    \"cricket_game\": \"🏏\",\n    \"croatia\": \"🇭🇷\",\n    \"crocodile\": \"🐊\",\n    \"croissant\": \"🥐\",\n    \"crossed_fingers\": \"🤞\",\n    \"crossed_flags\": \"🎌\",\n    \"crossed_swords\": \"⚔️\",\n    \"crown\": \"👑\",\n    \"cry\": \"😢\",\n    \"crying_cat_face\": \"😿\",\n    \"crystal_ball\": \"🔮\",\n    \"cuba\": \"🇨🇺\",\n    \"cucumber\": \"🥒\",\n    \"cup_with_straw\": \"🥤\",\n    \"cupcake\": \"🧁\",\n    \"cupid\": \"💘\",\n    \"curacao\": \"🇨🇼\",\n    \"curling_stone\": \"🥌\",\n    \"curly_haired_man\": \"👨‍🦱\",\n    \"curly_haired_woman\": \"👩‍🦱\",\n    \"curly_loop\": \"➰\",\n    \"currency_exchange\": \"💱\",\n    \"curry\": \"🍛\",\n    \"cursing_face\": \"🤬\",\n    \"custard\": \"🍮\",\n    \"customs\": \"🛃\",\n    \"cut_of_meat\": \"🥩\",\n    \"cyclone\": \"🌀\",\n    \"cyprus\": \"🇨🇾\",\n    \"czech_republic\": \"🇨🇿\",\n    \"dagger\": \"🗡️\",\n    \"dancer\": \"💃\",\n    \"dancers\": \"👯\",\n    \"dancing_men\": \"👯‍♂️\",\n    \"dancing_women\": \"👯‍♀️\",\n    \"dango\": \"🍡\",\n    \"dark_sunglasses\": \"🕶️\",\n    \"dart\": \"🎯\",\n    \"dash\": \"💨\",\n    \"date\": \"📅\",\n    \"de\": \"🇩🇪\",\n    \"deaf_man\": \"🧏‍♂️\",\n    \"deaf_person\": \"🧏\",\n    \"deaf_woman\": \"🧏‍♀️\",\n    \"deciduous_tree\": \"🌳\",\n    \"deer\": \"🦌\",\n    \"denmark\": \"🇩🇰\",\n    \"department_store\": \"🏬\",\n    \"derelict_house\": \"🏚️\",\n    \"desert\": \"🏜️\",\n    \"desert_island\": \"🏝️\",\n    \"desktop_computer\": \"🖥️\",\n    \"detective\": \"🕵️\",\n    \"diamond_shape_with_a_dot_inside\": \"💠\",\n    \"diamonds\": \"♦️\",\n    \"diego_garcia\": \"🇩🇬\",\n    \"disappointed\": \"😞\",\n    \"disappointed_relieved\": \"😥\",\n    \"disguised_face\": \"🥸\",\n    \"diving_mask\": \"🤿\",\n    \"diya_lamp\": \"🪔\",\n    \"dizzy\": \"💫\",\n    \"dizzy_face\": \"😵\",\n    \"djibouti\": \"🇩🇯\",\n    \"dna\": \"🧬\",\n    \"do_not_litter\": \"🚯\",\n    \"dodo\": \"🦤\",\n    \"dog\": \"🐶\",\n    \"dog2\": \"🐕\",\n    \"dollar\": \"💵\",\n    \"dolls\": \"🎎\",\n    \"dolphin\": \"🐬\",\n    \"dominica\": \"🇩🇲\",\n    \"dominican_republic\": \"🇩🇴\",\n    \"door\": \"🚪\",\n    \"doughnut\": \"🍩\",\n    \"dove\": \"🕊️\",\n    \"dragon\": \"🐉\",\n    \"dragon_face\": \"🐲\",\n    \"dress\": \"👗\",\n    \"dromedary_camel\": \"🐪\",\n    \"drooling_face\": \"🤤\",\n    \"drop_of_blood\": \"🩸\",\n    \"droplet\": \"💧\",\n    \"drum\": \"🥁\",\n    \"duck\": \"🦆\",\n    \"dumpling\": \"🥟\",\n    \"dvd\": \"📀\",\n    \"e-mail\": \"📧\",\n    \"eagle\": \"🦅\",\n    \"ear\": \"👂\",\n    \"ear_of_rice\": \"🌾\",\n    \"ear_with_hearing_aid\": \"🦻\",\n    \"earth_africa\": \"🌍\",\n    \"earth_americas\": \"🌎\",\n    \"earth_asia\": \"🌏\",\n    \"ecuador\": \"🇪🇨\",\n    \"egg\": \"🥚\",\n    \"eggplant\": \"🍆\",\n    \"egypt\": \"🇪🇬\",\n    \"eight\": \"8️⃣\",\n    \"eight_pointed_black_star\": \"✴️\",\n    \"eight_spoked_asterisk\": \"✳️\",\n    \"eject_button\": \"⏏️\",\n    \"el_salvador\": \"🇸🇻\",\n    \"electric_plug\": \"🔌\",\n    \"elephant\": \"🐘\",\n    \"elevator\": \"🛗\",\n    \"elf\": \"🧝\",\n    \"elf_man\": \"🧝‍♂️\",\n    \"elf_woman\": \"🧝‍♀️\",\n    \"email\": \"📧\",\n    \"end\": \"🔚\",\n    \"england\": \"🏴󠁧󠁢󠁥󠁮󠁧󠁿\",\n    \"envelope\": \"✉️\",\n    \"envelope_with_arrow\": \"📩\",\n    \"equatorial_guinea\": \"🇬🇶\",\n    \"eritrea\": \"🇪🇷\",\n    \"es\": \"🇪🇸\",\n    \"estonia\": \"🇪🇪\",\n    \"ethiopia\": \"🇪🇹\",\n    \"eu\": \"🇪🇺\",\n    \"euro\": \"💶\",\n    \"european_castle\": \"🏰\",\n    \"european_post_office\": \"🏤\",\n    \"european_union\": \"🇪🇺\",\n    \"evergreen_tree\": \"🌲\",\n    \"exclamation\": \"❗\",\n    \"exploding_head\": \"🤯\",\n    \"expressionless\": \"😑\",\n    \"eye\": \"👁️\",\n    \"eye_speech_bubble\": \"👁️‍🗨️\",\n    \"eyeglasses\": \"👓\",\n    \"eyes\": \"👀\",\n    \"face_exhaling\": \"😮‍💨\",\n    \"face_in_clouds\": \"😶‍🌫️\",\n    \"face_with_head_bandage\": \"🤕\",\n    \"face_with_spiral_eyes\": \"😵‍💫\",\n    \"face_with_thermometer\": \"🤒\",\n    \"facepalm\": \"🤦\",\n    \"facepunch\": \"👊\",\n    \"factory\": \"🏭\",\n    \"factory_worker\": \"🧑‍🏭\",\n    \"fairy\": \"🧚\",\n    \"fairy_man\": \"🧚‍♂️\",\n    \"fairy_woman\": \"🧚‍♀️\",\n    \"falafel\": \"🧆\",\n    \"falkland_islands\": \"🇫🇰\",\n    \"fallen_leaf\": \"🍂\",\n    \"family\": \"👪\",\n    \"family_man_boy\": \"👨‍👦\",\n    \"family_man_boy_boy\": \"👨‍👦‍👦\",\n    \"family_man_girl\": \"👨‍👧\",\n    \"family_man_girl_boy\": \"👨‍👧‍👦\",\n    \"family_man_girl_girl\": \"👨‍👧‍👧\",\n    \"family_man_man_boy\": \"👨‍👨‍👦\",\n    \"family_man_man_boy_boy\": \"👨‍👨‍👦‍👦\",\n    \"family_man_man_girl\": \"👨‍👨‍👧\",\n    \"family_man_man_girl_boy\": \"👨‍👨‍👧‍👦\",\n    \"family_man_man_girl_girl\": \"👨‍👨‍👧‍👧\",\n    \"family_man_woman_boy\": \"👨‍👩‍👦\",\n    \"family_man_woman_boy_boy\": \"👨‍👩‍👦‍👦\",\n    \"family_man_woman_girl\": \"👨‍👩‍👧\",\n    \"family_man_woman_girl_boy\": \"👨‍👩‍👧‍👦\",\n    \"family_man_woman_girl_girl\": \"👨‍👩‍👧‍👧\",\n    \"family_woman_boy\": \"👩‍👦\",\n    \"family_woman_boy_boy\": \"👩‍👦‍👦\",\n    \"family_woman_girl\": \"👩‍👧\",\n    \"family_woman_girl_boy\": \"👩‍👧‍👦\",\n    \"family_woman_girl_girl\": \"👩‍👧‍👧\",\n    \"family_woman_woman_boy\": \"👩‍👩‍👦\",\n    \"family_woman_woman_boy_boy\": \"👩‍👩‍👦‍👦\",\n    \"family_woman_woman_girl\": \"👩‍👩‍👧\",\n    \"family_woman_woman_girl_boy\": \"👩‍👩‍👧‍👦\",\n    \"family_woman_woman_girl_girl\": \"👩‍👩‍👧‍👧\",\n    \"farmer\": \"🧑‍🌾\",\n    \"faroe_islands\": \"🇫🇴\",\n    \"fast_forward\": \"⏩\",\n    \"fax\": \"📠\",\n    \"fearful\": \"😨\",\n    \"feather\": \"🪶\",\n    \"feet\": \"🐾\",\n    \"female_detective\": \"🕵️‍♀️\",\n    \"female_sign\": \"♀️\",\n    \"ferris_wheel\": \"🎡\",\n    \"ferry\": \"⛴️\",\n    \"field_hockey\": \"🏑\",\n    \"fiji\": \"🇫🇯\",\n    \"file_cabinet\": \"🗄️\",\n    \"file_folder\": \"📁\",\n    \"film_projector\": \"📽️\",\n    \"film_strip\": \"🎞️\",\n    \"finland\": \"🇫🇮\",\n    \"fire\": \"🔥\",\n    \"fire_engine\": \"🚒\",\n    \"fire_extinguisher\": \"🧯\",\n    \"firecracker\": \"🧨\",\n    \"firefighter\": \"🧑‍🚒\",\n    \"fireworks\": \"🎆\",\n    \"first_quarter_moon\": \"🌓\",\n    \"first_quarter_moon_with_face\": \"🌛\",\n    \"fish\": \"🐟\",\n    \"fish_cake\": \"🍥\",\n    \"fishing_pole_and_fish\": \"🎣\",\n    \"fist\": \"✊\",\n    \"fist_left\": \"🤛\",\n    \"fist_oncoming\": \"👊\",\n    \"fist_raised\": \"✊\",\n    \"fist_right\": \"🤜\",\n    \"five\": \"5️⃣\",\n    \"flags\": \"🎏\",\n    \"flamingo\": \"🦩\",\n    \"flashlight\": \"🔦\",\n    \"flat_shoe\": \"🥿\",\n    \"flatbread\": \"🫓\",\n    \"fleur_de_lis\": \"⚜️\",\n    \"flight_arrival\": \"🛬\",\n    \"flight_departure\": \"🛫\",\n    \"flipper\": \"🐬\",\n    \"floppy_disk\": \"💾\",\n    \"flower_playing_cards\": \"🎴\",\n    \"flushed\": \"😳\",\n    \"fly\": \"🪰\",\n    \"flying_disc\": \"🥏\",\n    \"flying_saucer\": \"🛸\",\n    \"fog\": \"🌫️\",\n    \"foggy\": \"🌁\",\n    \"fondue\": \"🫕\",\n    \"foot\": \"🦶\",\n    \"football\": \"🏈\",\n    \"footprints\": \"👣\",\n    \"fork_and_knife\": \"🍴\",\n    \"fortune_cookie\": \"🥠\",\n    \"fountain\": \"⛲\",\n    \"fountain_pen\": \"🖋️\",\n    \"four\": \"4️⃣\",\n    \"four_leaf_clover\": \"🍀\",\n    \"fox_face\": \"🦊\",\n    \"fr\": \"🇫🇷\",\n    \"framed_picture\": \"🖼️\",\n    \"free\": \"🆓\",\n    \"french_guiana\": \"🇬🇫\",\n    \"french_polynesia\": \"🇵🇫\",\n    \"french_southern_territories\": \"🇹🇫\",\n    \"fried_egg\": \"🍳\",\n    \"fried_shrimp\": \"🍤\",\n    \"fries\": \"🍟\",\n    \"frog\": \"🐸\",\n    \"frowning\": \"😦\",\n    \"frowning_face\": \"☹️\",\n    \"frowning_man\": \"🙍‍♂️\",\n    \"frowning_person\": \"🙍\",\n    \"frowning_woman\": \"🙍‍♀️\",\n    \"fu\": \"🖕\",\n    \"fuelpump\": \"⛽\",\n    \"full_moon\": \"🌕\",\n    \"full_moon_with_face\": \"🌝\",\n    \"funeral_urn\": \"⚱️\",\n    \"gabon\": \"🇬🇦\",\n    \"gambia\": \"🇬🇲\",\n    \"game_die\": \"🎲\",\n    \"garlic\": \"🧄\",\n    \"gb\": \"🇬🇧\",\n    \"gear\": \"⚙️\",\n    \"gem\": \"💎\",\n    \"gemini\": \"♊\",\n    \"genie\": \"🧞\",\n    \"genie_man\": \"🧞‍♂️\",\n    \"genie_woman\": \"🧞‍♀️\",\n    \"georgia\": \"🇬🇪\",\n    \"ghana\": \"🇬🇭\",\n    \"ghost\": \"👻\",\n    \"gibraltar\": \"🇬🇮\",\n    \"gift\": \"🎁\",\n    \"gift_heart\": \"💝\",\n    \"giraffe\": \"🦒\",\n    \"girl\": \"👧\",\n    \"globe_with_meridians\": \"🌐\",\n    \"gloves\": \"🧤\",\n    \"goal_net\": \"🥅\",\n    \"goat\": \"🐐\",\n    \"goggles\": \"🥽\",\n    \"golf\": \"⛳\",\n    \"golfing\": \"🏌️\",\n    \"golfing_man\": \"🏌️‍♂️\",\n    \"golfing_woman\": \"🏌️‍♀️\",\n    \"gorilla\": \"🦍\",\n    \"grapes\": \"🍇\",\n    \"greece\": \"🇬🇷\",\n    \"green_apple\": \"🍏\",\n    \"green_book\": \"📗\",\n    \"green_circle\": \"🟢\",\n    \"green_heart\": \"💚\",\n    \"green_salad\": \"🥗\",\n    \"green_square\": \"🟩\",\n    \"greenland\": \"🇬🇱\",\n    \"grenada\": \"🇬🇩\",\n    \"grey_exclamation\": \"❕\",\n    \"grey_question\": \"❔\",\n    \"grimacing\": \"😬\",\n    \"grin\": \"😁\",\n    \"grinning\": \"😀\",\n    \"guadeloupe\": \"🇬🇵\",\n    \"guam\": \"🇬🇺\",\n    \"guard\": \"💂\",\n    \"guardsman\": \"💂‍♂️\",\n    \"guardswoman\": \"💂‍♀️\",\n    \"guatemala\": \"🇬🇹\",\n    \"guernsey\": \"🇬🇬\",\n    \"guide_dog\": \"🦮\",\n    \"guinea\": \"🇬🇳\",\n    \"guinea_bissau\": \"🇬🇼\",\n    \"guitar\": \"🎸\",\n    \"gun\": \"🔫\",\n    \"guyana\": \"🇬🇾\",\n    \"haircut\": \"💇\",\n    \"haircut_man\": \"💇‍♂️\",\n    \"haircut_woman\": \"💇‍♀️\",\n    \"haiti\": \"🇭🇹\",\n    \"hamburger\": \"🍔\",\n    \"hammer\": \"🔨\",\n    \"hammer_and_pick\": \"⚒️\",\n    \"hammer_and_wrench\": \"🛠️\",\n    \"hamster\": \"🐹\",\n    \"hand\": \"✋\",\n    \"hand_over_mouth\": \"🤭\",\n    \"handbag\": \"👜\",\n    \"handball_person\": \"🤾\",\n    \"handshake\": \"🤝\",\n    \"hankey\": \"💩\",\n    \"hash\": \"#️⃣\",\n    \"hatched_chick\": \"🐥\",\n    \"hatching_chick\": \"🐣\",\n    \"headphones\": \"🎧\",\n    \"headstone\": \"🪦\",\n    \"health_worker\": \"🧑‍⚕️\",\n    \"hear_no_evil\": \"🙉\",\n    \"heard_mcdonald_islands\": \"🇭🇲\",\n    \"heart\": \"❤️\",\n    \"heart_decoration\": \"💟\",\n    \"heart_eyes\": \"😍\",\n    \"heart_eyes_cat\": \"😻\",\n    \"heart_on_fire\": \"❤️‍🔥\",\n    \"heartbeat\": \"💓\",\n    \"heartpulse\": \"💗\",\n    \"hearts\": \"♥️\",\n    \"heavy_check_mark\": \"✔️\",\n    \"heavy_division_sign\": \"➗\",\n    \"heavy_dollar_sign\": \"💲\",\n    \"heavy_exclamation_mark\": \"❗\",\n    \"heavy_heart_exclamation\": \"❣️\",\n    \"heavy_minus_sign\": \"➖\",\n    \"heavy_multiplication_x\": \"✖️\",\n    \"heavy_plus_sign\": \"➕\",\n    \"hedgehog\": \"🦔\",\n    \"helicopter\": \"🚁\",\n    \"herb\": \"🌿\",\n    \"hibiscus\": \"🌺\",\n    \"high_brightness\": \"🔆\",\n    \"high_heel\": \"👠\",\n    \"hiking_boot\": \"🥾\",\n    \"hindu_temple\": \"🛕\",\n    \"hippopotamus\": \"🦛\",\n    \"hocho\": \"🔪\",\n    \"hole\": \"🕳️\",\n    \"honduras\": \"🇭🇳\",\n    \"honey_pot\": \"🍯\",\n    \"honeybee\": \"🐝\",\n    \"hong_kong\": \"🇭🇰\",\n    \"hook\": \"🪝\",\n    \"horse\": \"🐴\",\n    \"horse_racing\": \"🏇\",\n    \"hospital\": \"🏥\",\n    \"hot_face\": \"🥵\",\n    \"hot_pepper\": \"🌶️\",\n    \"hotdog\": \"🌭\",\n    \"hotel\": \"🏨\",\n    \"hotsprings\": \"♨️\",\n    \"hourglass\": \"⌛\",\n    \"hourglass_flowing_sand\": \"⏳\",\n    \"house\": \"🏠\",\n    \"house_with_garden\": \"🏡\",\n    \"houses\": \"🏘️\",\n    \"hugs\": \"🤗\",\n    \"hungary\": \"🇭🇺\",\n    \"hushed\": \"😯\",\n    \"hut\": \"🛖\",\n    \"ice_cream\": \"🍨\",\n    \"ice_cube\": \"🧊\",\n    \"ice_hockey\": \"🏒\",\n    \"ice_skate\": \"⛸️\",\n    \"icecream\": \"🍦\",\n    \"iceland\": \"🇮🇸\",\n    \"id\": \"🆔\",\n    \"ideograph_advantage\": \"🉐\",\n    \"imp\": \"👿\",\n    \"inbox_tray\": \"📥\",\n    \"incoming_envelope\": \"📨\",\n    \"india\": \"🇮🇳\",\n    \"indonesia\": \"🇮🇩\",\n    \"infinity\": \"♾️\",\n    \"information_desk_person\": \"💁\",\n    \"information_source\": \"ℹ️\",\n    \"innocent\": \"😇\",\n    \"interrobang\": \"⁉️\",\n    \"iphone\": \"📱\",\n    \"iran\": \"🇮🇷\",\n    \"iraq\": \"🇮🇶\",\n    \"ireland\": \"🇮🇪\",\n    \"isle_of_man\": \"🇮🇲\",\n    \"israel\": \"🇮🇱\",\n    \"it\": \"🇮🇹\",\n    \"izakaya_lantern\": \"🏮\",\n    \"jack_o_lantern\": \"🎃\",\n    \"jamaica\": \"🇯🇲\",\n    \"japan\": \"🗾\",\n    \"japanese_castle\": \"🏯\",\n    \"japanese_goblin\": \"👺\",\n    \"japanese_ogre\": \"👹\",\n    \"jeans\": \"👖\",\n    \"jersey\": \"🇯🇪\",\n    \"jigsaw\": \"🧩\",\n    \"jordan\": \"🇯🇴\",\n    \"joy\": \"😂\",\n    \"joy_cat\": \"😹\",\n    \"joystick\": \"🕹️\",\n    \"jp\": \"🇯🇵\",\n    \"judge\": \"🧑‍⚖️\",\n    \"juggling_person\": \"🤹\",\n    \"kaaba\": \"🕋\",\n    \"kangaroo\": \"🦘\",\n    \"kazakhstan\": \"🇰🇿\",\n    \"kenya\": \"🇰🇪\",\n    \"key\": \"🔑\",\n    \"keyboard\": \"⌨️\",\n    \"keycap_ten\": \"🔟\",\n    \"kick_scooter\": \"🛴\",\n    \"kimono\": \"👘\",\n    \"kiribati\": \"🇰🇮\",\n    \"kiss\": \"💋\",\n    \"kissing\": \"😗\",\n    \"kissing_cat\": \"😽\",\n    \"kissing_closed_eyes\": \"😚\",\n    \"kissing_heart\": \"😘\",\n    \"kissing_smiling_eyes\": \"😙\",\n    \"kite\": \"🪁\",\n    \"kiwi_fruit\": \"🥝\",\n    \"kneeling_man\": \"🧎‍♂️\",\n    \"kneeling_person\": \"🧎\",\n    \"kneeling_woman\": \"🧎‍♀️\",\n    \"knife\": \"🔪\",\n    \"knot\": \"🪢\",\n    \"koala\": \"🐨\",\n    \"koko\": \"🈁\",\n    \"kosovo\": \"🇽🇰\",\n    \"kr\": \"🇰🇷\",\n    \"kuwait\": \"🇰🇼\",\n    \"kyrgyzstan\": \"🇰🇬\",\n    \"lab_coat\": \"🥼\",\n    \"label\": \"🏷️\",\n    \"lacrosse\": \"🥍\",\n    \"ladder\": \"🪜\",\n    \"lady_beetle\": \"🐞\",\n    \"lantern\": \"🏮\",\n    \"laos\": \"🇱🇦\",\n    \"large_blue_circle\": \"🔵\",\n    \"large_blue_diamond\": \"🔷\",\n    \"large_orange_diamond\": \"🔶\",\n    \"last_quarter_moon\": \"🌗\",\n    \"last_quarter_moon_with_face\": \"🌜\",\n    \"latin_cross\": \"✝️\",\n    \"latvia\": \"🇱🇻\",\n    \"laughing\": \"😆\",\n    \"leafy_green\": \"🥬\",\n    \"leaves\": \"🍃\",\n    \"lebanon\": \"🇱🇧\",\n    \"ledger\": \"📒\",\n    \"left_luggage\": \"🛅\",\n    \"left_right_arrow\": \"↔️\",\n    \"left_speech_bubble\": \"🗨️\",\n    \"leftwards_arrow_with_hook\": \"↩️\",\n    \"leg\": \"🦵\",\n    \"lemon\": \"🍋\",\n    \"leo\": \"♌\",\n    \"leopard\": \"🐆\",\n    \"lesotho\": \"🇱🇸\",\n    \"level_slider\": \"🎚️\",\n    \"liberia\": \"🇱🇷\",\n    \"libra\": \"♎\",\n    \"libya\": \"🇱🇾\",\n    \"liechtenstein\": \"🇱🇮\",\n    \"light_rail\": \"🚈\",\n    \"link\": \"🔗\",\n    \"lion\": \"🦁\",\n    \"lips\": \"👄\",\n    \"lipstick\": \"💄\",\n    \"lithuania\": \"🇱🇹\",\n    \"lizard\": \"🦎\",\n    \"llama\": \"🦙\",\n    \"lobster\": \"🦞\",\n    \"lock\": \"🔒\",\n    \"lock_with_ink_pen\": \"🔏\",\n    \"lollipop\": \"🍭\",\n    \"long_drum\": \"🪘\",\n    \"loop\": \"➿\",\n    \"lotion_bottle\": \"🧴\",\n    \"lotus_position\": \"🧘\",\n    \"lotus_position_man\": \"🧘‍♂️\",\n    \"lotus_position_woman\": \"🧘‍♀️\",\n    \"loud_sound\": \"🔊\",\n    \"loudspeaker\": \"📢\",\n    \"love_hotel\": \"🏩\",\n    \"love_letter\": \"💌\",\n    \"love_you_gesture\": \"🤟\",\n    \"low_brightness\": \"🔅\",\n    \"luggage\": \"🧳\",\n    \"lungs\": \"🫁\",\n    \"luxembourg\": \"🇱🇺\",\n    \"lying_face\": \"🤥\",\n    \"m\": \"Ⓜ️\",\n    \"macau\": \"🇲🇴\",\n    \"macedonia\": \"🇲🇰\",\n    \"madagascar\": \"🇲🇬\",\n    \"mag\": \"🔍\",\n    \"mag_right\": \"🔎\",\n    \"mage\": \"🧙\",\n    \"mage_man\": \"🧙‍♂️\",\n    \"mage_woman\": \"🧙‍♀️\",\n    \"magic_wand\": \"🪄\",\n    \"magnet\": \"🧲\",\n    \"mahjong\": \"🀄\",\n    \"mailbox\": \"📫\",\n    \"mailbox_closed\": \"📪\",\n    \"mailbox_with_mail\": \"📬\",\n    \"mailbox_with_no_mail\": \"📭\",\n    \"malawi\": \"🇲🇼\",\n    \"malaysia\": \"🇲🇾\",\n    \"maldives\": \"🇲🇻\",\n    \"male_detective\": \"🕵️‍♂️\",\n    \"male_sign\": \"♂️\",\n    \"mali\": \"🇲🇱\",\n    \"malta\": \"🇲🇹\",\n    \"mammoth\": \"🦣\",\n    \"man\": \"👨\",\n    \"man_artist\": \"👨‍🎨\",\n    \"man_astronaut\": \"👨‍🚀\",\n    \"man_beard\": \"🧔‍♂️\",\n    \"man_cartwheeling\": \"🤸‍♂️\",\n    \"man_cook\": \"👨‍🍳\",\n    \"man_dancing\": \"🕺\",\n    \"man_facepalming\": \"🤦‍♂️\",\n    \"man_factory_worker\": \"👨‍🏭\",\n    \"man_farmer\": \"👨‍🌾\",\n    \"man_feeding_baby\": \"👨‍🍼\",\n    \"man_firefighter\": \"👨‍🚒\",\n    \"man_health_worker\": \"👨‍⚕️\",\n    \"man_in_manual_wheelchair\": \"👨‍🦽\",\n    \"man_in_motorized_wheelchair\": \"👨‍🦼\",\n    \"man_in_tuxedo\": \"🤵‍♂️\",\n    \"man_judge\": \"👨‍⚖️\",\n    \"man_juggling\": \"🤹‍♂️\",\n    \"man_mechanic\": \"👨‍🔧\",\n    \"man_office_worker\": \"👨‍💼\",\n    \"man_pilot\": \"👨‍✈️\",\n    \"man_playing_handball\": \"🤾‍♂️\",\n    \"man_playing_water_polo\": \"🤽‍♂️\",\n    \"man_scientist\": \"👨‍🔬\",\n    \"man_shrugging\": \"🤷‍♂️\",\n    \"man_singer\": \"👨‍🎤\",\n    \"man_student\": \"👨‍🎓\",\n    \"man_teacher\": \"👨‍🏫\",\n    \"man_technologist\": \"👨‍💻\",\n    \"man_with_gua_pi_mao\": \"👲\",\n    \"man_with_probing_cane\": \"👨‍🦯\",\n    \"man_with_turban\": \"👳‍♂️\",\n    \"man_with_veil\": \"👰‍♂️\",\n    \"mandarin\": \"🍊\",\n    \"mango\": \"🥭\",\n    \"mans_shoe\": \"👞\",\n    \"mantelpiece_clock\": \"🕰️\",\n    \"manual_wheelchair\": \"🦽\",\n    \"maple_leaf\": \"🍁\",\n    \"marshall_islands\": \"🇲🇭\",\n    \"martial_arts_uniform\": \"🥋\",\n    \"martinique\": \"🇲🇶\",\n    \"mask\": \"😷\",\n    \"massage\": \"💆\",\n    \"massage_man\": \"💆‍♂️\",\n    \"massage_woman\": \"💆‍♀️\",\n    \"mate\": \"🧉\",\n    \"mauritania\": \"🇲🇷\",\n    \"mauritius\": \"🇲🇺\",\n    \"mayotte\": \"🇾🇹\",\n    \"meat_on_bone\": \"🍖\",\n    \"mechanic\": \"🧑‍🔧\",\n    \"mechanical_arm\": \"🦾\",\n    \"mechanical_leg\": \"🦿\",\n    \"medal_military\": \"🎖️\",\n    \"medal_sports\": \"🏅\",\n    \"medical_symbol\": \"⚕️\",\n    \"mega\": \"📣\",\n    \"melon\": \"🍈\",\n    \"memo\": \"📝\",\n    \"men_wrestling\": \"🤼‍♂️\",\n    \"mending_heart\": \"❤️‍🩹\",\n    \"menorah\": \"🕎\",\n    \"mens\": \"🚹\",\n    \"mermaid\": \"🧜‍♀️\",\n    \"merman\": \"🧜‍♂️\",\n    \"merperson\": \"🧜\",\n    \"metal\": \"🤘\",\n    \"metro\": \"🚇\",\n    \"mexico\": \"🇲🇽\",\n    \"microbe\": \"🦠\",\n    \"micronesia\": \"🇫🇲\",\n    \"microphone\": \"🎤\",\n    \"microscope\": \"🔬\",\n    \"middle_finger\": \"🖕\",\n    \"military_helmet\": \"🪖\",\n    \"milk_glass\": \"🥛\",\n    \"milky_way\": \"🌌\",\n    \"minibus\": \"🚐\",\n    \"minidisc\": \"💽\",\n    \"mirror\": \"🪞\",\n    \"mobile_phone_off\": \"📴\",\n    \"moldova\": \"🇲🇩\",\n    \"monaco\": \"🇲🇨\",\n    \"money_mouth_face\": \"🤑\",\n    \"money_with_wings\": \"💸\",\n    \"moneybag\": \"💰\",\n    \"mongolia\": \"🇲🇳\",\n    \"monkey\": \"🐒\",\n    \"monkey_face\": \"🐵\",\n    \"monocle_face\": \"🧐\",\n    \"monorail\": \"🚝\",\n    \"montenegro\": \"🇲🇪\",\n    \"montserrat\": \"🇲🇸\",\n    \"moon\": \"🌔\",\n    \"moon_cake\": \"🥮\",\n    \"morocco\": \"🇲🇦\",\n    \"mortar_board\": \"🎓\",\n    \"mosque\": \"🕌\",\n    \"mosquito\": \"🦟\",\n    \"motor_boat\": \"🛥️\",\n    \"motor_scooter\": \"🛵\",\n    \"motorcycle\": \"🏍️\",\n    \"motorized_wheelchair\": \"🦼\",\n    \"motorway\": \"🛣️\",\n    \"mount_fuji\": \"🗻\",\n    \"mountain\": \"⛰️\",\n    \"mountain_bicyclist\": \"🚵\",\n    \"mountain_biking_man\": \"🚵‍♂️\",\n    \"mountain_biking_woman\": \"🚵‍♀️\",\n    \"mountain_cableway\": \"🚠\",\n    \"mountain_railway\": \"🚞\",\n    \"mountain_snow\": \"🏔️\",\n    \"mouse\": \"🐭\",\n    \"mouse2\": \"🐁\",\n    \"mouse_trap\": \"🪤\",\n    \"movie_camera\": \"🎥\",\n    \"moyai\": \"🗿\",\n    \"mozambique\": \"🇲🇿\",\n    \"mrs_claus\": \"🤶\",\n    \"muscle\": \"💪\",\n    \"mushroom\": \"🍄\",\n    \"musical_keyboard\": \"🎹\",\n    \"musical_note\": \"🎵\",\n    \"musical_score\": \"🎼\",\n    \"mute\": \"🔇\",\n    \"mx_claus\": \"🧑‍🎄\",\n    \"myanmar\": \"🇲🇲\",\n    \"nail_care\": \"💅\",\n    \"name_badge\": \"📛\",\n    \"namibia\": \"🇳🇦\",\n    \"national_park\": \"🏞️\",\n    \"nauru\": \"🇳🇷\",\n    \"nauseated_face\": \"🤢\",\n    \"nazar_amulet\": \"🧿\",\n    \"necktie\": \"👔\",\n    \"negative_squared_cross_mark\": \"❎\",\n    \"nepal\": \"🇳🇵\",\n    \"nerd_face\": \"🤓\",\n    \"nesting_dolls\": \"🪆\",\n    \"netherlands\": \"🇳🇱\",\n    \"neutral_face\": \"😐\",\n    \"new\": \"🆕\",\n    \"new_caledonia\": \"🇳🇨\",\n    \"new_moon\": \"🌑\",\n    \"new_moon_with_face\": \"🌚\",\n    \"new_zealand\": \"🇳🇿\",\n    \"newspaper\": \"📰\",\n    \"newspaper_roll\": \"🗞️\",\n    \"next_track_button\": \"⏭️\",\n    \"ng\": \"🆖\",\n    \"ng_man\": \"🙅‍♂️\",\n    \"ng_woman\": \"🙅‍♀️\",\n    \"nicaragua\": \"🇳🇮\",\n    \"niger\": \"🇳🇪\",\n    \"nigeria\": \"🇳🇬\",\n    \"night_with_stars\": \"🌃\",\n    \"nine\": \"9️⃣\",\n    \"ninja\": \"🥷\",\n    \"niue\": \"🇳🇺\",\n    \"no_bell\": \"🔕\",\n    \"no_bicycles\": \"🚳\",\n    \"no_entry\": \"⛔\",\n    \"no_entry_sign\": \"🚫\",\n    \"no_good\": \"🙅\",\n    \"no_good_man\": \"🙅‍♂️\",\n    \"no_good_woman\": \"🙅‍♀️\",\n    \"no_mobile_phones\": \"📵\",\n    \"no_mouth\": \"😶\",\n    \"no_pedestrians\": \"🚷\",\n    \"no_smoking\": \"🚭\",\n    \"non-potable_water\": \"🚱\",\n    \"norfolk_island\": \"🇳🇫\",\n    \"north_korea\": \"🇰🇵\",\n    \"northern_mariana_islands\": \"🇲🇵\",\n    \"norway\": \"🇳🇴\",\n    \"nose\": \"👃\",\n    \"notebook\": \"📓\",\n    \"notebook_with_decorative_cover\": \"📔\",\n    \"notes\": \"🎶\",\n    \"nut_and_bolt\": \"🔩\",\n    \"o\": \"⭕\",\n    \"o2\": \"🅾️\",\n    \"ocean\": \"🌊\",\n    \"octopus\": \"🐙\",\n    \"oden\": \"🍢\",\n    \"office\": \"🏢\",\n    \"office_worker\": \"🧑‍💼\",\n    \"oil_drum\": \"🛢️\",\n    \"ok\": \"🆗\",\n    \"ok_hand\": \"👌\",\n    \"ok_man\": \"🙆‍♂️\",\n    \"ok_person\": \"🙆\",\n    \"ok_woman\": \"🙆‍♀️\",\n    \"old_key\": \"🗝️\",\n    \"older_adult\": \"🧓\",\n    \"older_man\": \"👴\",\n    \"older_woman\": \"👵\",\n    \"olive\": \"🫒\",\n    \"om\": \"🕉️\",\n    \"oman\": \"🇴🇲\",\n    \"on\": \"🔛\",\n    \"oncoming_automobile\": \"🚘\",\n    \"oncoming_bus\": \"🚍\",\n    \"oncoming_police_car\": \"🚔\",\n    \"oncoming_taxi\": \"🚖\",\n    \"one\": \"1️⃣\",\n    \"one_piece_swimsuit\": \"🩱\",\n    \"onion\": \"🧅\",\n    \"open_book\": \"📖\",\n    \"open_file_folder\": \"📂\",\n    \"open_hands\": \"👐\",\n    \"open_mouth\": \"😮\",\n    \"open_umbrella\": \"☂️\",\n    \"ophiuchus\": \"⛎\",\n    \"orange\": \"🍊\",\n    \"orange_book\": \"📙\",\n    \"orange_circle\": \"🟠\",\n    \"orange_heart\": \"🧡\",\n    \"orange_square\": \"🟧\",\n    \"orangutan\": \"🦧\",\n    \"orthodox_cross\": \"☦️\",\n    \"otter\": \"🦦\",\n    \"outbox_tray\": \"📤\",\n    \"owl\": \"🦉\",\n    \"ox\": \"🐂\",\n    \"oyster\": \"🦪\",\n    \"package\": \"📦\",\n    \"page_facing_up\": \"📄\",\n    \"page_with_curl\": \"📃\",\n    \"pager\": \"📟\",\n    \"paintbrush\": \"🖌️\",\n    \"pakistan\": \"🇵🇰\",\n    \"palau\": \"🇵🇼\",\n    \"palestinian_territories\": \"🇵🇸\",\n    \"palm_tree\": \"🌴\",\n    \"palms_up_together\": \"🤲\",\n    \"panama\": \"🇵🇦\",\n    \"pancakes\": \"🥞\",\n    \"panda_face\": \"🐼\",\n    \"paperclip\": \"📎\",\n    \"paperclips\": \"🖇️\",\n    \"papua_new_guinea\": \"🇵🇬\",\n    \"parachute\": \"🪂\",\n    \"paraguay\": \"🇵🇾\",\n    \"parasol_on_ground\": \"⛱️\",\n    \"parking\": \"🅿️\",\n    \"parrot\": \"🦜\",\n    \"part_alternation_mark\": \"〽️\",\n    \"partly_sunny\": \"⛅\",\n    \"partying_face\": \"🥳\",\n    \"passenger_ship\": \"🛳️\",\n    \"passport_control\": \"🛂\",\n    \"pause_button\": \"⏸️\",\n    \"paw_prints\": \"🐾\",\n    \"peace_symbol\": \"☮️\",\n    \"peach\": \"🍑\",\n    \"peacock\": \"🦚\",\n    \"peanuts\": \"🥜\",\n    \"pear\": \"🍐\",\n    \"pen\": \"🖊️\",\n    \"pencil\": \"📝\",\n    \"pencil2\": \"✏️\",\n    \"penguin\": \"🐧\",\n    \"pensive\": \"😔\",\n    \"people_holding_hands\": \"🧑‍🤝‍🧑\",\n    \"people_hugging\": \"🫂\",\n    \"performing_arts\": \"🎭\",\n    \"persevere\": \"😣\",\n    \"person_bald\": \"🧑‍🦲\",\n    \"person_curly_hair\": \"🧑‍🦱\",\n    \"person_feeding_baby\": \"🧑‍🍼\",\n    \"person_fencing\": \"🤺\",\n    \"person_in_manual_wheelchair\": \"🧑‍🦽\",\n    \"person_in_motorized_wheelchair\": \"🧑‍🦼\",\n    \"person_in_tuxedo\": \"🤵\",\n    \"person_red_hair\": \"🧑‍🦰\",\n    \"person_white_hair\": \"🧑‍🦳\",\n    \"person_with_probing_cane\": \"🧑‍🦯\",\n    \"person_with_turban\": \"👳\",\n    \"person_with_veil\": \"👰\",\n    \"peru\": \"🇵🇪\",\n    \"petri_dish\": \"🧫\",\n    \"philippines\": \"🇵🇭\",\n    \"phone\": \"☎️\",\n    \"pick\": \"⛏️\",\n    \"pickup_truck\": \"🛻\",\n    \"pie\": \"🥧\",\n    \"pig\": \"🐷\",\n    \"pig2\": \"🐖\",\n    \"pig_nose\": \"🐽\",\n    \"pill\": \"💊\",\n    \"pilot\": \"🧑‍✈️\",\n    \"pinata\": \"🪅\",\n    \"pinched_fingers\": \"🤌\",\n    \"pinching_hand\": \"🤏\",\n    \"pineapple\": \"🍍\",\n    \"ping_pong\": \"🏓\",\n    \"pirate_flag\": \"🏴‍☠️\",\n    \"pisces\": \"♓\",\n    \"pitcairn_islands\": \"🇵🇳\",\n    \"pizza\": \"🍕\",\n    \"placard\": \"🪧\",\n    \"place_of_worship\": \"🛐\",\n    \"plate_with_cutlery\": \"🍽️\",\n    \"play_or_pause_button\": \"⏯️\",\n    \"pleading_face\": \"🥺\",\n    \"plunger\": \"🪠\",\n    \"point_down\": \"👇\",\n    \"point_left\": \"👈\",\n    \"point_right\": \"👉\",\n    \"point_up\": \"☝️\",\n    \"point_up_2\": \"👆\",\n    \"poland\": \"🇵🇱\",\n    \"polar_bear\": \"🐻‍❄️\",\n    \"police_car\": \"🚓\",\n    \"police_officer\": \"👮\",\n    \"policeman\": \"👮‍♂️\",\n    \"policewoman\": \"👮‍♀️\",\n    \"poodle\": \"🐩\",\n    \"poop\": \"💩\",\n    \"popcorn\": \"🍿\",\n    \"portugal\": \"🇵🇹\",\n    \"post_office\": \"🏣\",\n    \"postal_horn\": \"📯\",\n    \"postbox\": \"📮\",\n    \"potable_water\": \"🚰\",\n    \"potato\": \"🥔\",\n    \"potted_plant\": \"🪴\",\n    \"pouch\": \"👝\",\n    \"poultry_leg\": \"🍗\",\n    \"pound\": \"💷\",\n    \"pout\": \"😡\",\n    \"pouting_cat\": \"😾\",\n    \"pouting_face\": \"🙎\",\n    \"pouting_man\": \"🙎‍♂️\",\n    \"pouting_woman\": \"🙎‍♀️\",\n    \"pray\": \"🙏\",\n    \"prayer_beads\": \"📿\",\n    \"pregnant_woman\": \"🤰\",\n    \"pretzel\": \"🥨\",\n    \"previous_track_button\": \"⏮️\",\n    \"prince\": \"🤴\",\n    \"princess\": \"👸\",\n    \"printer\": \"🖨️\",\n    \"probing_cane\": \"🦯\",\n    \"puerto_rico\": \"🇵🇷\",\n    \"punch\": \"👊\",\n    \"purple_circle\": \"🟣\",\n    \"purple_heart\": \"💜\",\n    \"purple_square\": \"🟪\",\n    \"purse\": \"👛\",\n    \"pushpin\": \"📌\",\n    \"put_litter_in_its_place\": \"🚮\",\n    \"qatar\": \"🇶🇦\",\n    \"question\": \"❓\",\n    \"rabbit\": \"🐰\",\n    \"rabbit2\": \"🐇\",\n    \"raccoon\": \"🦝\",\n    \"racehorse\": \"🐎\",\n    \"racing_car\": \"🏎️\",\n    \"radio\": \"📻\",\n    \"radio_button\": \"🔘\",\n    \"radioactive\": \"☢️\",\n    \"rage\": \"😡\",\n    \"railway_car\": \"🚃\",\n    \"railway_track\": \"🛤️\",\n    \"rainbow\": \"🌈\",\n    \"rainbow_flag\": \"🏳️‍🌈\",\n    \"raised_back_of_hand\": \"🤚\",\n    \"raised_eyebrow\": \"🤨\",\n    \"raised_hand\": \"✋\",\n    \"raised_hand_with_fingers_splayed\": \"🖐️\",\n    \"raised_hands\": \"🙌\",\n    \"raising_hand\": \"🙋\",\n    \"raising_hand_man\": \"🙋‍♂️\",\n    \"raising_hand_woman\": \"🙋‍♀️\",\n    \"ram\": \"🐏\",\n    \"ramen\": \"🍜\",\n    \"rat\": \"🐀\",\n    \"razor\": \"🪒\",\n    \"receipt\": \"🧾\",\n    \"record_button\": \"⏺️\",\n    \"recycle\": \"♻️\",\n    \"red_car\": \"🚗\",\n    \"red_circle\": \"🔴\",\n    \"red_envelope\": \"🧧\",\n    \"red_haired_man\": \"👨‍🦰\",\n    \"red_haired_woman\": \"👩‍🦰\",\n    \"red_square\": \"🟥\",\n    \"registered\": \"®️\",\n    \"relaxed\": \"☺️\",\n    \"relieved\": \"😌\",\n    \"reminder_ribbon\": \"🎗️\",\n    \"repeat\": \"🔁\",\n    \"repeat_one\": \"🔂\",\n    \"rescue_worker_helmet\": \"⛑️\",\n    \"restroom\": \"🚻\",\n    \"reunion\": \"🇷🇪\",\n    \"revolving_hearts\": \"💞\",\n    \"rewind\": \"⏪\",\n    \"rhinoceros\": \"🦏\",\n    \"ribbon\": \"🎀\",\n    \"rice\": \"🍚\",\n    \"rice_ball\": \"🍙\",\n    \"rice_cracker\": \"🍘\",\n    \"rice_scene\": \"🎑\",\n    \"right_anger_bubble\": \"🗯️\",\n    \"ring\": \"💍\",\n    \"ringed_planet\": \"🪐\",\n    \"robot\": \"🤖\",\n    \"rock\": \"🪨\",\n    \"rocket\": \"🚀\",\n    \"rofl\": \"🤣\",\n    \"roll_eyes\": \"🙄\",\n    \"roll_of_paper\": \"🧻\",\n    \"roller_coaster\": \"🎢\",\n    \"roller_skate\": \"🛼\",\n    \"romania\": \"🇷🇴\",\n    \"rooster\": \"🐓\",\n    \"rose\": \"🌹\",\n    \"rosette\": \"🏵️\",\n    \"rotating_light\": \"🚨\",\n    \"round_pushpin\": \"📍\",\n    \"rowboat\": \"🚣\",\n    \"rowing_man\": \"🚣‍♂️\",\n    \"rowing_woman\": \"🚣‍♀️\",\n    \"ru\": \"🇷🇺\",\n    \"rugby_football\": \"🏉\",\n    \"runner\": \"🏃\",\n    \"running\": \"🏃\",\n    \"running_man\": \"🏃‍♂️\",\n    \"running_shirt_with_sash\": \"🎽\",\n    \"running_woman\": \"🏃‍♀️\",\n    \"rwanda\": \"🇷🇼\",\n    \"sa\": \"🈂️\",\n    \"safety_pin\": \"🧷\",\n    \"safety_vest\": \"🦺\",\n    \"sagittarius\": \"♐\",\n    \"sailboat\": \"⛵\",\n    \"sake\": \"🍶\",\n    \"salt\": \"🧂\",\n    \"samoa\": \"🇼🇸\",\n    \"san_marino\": \"🇸🇲\",\n    \"sandal\": \"👡\",\n    \"sandwich\": \"🥪\",\n    \"santa\": \"🎅\",\n    \"sao_tome_principe\": \"🇸🇹\",\n    \"sari\": \"🥻\",\n    \"sassy_man\": \"💁‍♂️\",\n    \"sassy_woman\": \"💁‍♀️\",\n    \"satellite\": \"📡\",\n    \"satisfied\": \"😆\",\n    \"saudi_arabia\": \"🇸🇦\",\n    \"sauna_man\": \"🧖‍♂️\",\n    \"sauna_person\": \"🧖\",\n    \"sauna_woman\": \"🧖‍♀️\",\n    \"sauropod\": \"🦕\",\n    \"saxophone\": \"🎷\",\n    \"scarf\": \"🧣\",\n    \"school\": \"🏫\",\n    \"school_satchel\": \"🎒\",\n    \"scientist\": \"🧑‍🔬\",\n    \"scissors\": \"✂️\",\n    \"scorpion\": \"🦂\",\n    \"scorpius\": \"♏\",\n    \"scotland\": \"🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\n    \"scream\": \"😱\",\n    \"scream_cat\": \"🙀\",\n    \"screwdriver\": \"🪛\",\n    \"scroll\": \"📜\",\n    \"seal\": \"🦭\",\n    \"seat\": \"💺\",\n    \"secret\": \"㊙️\",\n    \"see_no_evil\": \"🙈\",\n    \"seedling\": \"🌱\",\n    \"selfie\": \"🤳\",\n    \"senegal\": \"🇸🇳\",\n    \"serbia\": \"🇷🇸\",\n    \"service_dog\": \"🐕‍🦺\",\n    \"seven\": \"7️⃣\",\n    \"sewing_needle\": \"🪡\",\n    \"seychelles\": \"🇸🇨\",\n    \"shallow_pan_of_food\": \"🥘\",\n    \"shamrock\": \"☘️\",\n    \"shark\": \"🦈\",\n    \"shaved_ice\": \"🍧\",\n    \"sheep\": \"🐑\",\n    \"shell\": \"🐚\",\n    \"shield\": \"🛡️\",\n    \"shinto_shrine\": \"⛩️\",\n    \"ship\": \"🚢\",\n    \"shirt\": \"👕\",\n    \"shit\": \"💩\",\n    \"shoe\": \"👞\",\n    \"shopping\": \"🛍️\",\n    \"shopping_cart\": \"🛒\",\n    \"shorts\": \"🩳\",\n    \"shower\": \"🚿\",\n    \"shrimp\": \"🦐\",\n    \"shrug\": \"🤷\",\n    \"shushing_face\": \"🤫\",\n    \"sierra_leone\": \"🇸🇱\",\n    \"signal_strength\": \"📶\",\n    \"singapore\": \"🇸🇬\",\n    \"singer\": \"🧑‍🎤\",\n    \"sint_maarten\": \"🇸🇽\",\n    \"six\": \"6️⃣\",\n    \"six_pointed_star\": \"🔯\",\n    \"skateboard\": \"🛹\",\n    \"ski\": \"🎿\",\n    \"skier\": \"⛷️\",\n    \"skull\": \"💀\",\n    \"skull_and_crossbones\": \"☠️\",\n    \"skunk\": \"🦨\",\n    \"sled\": \"🛷\",\n    \"sleeping\": \"😴\",\n    \"sleeping_bed\": \"🛌\",\n    \"sleepy\": \"😪\",\n    \"slightly_frowning_face\": \"🙁\",\n    \"slightly_smiling_face\": \"🙂\",\n    \"slot_machine\": \"🎰\",\n    \"sloth\": \"🦥\",\n    \"slovakia\": \"🇸🇰\",\n    \"slovenia\": \"🇸🇮\",\n    \"small_airplane\": \"🛩️\",\n    \"small_blue_diamond\": \"🔹\",\n    \"small_orange_diamond\": \"🔸\",\n    \"small_red_triangle\": \"🔺\",\n    \"small_red_triangle_down\": \"🔻\",\n    \"smile\": \"😄\",\n    \"smile_cat\": \"😸\",\n    \"smiley\": \"😃\",\n    \"smiley_cat\": \"😺\",\n    \"smiling_face_with_tear\": \"🥲\",\n    \"smiling_face_with_three_hearts\": \"🥰\",\n    \"smiling_imp\": \"😈\",\n    \"smirk\": \"😏\",\n    \"smirk_cat\": \"😼\",\n    \"smoking\": \"🚬\",\n    \"snail\": \"🐌\",\n    \"snake\": \"🐍\",\n    \"sneezing_face\": \"🤧\",\n    \"snowboarder\": \"🏂\",\n    \"snowflake\": \"❄️\",\n    \"snowman\": \"⛄\",\n    \"snowman_with_snow\": \"☃️\",\n    \"soap\": \"🧼\",\n    \"sob\": \"😭\",\n    \"soccer\": \"⚽\",\n    \"socks\": \"🧦\",\n    \"softball\": \"🥎\",\n    \"solomon_islands\": \"🇸🇧\",\n    \"somalia\": \"🇸🇴\",\n    \"soon\": \"🔜\",\n    \"sos\": \"🆘\",\n    \"sound\": \"🔉\",\n    \"south_africa\": \"🇿🇦\",\n    \"south_georgia_south_sandwich_islands\": \"🇬🇸\",\n    \"south_sudan\": \"🇸🇸\",\n    \"space_invader\": \"👾\",\n    \"spades\": \"♠️\",\n    \"spaghetti\": \"🍝\",\n    \"sparkle\": \"❇️\",\n    \"sparkler\": \"🎇\",\n    \"sparkles\": \"✨\",\n    \"sparkling_heart\": \"💖\",\n    \"speak_no_evil\": \"🙊\",\n    \"speaker\": \"🔈\",\n    \"speaking_head\": \"🗣️\",\n    \"speech_balloon\": \"💬\",\n    \"speedboat\": \"🚤\",\n    \"spider\": \"🕷️\",\n    \"spider_web\": \"🕸️\",\n    \"spiral_calendar\": \"🗓️\",\n    \"spiral_notepad\": \"🗒️\",\n    \"sponge\": \"🧽\",\n    \"spoon\": \"🥄\",\n    \"squid\": \"🦑\",\n    \"sri_lanka\": \"🇱🇰\",\n    \"st_barthelemy\": \"🇧🇱\",\n    \"st_helena\": \"🇸🇭\",\n    \"st_kitts_nevis\": \"🇰🇳\",\n    \"st_lucia\": \"🇱🇨\",\n    \"st_martin\": \"🇲🇫\",\n    \"st_pierre_miquelon\": \"🇵🇲\",\n    \"st_vincent_grenadines\": \"🇻🇨\",\n    \"stadium\": \"🏟️\",\n    \"standing_man\": \"🧍‍♂️\",\n    \"standing_person\": \"🧍\",\n    \"standing_woman\": \"🧍‍♀️\",\n    \"star\": \"⭐\",\n    \"star2\": \"🌟\",\n    \"star_and_crescent\": \"☪️\",\n    \"star_of_david\": \"✡️\",\n    \"star_struck\": \"🤩\",\n    \"stars\": \"🌠\",\n    \"station\": \"🚉\",\n    \"statue_of_liberty\": \"🗽\",\n    \"steam_locomotive\": \"🚂\",\n    \"stethoscope\": \"🩺\",\n    \"stew\": \"🍲\",\n    \"stop_button\": \"⏹️\",\n    \"stop_sign\": \"🛑\",\n    \"stopwatch\": \"⏱️\",\n    \"straight_ruler\": \"📏\",\n    \"strawberry\": \"🍓\",\n    \"stuck_out_tongue\": \"😛\",\n    \"stuck_out_tongue_closed_eyes\": \"😝\",\n    \"stuck_out_tongue_winking_eye\": \"😜\",\n    \"student\": \"🧑‍🎓\",\n    \"studio_microphone\": \"🎙️\",\n    \"stuffed_flatbread\": \"🥙\",\n    \"sudan\": \"🇸🇩\",\n    \"sun_behind_large_cloud\": \"🌥️\",\n    \"sun_behind_rain_cloud\": \"🌦️\",\n    \"sun_behind_small_cloud\": \"🌤️\",\n    \"sun_with_face\": \"🌞\",\n    \"sunflower\": \"🌻\",\n    \"sunglasses\": \"😎\",\n    \"sunny\": \"☀️\",\n    \"sunrise\": \"🌅\",\n    \"sunrise_over_mountains\": \"🌄\",\n    \"superhero\": \"🦸\",\n    \"superhero_man\": \"🦸‍♂️\",\n    \"superhero_woman\": \"🦸‍♀️\",\n    \"supervillain\": \"🦹\",\n    \"supervillain_man\": \"🦹‍♂️\",\n    \"supervillain_woman\": \"🦹‍♀️\",\n    \"surfer\": \"🏄\",\n    \"surfing_man\": \"🏄‍♂️\",\n    \"surfing_woman\": \"🏄‍♀️\",\n    \"suriname\": \"🇸🇷\",\n    \"sushi\": \"🍣\",\n    \"suspension_railway\": \"🚟\",\n    \"svalbard_jan_mayen\": \"🇸🇯\",\n    \"swan\": \"🦢\",\n    \"swaziland\": \"🇸🇿\",\n    \"sweat\": \"😓\",\n    \"sweat_drops\": \"💦\",\n    \"sweat_smile\": \"😅\",\n    \"sweden\": \"🇸🇪\",\n    \"sweet_potato\": \"🍠\",\n    \"swim_brief\": \"🩲\",\n    \"swimmer\": \"🏊\",\n    \"swimming_man\": \"🏊‍♂️\",\n    \"swimming_woman\": \"🏊‍♀️\",\n    \"switzerland\": \"🇨🇭\",\n    \"symbols\": \"🔣\",\n    \"synagogue\": \"🕍\",\n    \"syria\": \"🇸🇾\",\n    \"syringe\": \"💉\",\n    \"t-rex\": \"🦖\",\n    \"taco\": \"🌮\",\n    \"tada\": \"🎉\",\n    \"taiwan\": \"🇹🇼\",\n    \"tajikistan\": \"🇹🇯\",\n    \"takeout_box\": \"🥡\",\n    \"tamale\": \"🫔\",\n    \"tanabata_tree\": \"🎋\",\n    \"tangerine\": \"🍊\",\n    \"tanzania\": \"🇹🇿\",\n    \"taurus\": \"♉\",\n    \"taxi\": \"🚕\",\n    \"tea\": \"🍵\",\n    \"teacher\": \"🧑‍🏫\",\n    \"teapot\": \"🫖\",\n    \"technologist\": \"🧑‍💻\",\n    \"teddy_bear\": \"🧸\",\n    \"telephone\": \"☎️\",\n    \"telephone_receiver\": \"📞\",\n    \"telescope\": \"🔭\",\n    \"tennis\": \"🎾\",\n    \"tent\": \"⛺\",\n    \"test_tube\": \"🧪\",\n    \"thailand\": \"🇹🇭\",\n    \"thermometer\": \"🌡️\",\n    \"thinking\": \"🤔\",\n    \"thong_sandal\": \"🩴\",\n    \"thought_balloon\": \"💭\",\n    \"thread\": \"🧵\",\n    \"three\": \"3️⃣\",\n    \"thumbsdown\": \"👎\",\n    \"thumbsup\": \"👍\",\n    \"ticket\": \"🎫\",\n    \"tickets\": \"🎟️\",\n    \"tiger\": \"🐯\",\n    \"tiger2\": \"🐅\",\n    \"timer_clock\": \"⏲️\",\n    \"timor_leste\": \"🇹🇱\",\n    \"tipping_hand_man\": \"💁‍♂️\",\n    \"tipping_hand_person\": \"💁\",\n    \"tipping_hand_woman\": \"💁‍♀️\",\n    \"tired_face\": \"😫\",\n    \"tm\": \"™️\",\n    \"togo\": \"🇹🇬\",\n    \"toilet\": \"🚽\",\n    \"tokelau\": \"🇹🇰\",\n    \"tokyo_tower\": \"🗼\",\n    \"tomato\": \"🍅\",\n    \"tonga\": \"🇹🇴\",\n    \"tongue\": \"👅\",\n    \"toolbox\": \"🧰\",\n    \"tooth\": \"🦷\",\n    \"toothbrush\": \"🪥\",\n    \"top\": \"🔝\",\n    \"tophat\": \"🎩\",\n    \"tornado\": \"🌪️\",\n    \"tr\": \"🇹🇷\",\n    \"trackball\": \"🖲️\",\n    \"tractor\": \"🚜\",\n    \"traffic_light\": \"🚥\",\n    \"train\": \"🚋\",\n    \"train2\": \"🚆\",\n    \"tram\": \"🚊\",\n    \"transgender_flag\": \"🏳️‍⚧️\",\n    \"transgender_symbol\": \"⚧️\",\n    \"triangular_flag_on_post\": \"🚩\",\n    \"triangular_ruler\": \"📐\",\n    \"trident\": \"🔱\",\n    \"trinidad_tobago\": \"🇹🇹\",\n    \"tristan_da_cunha\": \"🇹🇦\",\n    \"triumph\": \"😤\",\n    \"trolleybus\": \"🚎\",\n    \"trophy\": \"🏆\",\n    \"tropical_drink\": \"🍹\",\n    \"tropical_fish\": \"🐠\",\n    \"truck\": \"🚚\",\n    \"trumpet\": \"🎺\",\n    \"tshirt\": \"👕\",\n    \"tulip\": \"🌷\",\n    \"tumbler_glass\": \"🥃\",\n    \"tunisia\": \"🇹🇳\",\n    \"turkey\": \"🦃\",\n    \"turkmenistan\": \"🇹🇲\",\n    \"turks_caicos_islands\": \"🇹🇨\",\n    \"turtle\": \"🐢\",\n    \"tuvalu\": \"🇹🇻\",\n    \"tv\": \"📺\",\n    \"twisted_rightwards_arrows\": \"🔀\",\n    \"two\": \"2️⃣\",\n    \"two_hearts\": \"💕\",\n    \"two_men_holding_hands\": \"👬\",\n    \"two_women_holding_hands\": \"👭\",\n    \"u5272\": \"🈹\",\n    \"u5408\": \"🈴\",\n    \"u55b6\": \"🈺\",\n    \"u6307\": \"🈯\",\n    \"u6708\": \"🈷️\",\n    \"u6709\": \"🈶\",\n    \"u6e80\": \"🈵\",\n    \"u7121\": \"🈚\",\n    \"u7533\": \"🈸\",\n    \"u7981\": \"🈲\",\n    \"u7a7a\": \"🈳\",\n    \"uganda\": \"🇺🇬\",\n    \"uk\": \"🇬🇧\",\n    \"ukraine\": \"🇺🇦\",\n    \"umbrella\": \"☔\",\n    \"unamused\": \"😒\",\n    \"underage\": \"🔞\",\n    \"unicorn\": \"🦄\",\n    \"united_arab_emirates\": \"🇦🇪\",\n    \"united_nations\": \"🇺🇳\",\n    \"unlock\": \"🔓\",\n    \"up\": \"🆙\",\n    \"upside_down_face\": \"🙃\",\n    \"uruguay\": \"🇺🇾\",\n    \"us\": \"🇺🇸\",\n    \"us_outlying_islands\": \"🇺🇲\",\n    \"us_virgin_islands\": \"🇻🇮\",\n    \"uzbekistan\": \"🇺🇿\",\n    \"v\": \"✌️\",\n    \"vampire\": \"🧛\",\n    \"vampire_man\": \"🧛‍♂️\",\n    \"vampire_woman\": \"🧛‍♀️\",\n    \"vanuatu\": \"🇻🇺\",\n    \"vatican_city\": \"🇻🇦\",\n    \"venezuela\": \"🇻🇪\",\n    \"vertical_traffic_light\": \"🚦\",\n    \"vhs\": \"📼\",\n    \"vibration_mode\": \"📳\",\n    \"video_camera\": \"📹\",\n    \"video_game\": \"🎮\",\n    \"vietnam\": \"🇻🇳\",\n    \"violin\": \"🎻\",\n    \"virgo\": \"♍\",\n    \"volcano\": \"🌋\",\n    \"volleyball\": \"🏐\",\n    \"vomiting_face\": \"🤮\",\n    \"vs\": \"🆚\",\n    \"vulcan_salute\": \"🖖\",\n    \"waffle\": \"🧇\",\n    \"wales\": \"🏴󠁧󠁢󠁷󠁬󠁳󠁿\",\n    \"walking\": \"🚶\",\n    \"walking_man\": \"🚶‍♂️\",\n    \"walking_woman\": \"🚶‍♀️\",\n    \"wallis_futuna\": \"🇼🇫\",\n    \"waning_crescent_moon\": \"🌘\",\n    \"waning_gibbous_moon\": \"🌖\",\n    \"warning\": \"⚠️\",\n    \"wastebasket\": \"🗑️\",\n    \"watch\": \"⌚\",\n    \"water_buffalo\": \"🐃\",\n    \"water_polo\": \"🤽\",\n    \"watermelon\": \"🍉\",\n    \"wave\": \"👋\",\n    \"wavy_dash\": \"〰️\",\n    \"waxing_crescent_moon\": \"🌒\",\n    \"waxing_gibbous_moon\": \"🌔\",\n    \"wc\": \"🚾\",\n    \"weary\": \"😩\",\n    \"wedding\": \"💒\",\n    \"weight_lifting\": \"🏋️\",\n    \"weight_lifting_man\": \"🏋️‍♂️\",\n    \"weight_lifting_woman\": \"🏋️‍♀️\",\n    \"western_sahara\": \"🇪🇭\",\n    \"whale\": \"🐳\",\n    \"whale2\": \"🐋\",\n    \"wheel_of_dharma\": \"☸️\",\n    \"wheelchair\": \"♿\",\n    \"white_check_mark\": \"✅\",\n    \"white_circle\": \"⚪\",\n    \"white_flag\": \"🏳️\",\n    \"white_flower\": \"💮\",\n    \"white_haired_man\": \"👨‍🦳\",\n    \"white_haired_woman\": \"👩‍🦳\",\n    \"white_heart\": \"🤍\",\n    \"white_large_square\": \"⬜\",\n    \"white_medium_small_square\": \"◽\",\n    \"white_medium_square\": \"◻️\",\n    \"white_small_square\": \"▫️\",\n    \"white_square_button\": \"🔳\",\n    \"wilted_flower\": \"🥀\",\n    \"wind_chime\": \"🎐\",\n    \"wind_face\": \"🌬️\",\n    \"window\": \"🪟\",\n    \"wine_glass\": \"🍷\",\n    \"wink\": \"😉\",\n    \"wolf\": \"🐺\",\n    \"woman\": \"👩\",\n    \"woman_artist\": \"👩‍🎨\",\n    \"woman_astronaut\": \"👩‍🚀\",\n    \"woman_beard\": \"🧔‍♀️\",\n    \"woman_cartwheeling\": \"🤸‍♀️\",\n    \"woman_cook\": \"👩‍🍳\",\n    \"woman_dancing\": \"💃\",\n    \"woman_facepalming\": \"🤦‍♀️\",\n    \"woman_factory_worker\": \"👩‍🏭\",\n    \"woman_farmer\": \"👩‍🌾\",\n    \"woman_feeding_baby\": \"👩‍🍼\",\n    \"woman_firefighter\": \"👩‍🚒\",\n    \"woman_health_worker\": \"👩‍⚕️\",\n    \"woman_in_manual_wheelchair\": \"👩‍🦽\",\n    \"woman_in_motorized_wheelchair\": \"👩‍🦼\",\n    \"woman_in_tuxedo\": \"🤵‍♀️\",\n    \"woman_judge\": \"👩‍⚖️\",\n    \"woman_juggling\": \"🤹‍♀️\",\n    \"woman_mechanic\": \"👩‍🔧\",\n    \"woman_office_worker\": \"👩‍💼\",\n    \"woman_pilot\": \"👩‍✈️\",\n    \"woman_playing_handball\": \"🤾‍♀️\",\n    \"woman_playing_water_polo\": \"🤽‍♀️\",\n    \"woman_scientist\": \"👩‍🔬\",\n    \"woman_shrugging\": \"🤷‍♀️\",\n    \"woman_singer\": \"👩‍🎤\",\n    \"woman_student\": \"👩‍🎓\",\n    \"woman_teacher\": \"👩‍🏫\",\n    \"woman_technologist\": \"👩‍💻\",\n    \"woman_with_headscarf\": \"🧕\",\n    \"woman_with_probing_cane\": \"👩‍🦯\",\n    \"woman_with_turban\": \"👳‍♀️\",\n    \"woman_with_veil\": \"👰‍♀️\",\n    \"womans_clothes\": \"👚\",\n    \"womans_hat\": \"👒\",\n    \"women_wrestling\": \"🤼‍♀️\",\n    \"womens\": \"🚺\",\n    \"wood\": \"🪵\",\n    \"woozy_face\": \"🥴\",\n    \"world_map\": \"🗺️\",\n    \"worm\": \"🪱\",\n    \"worried\": \"😟\",\n    \"wrench\": \"🔧\",\n    \"wrestling\": \"🤼\",\n    \"writing_hand\": \"✍️\",\n    \"x\": \"❌\",\n    \"yarn\": \"🧶\",\n    \"yawning_face\": \"🥱\",\n    \"yellow_circle\": \"🟡\",\n    \"yellow_heart\": \"💛\",\n    \"yellow_square\": \"🟨\",\n    \"yemen\": \"🇾🇪\",\n    \"yen\": \"💴\",\n    \"yin_yang\": \"☯️\",\n    \"yo_yo\": \"🪀\",\n    \"yum\": \"😋\",\n    \"zambia\": \"🇿🇲\",\n    \"zany_face\": \"🤪\",\n    \"zap\": \"⚡\",\n    \"zebra\": \"🦓\",\n    \"zero\": \"0️⃣\",\n    \"zimbabwe\": \"🇿🇼\",\n    \"zipper_mouth_face\": \"🤐\",\n    \"zombie\": \"🧟\",\n    \"zombie_man\": \"🧟‍♂️\",\n    \"zombie_woman\": \"🧟‍♀️\",\n    \"zzz\": \"💤\"\n}"
  },
  {
    "path": "server/ntfy.service",
    "content": "[Unit]\nDescription=ntfy server\nAfter=network.target\n\n[Service]\nUser=ntfy\nGroup=ntfy\nExecStart=/usr/bin/ntfy serve --no-log-dates\nExecReload=/bin/kill --signal HUP $MAINPID\nRestart=on-failure\nAmbientCapabilities=CAP_NET_BIND_SERVICE\nLimitNOFILE=10000\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "server/server.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"embed\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/pprof\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"text/template\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/emersion/go-smtp\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\t\"golang.org/x/sync/errgroup\"\n\t\"gopkg.in/yaml.v2\"\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/db/pg\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/message\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/payments\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"heckel.io/ntfy/v2/util/sprig\"\n\t\"heckel.io/ntfy/v2/webpush\"\n)\n\n// Server is the main server, providing the UI and API for ntfy\ntype Server struct {\n\tconfig            *Config\n\tdb                *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite\n\thttpServer        *http.Server\n\thttpsServer       *http.Server\n\thttpMetricsServer *http.Server\n\thttpProfileServer *http.Server\n\tunixListener      net.Listener\n\tsmtpServer        *smtp.Server\n\tsmtpServerBackend *smtpBackend\n\tsmtpSender        mailer\n\ttopics            map[string]*topic\n\tvisitors          map[string]*visitor // ip:<ip> or user:<user>\n\tfirebaseClient    *firebaseClient\n\tmessages          int64                               // Total number of messages (persisted if messageCache enabled)\n\tmessagesHistory   []int64                             // Last n values of the messages counter, used to determine rate\n\tuserManager       *user.Manager                       // Might be nil!\n\tmessageCache      *message.Cache                      // Database that stores the messages\n\twebPush           *webpush.Store                      // Database that stores web push subscriptions\n\tfileCache         *fileCache                          // File system based cache that stores attachments\n\tstripe            stripeAPI                           // Stripe API, can be replaced with a mock\n\tpriceCache        *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)\n\tmetricsHandler    http.Handler                        // Handles /metrics if enable-metrics set, and listen-metrics-http not set\n\tcloseChan         chan bool\n\tmu                sync.RWMutex\n}\n\n// handleFunc extends the normal http.HandlerFunc to be able to easily return errors\ntype handleFunc func(http.ResponseWriter, *http.Request, *visitor) error\n\nvar (\n\t// If changed, don't forget to update Android App and auth_sqlite.go\n\ttopicRegex             = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)               // No /!\n\ttopicPathRegex         = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`)              // Regex must match JS & Android app!\n\texternalTopicPathRegex = regexp.MustCompile(`^/[^/]+\\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic\n\tjsonPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)\n\tssePathRegex           = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)\n\trawPathRegex           = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)\n\twsPathRegex            = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)\n\tauthPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)\n\tpublishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)\n\tupdatePathRegex        = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)\n\tclearPathRegex         = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)\n\tsequenceIDRegex        = topicRegex\n\n\twebConfigPath                                        = \"/config.js\"\n\twebManifestPath                                      = \"/manifest.webmanifest\"\n\taccountPath                                          = \"/account\"\n\tmatrixPushPath                                       = \"/_matrix/push/v1/notify\"\n\tmetricsPath                                          = \"/metrics\"\n\tapiHealthPath                                        = \"/v1/health\"\n\tapiVersionPath                                       = \"/v1/version\"\n\tapiConfigPath                                        = \"/v1/config\"\n\tapiStatsPath                                         = \"/v1/stats\"\n\tapiWebPushPath                                       = \"/v1/webpush\"\n\tapiTiersPath                                         = \"/v1/tiers\"\n\tapiUsersPath                                         = \"/v1/users\"\n\tapiUsersAccessPath                                   = \"/v1/users/access\"\n\tapiAccountPath                                       = \"/v1/account\"\n\tapiAccountTokenPath                                  = \"/v1/account/token\"\n\tapiAccountPasswordPath                               = \"/v1/account/password\"\n\tapiAccountSettingsPath                               = \"/v1/account/settings\"\n\tapiAccountSubscriptionPath                           = \"/v1/account/subscription\"\n\tapiAccountReservationPath                            = \"/v1/account/reservation\"\n\tapiAccountPhonePath                                  = \"/v1/account/phone\"\n\tapiAccountPhoneVerifyPath                            = \"/v1/account/phone/verify\"\n\tapiAccountBillingPortalPath                          = \"/v1/account/billing/portal\"\n\tapiAccountBillingWebhookPath                         = \"/v1/account/billing/webhook\"\n\tapiAccountBillingSubscriptionPath                    = \"/v1/account/billing/subscription\"\n\tapiAccountBillingSubscriptionCheckoutSuccessTemplate = \"/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}\"\n\tapiAccountBillingSubscriptionCheckoutSuccessRegex    = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)\n\tapiAccountReservationSingleRegex                     = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)\n\tstaticRegex                                          = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)\n\tdocsRegex                                            = regexp.MustCompile(`^/docs(|/.*)$`)\n\tfileRegex                                            = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\\.[A-Za-z0-9]{1,16})?$`)\n\turlRegex                                             = regexp.MustCompile(`^https?://`)\n\tphoneNumberRegex                                     = regexp.MustCompile(`^\\+\\d{1,100}$`)\n\temailAddressRegex                                    = regexp.MustCompile(`^[^\\s,;]+@[^\\s,;]+$`)\n\n\t//go:embed site\n\twebFs       embed.FS\n\twebFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs}\n\twebSiteDir  = \"/site\"\n\twebAppIndex = \"/app.html\" // React app\n\n\t//go:embed docs\n\tdocsStaticFs     embed.FS\n\tdocsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}\n\n\t//go:embed templates\n\ttemplatesFs  embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)\n\ttemplatesDir = \"templates\"\n\n\t// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they\n\t// are not useful, and seem potentially troublesome.\n\ttemplateDisallowedRegex = regexp.MustCompile(`(?m)\\{\\{-?\\s*(call|template|define)\\b`)\n\ttemplateNameRegex       = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)\n)\n\nconst (\n\tfirebaseControlTopic     = \"~control\"                // See Android if changed\n\tfirebasePollTopic        = \"~poll\"                   // See iOS if changed (DISABLED for now)\n\temptyMessageBody         = \"triggered\"               // Used when a message body is empty\n\tnewMessageBody           = \"New message\"             // Used in poll requests as generic message\n\tdefaultAttachmentMessage = \"You received a file: %s\" // Used if message body is empty, and there is an attachment\n\tencodingBase64           = \"base64\"                  // Used mainly for binary UnifiedPush messages\n\tjsonBodyBytesLimit       = 131072                    // Max number of bytes for a request bodys (unless MessageLimit is higher)\n\tunifiedPushTopicPrefix   = \"up\"                      // Temporarily, we rate limit all \"up*\" topics based on the subscriber\n\tunifiedPushTopicLength   = 14                        // Length of UnifiedPush topics, including the \"up\" part\n\tmessagesHistoryMax       = 10                        // Number of message count values to keep in memory\n\ttemplateMaxExecutionTime = 100 * time.Millisecond    // Maximum time a template can take to execute, used to prevent DoS attacks\n\ttemplateMaxOutputBytes   = 1024 * 1024               // Maximum number of bytes a template can output, used to prevent DoS attacks\n\ttemplateFileExtension    = \".yml\"                    // Template files must end with this extension\n)\n\n// WebSocket constants\nconst (\n\twsWriteWait  = 2 * time.Second\n\twsBufferSize = 1024\n\twsReadLimit  = 64 // We only ever receive PINGs\n\twsPongWait   = 15 * time.Second\n)\n\n// New instantiates a new Server. It creates the cache and adds a Firebase\n// subscriber (if configured).\nfunc New(conf *Config) (*Server, error) {\n\tvar mailer mailer\n\tif conf.SMTPSenderAddr != \"\" {\n\t\tmailer = &smtpSender{config: conf}\n\t}\n\tvar stripe stripeAPI\n\tif payments.Available && conf.StripeSecretKey != \"\" {\n\t\tstripe = newStripeAPI()\n\t}\n\t// Open shared PostgreSQL connection pool if configured\n\tvar pool *db.DB\n\tif conf.DatabaseURL != \"\" {\n\t\tprimary, err := pg.Open(conf.DatabaseURL)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvar replicas []*db.Host\n\t\tfor _, replicaURL := range conf.DatabaseReplicaURLs {\n\t\t\tr, err := pg.OpenReplica(replicaURL)\n\t\t\tif err != nil {\n\t\t\t\t// Close already-opened replicas before returning\n\t\t\t\tfor _, opened := range replicas {\n\t\t\t\t\topened.DB.Close()\n\t\t\t\t}\n\t\t\t\tprimary.DB.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"failed to open database replica: %w\", err)\n\t\t\t}\n\t\t\treplicas = append(replicas, r)\n\t\t}\n\t\tpool = db.New(primary, replicas)\n\t}\n\tmessageCache, err := createMessageCache(conf, pool)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar wp *webpush.Store\n\tif conf.WebPushPublicKey != \"\" {\n\t\tif pool != nil {\n\t\t\twp, err = webpush.NewPostgresStore(pool)\n\t\t} else {\n\t\t\twp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ttopicIDs, err := messageCache.Topics()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttopics := make(map[string]*topic, len(topicIDs))\n\tfor _, id := range topicIDs {\n\t\ttopics[id] = newTopic(id)\n\t}\n\tmessages, err := messageCache.Stats()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar fileCache *fileCache\n\tif conf.AttachmentCacheDir != \"\" {\n\t\tfileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tvar userManager *user.Manager\n\tif conf.AuthFile != \"\" || pool != nil {\n\t\tauthConfig := &user.Config{\n\t\t\tFilename:            conf.AuthFile,\n\t\t\tDatabaseURL:         conf.DatabaseURL,\n\t\t\tStartupQueries:      conf.AuthStartupQueries,\n\t\t\tDefaultAccess:       conf.AuthDefault,\n\t\t\tProvisionEnabled:    true, // Enable provisioning of users and access\n\t\t\tUsers:               conf.AuthUsers,\n\t\t\tAccess:              conf.AuthAccess,\n\t\t\tTokens:              conf.AuthTokens,\n\t\t\tBcryptCost:          conf.AuthBcryptCost,\n\t\t\tQueueWriterInterval: conf.AuthStatsQueueWriterInterval,\n\t\t}\n\t\tif pool != nil {\n\t\t\tuserManager, err = user.NewPostgresManager(pool, authConfig)\n\t\t} else {\n\t\t\tuserManager, err = user.NewSQLiteManager(conf.AuthFile, conf.AuthStartupQueries, authConfig)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tvar firebaseClient *firebaseClient\n\tif conf.FirebaseKeyFile != \"\" {\n\t\tsender, err := newFirebaseSender(conf.FirebaseKeyFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// This awkward logic is required because Go is weird about nil types and interfaces.\n\t\t// See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example\n\t\tvar auther user.Auther\n\t\tif userManager != nil {\n\t\t\tauther = userManager\n\t\t}\n\t\tfirebaseClient = newFirebaseClient(sender, auther)\n\t}\n\ts := &Server{\n\t\tconfig:          conf,\n\t\tdb:              pool,\n\t\tmessageCache:    messageCache,\n\t\twebPush:         wp,\n\t\tfileCache:       fileCache,\n\t\tfirebaseClient:  firebaseClient,\n\t\tsmtpSender:      mailer,\n\t\ttopics:          topics,\n\t\tuserManager:     userManager,\n\t\tmessages:        messages,\n\t\tmessagesHistory: []int64{messages},\n\t\tvisitors:        make(map[string]*visitor),\n\t\tstripe:          stripe,\n\t}\n\ts.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)\n\treturn s, nil\n}\n\nfunc createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {\n\tif conf.CacheDuration == 0 {\n\t\treturn message.NewNopStore()\n\t} else if pool != nil {\n\t\treturn message.NewPostgresStore(pool, conf.CacheBatchSize, conf.CacheBatchTimeout)\n\t} else if conf.CacheFile != \"\" {\n\t\treturn message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)\n\t}\n\treturn message.NewMemStore()\n}\n\n// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts\n// a manager go routine to print stats and prune messages.\nfunc (s *Server) Run() error {\n\tvar listenStr string\n\tif s.config.ListenHTTP != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[http]\", s.config.ListenHTTP)\n\t}\n\tif s.config.ListenHTTPS != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[https]\", s.config.ListenHTTPS)\n\t}\n\tif s.config.ListenUnix != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[unix]\", s.config.ListenUnix)\n\t}\n\tif s.config.SMTPServerListen != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[smtp]\", s.config.SMTPServerListen)\n\t}\n\tif s.config.MetricsListenHTTP != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[http/metrics]\", s.config.MetricsListenHTTP)\n\t}\n\tif s.config.ProfileListenHTTP != \"\" {\n\t\tlistenStr += fmt.Sprintf(\" %s[http/profile]\", s.config.ProfileListenHTTP)\n\t}\n\tlog.Tag(tagStartup).Info(\"Listening on%s, ntfy %s, log level is %s\", listenStr, s.config.BuildVersion, log.CurrentLevel().String())\n\tif log.IsFile() {\n\t\tfmt.Fprintf(os.Stderr, \"Listening on%s, ntfy %s\\n\", listenStr, s.config.BuildVersion)\n\t\tfmt.Fprintf(os.Stderr, \"Logs are written to %s\\n\", log.File())\n\t}\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/\", s.handle)\n\terrChan := make(chan error)\n\ts.mu.Lock()\n\ts.closeChan = make(chan bool)\n\tif s.config.ListenHTTP != \"\" {\n\t\ts.httpServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}\n\t\tgo func() {\n\t\t\terrChan <- s.httpServer.ListenAndServe()\n\t\t}()\n\t}\n\tif s.config.ListenHTTPS != \"\" {\n\t\ts.httpsServer = &http.Server{Addr: s.config.ListenHTTPS, Handler: mux}\n\t\tgo func() {\n\t\t\terrChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)\n\t\t}()\n\t}\n\tif s.config.ListenUnix != \"\" {\n\t\tgo func() {\n\t\t\tvar err error\n\t\t\ts.mu.Lock()\n\t\t\tos.Remove(s.config.ListenUnix)\n\t\t\ts.unixListener, err = net.Listen(\"unix\", s.config.ListenUnix)\n\t\t\tif err != nil {\n\t\t\t\ts.mu.Unlock()\n\t\t\t\terrChan <- err\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer s.unixListener.Close()\n\t\t\tif s.config.ListenUnixMode > 0 {\n\t\t\t\tif err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil {\n\t\t\t\t\ts.mu.Unlock()\n\t\t\t\t\terrChan <- err\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\ts.mu.Unlock()\n\t\t\thttpServer := &http.Server{Handler: mux}\n\t\t\terrChan <- httpServer.Serve(s.unixListener)\n\t\t}()\n\t}\n\tif s.config.MetricsListenHTTP != \"\" {\n\t\tinitMetrics()\n\t\ts.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}\n\t\tgo func() {\n\t\t\terrChan <- s.httpMetricsServer.ListenAndServe()\n\t\t}()\n\t} else if s.config.EnableMetrics {\n\t\tinitMetrics()\n\t\ts.metricsHandler = promhttp.Handler()\n\t}\n\tif s.config.ProfileListenHTTP != \"\" {\n\t\tprofileMux := http.NewServeMux()\n\t\tprofileMux.HandleFunc(\"/debug/pprof/\", pprof.Index)\n\t\tprofileMux.HandleFunc(\"/debug/pprof/cmdline\", pprof.Cmdline)\n\t\tprofileMux.HandleFunc(\"/debug/pprof/profile\", pprof.Profile)\n\t\tprofileMux.HandleFunc(\"/debug/pprof/symbol\", pprof.Symbol)\n\t\tprofileMux.HandleFunc(\"/debug/pprof/trace\", pprof.Trace)\n\t\ts.httpProfileServer = &http.Server{Addr: s.config.ProfileListenHTTP, Handler: profileMux}\n\t\tgo func() {\n\t\t\terrChan <- s.httpProfileServer.ListenAndServe()\n\t\t}()\n\t}\n\tif s.config.SMTPServerListen != \"\" {\n\t\tgo func() {\n\t\t\terrChan <- s.runSMTPServer()\n\t\t}()\n\t}\n\ts.mu.Unlock()\n\tgo s.runManager()\n\tgo s.runStatsResetter()\n\tgo s.runDelayedSender()\n\tgo s.runFirebaseKeepaliver()\n\n\treturn <-errChan\n}\n\n// Stop stops HTTP (+HTTPS) server and all managers\nfunc (s *Server) Stop() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif s.httpServer != nil {\n\t\ts.httpServer.Close()\n\t}\n\tif s.httpsServer != nil {\n\t\ts.httpsServer.Close()\n\t}\n\tif s.unixListener != nil {\n\t\ts.unixListener.Close()\n\t}\n\tif s.smtpServer != nil {\n\t\ts.smtpServer.Close()\n\t}\n\ts.closeDatabases()\n\tclose(s.closeChan)\n}\n\nfunc (s *Server) closeDatabases() {\n\tif s.userManager != nil {\n\t\ts.userManager.Close()\n\t}\n\ts.messageCache.Close()\n\tif s.webPush != nil {\n\t\ts.webPush.Close()\n\t}\n\tif s.db != nil {\n\t\ts.db.Close()\n\t}\n}\n\n// handle is the main entry point for all HTTP requests\nfunc (s *Server) handle(w http.ResponseWriter, r *http.Request) {\n\tv, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned\n\tif err != nil {\n\t\ts.handleError(w, r, v, err)\n\t\treturn\n\t}\n\tev := logvr(v, r)\n\tif ev.IsTrace() {\n\t\tev.Field(\"http_request\", renderHTTPRequest(r)).Trace(\"HTTP request started\")\n\t} else if logvr(v, r).IsDebug() {\n\t\tev.Debug(\"HTTP request started\")\n\t}\n\tlogvr(v, r).\n\t\tTiming(func() {\n\t\t\tif err := s.handleInternal(w, r, v); err != nil {\n\t\t\t\ts.handleError(w, r, v, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif metricHTTPRequests != nil {\n\t\t\t\tmetricHTTPRequests.WithLabelValues(\"200\", \"20000\", r.Method).Inc()\n\t\t\t}\n\t\t}).\n\t\tDebug(\"HTTP request finished\")\n}\n\nfunc (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, err error) {\n\thttpErr, ok := err.(*errHTTP)\n\tif !ok {\n\t\thttpErr = errHTTPInternalError\n\t}\n\tif metricHTTPRequests != nil {\n\t\tmetricHTTPRequests.WithLabelValues(fmt.Sprintf(\"%d\", httpErr.HTTPCode), fmt.Sprintf(\"%d\", httpErr.Code), r.Method).Inc()\n\t}\n\tisRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)\n\tisNormalError := strings.Contains(err.Error(), \"i/o timeout\") || util.Contains(normalErrorCodes, httpErr.HTTPCode)\n\tev := logvr(v, r).Err(err)\n\tif websocket.IsWebSocketUpgrade(r) {\n\t\tev.Tag(tagWebsocket).Fields(websocketErrorContext(err))\n\t\tif isNormalError {\n\t\t\tev.Debug(\"WebSocket error (this error is okay, it happens a lot): %s\", err.Error())\n\t\t} else {\n\t\t\tev.Info(\"WebSocket error: %s\", err.Error())\n\t\t}\n\t\t// Write error response only if the connection was not hijacked yet. Bytes written to hijacked\n\t\t// connections are WebSocket frames, not HTTP, and will cause \"http: response.WriteHeader on hijacked\n\t\t// connection\" log spam.\n\t\tvar postUpgradeErr *errWebSocketPostUpgrade\n\t\tif !errors.As(err, &postUpgradeErr) {\n\t\t\tw.WriteHeader(httpErr.HTTPCode)\n\t\t}\n\t\treturn\n\t}\n\tif isNormalError {\n\t\tev.Debug(\"Connection closed with HTTP %d (ntfy error %d)\", httpErr.HTTPCode, httpErr.Code)\n\t} else {\n\t\tev.Info(\"Connection closed with HTTP %d (ntfy error %d)\", httpErr.HTTPCode, httpErr.Code)\n\t}\n\tif isRateLimiting && s.config.StripeSecretKey != \"\" {\n\t\tu := v.User()\n\t\tif u == nil || u.Tier == nil {\n\t\t\thttpErr = httpErr.Wrap(\"increase your limits with a paid plan, see %s\", s.config.BaseURL)\n\t\t}\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tw.WriteHeader(httpErr.HTTPCode)\n\tio.WriteString(w, httpErr.JSON()+\"\\n\")\n}\n\nfunc (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tif r.Method == http.MethodGet && r.URL.Path == \"/\" && s.config.WebRoot == \"/\" {\n\t\treturn s.ensureWebEnabled(s.handleRoot)(w, r, v)\n\t} else if r.Method == http.MethodHead && r.URL.Path == \"/\" {\n\t\treturn s.ensureWebEnabled(s.handleEmpty)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {\n\t\treturn s.handleHealth(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {\n\t\treturn s.ensureAdmin(s.handleVersion)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {\n\t\treturn s.handleConfig(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {\n\t\treturn s.ensureWebEnabled(s.handleWebConfig)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {\n\t\treturn s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {\n\t\treturn s.ensureAdmin(s.handleUsersGet)(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiUsersPath {\n\t\treturn s.ensureAdmin(s.handleUsersAdd)(w, r, v)\n\t} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {\n\t\treturn s.ensureAdmin(s.handleUsersUpdate)(w, r, v)\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath {\n\t\treturn s.ensureAdmin(s.handleUsersDelete)(w, r, v)\n\t} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath {\n\t\treturn s.ensureAdmin(s.handleAccessAllow)(w, r, v)\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersAccessPath {\n\t\treturn s.ensureAdmin(s.handleAccessReset)(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath {\n\t\treturn s.ensureUserManager(s.handleAccountCreate)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath {\n\t\treturn s.handleAccountGet(w, r, v) // Allowed by anonymous\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {\n\t\treturn s.ensureUser(s.handleAccountPasswordChange)(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)\n\t} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountTokenUpdate))(w, r, v)\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountTokenPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountTokenDelete))(w, r, v)\n\t} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionAdd))(w, r, v)\n\t} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSubscriptionPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionChange))(w, r, v)\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountSubscriptionPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionDelete))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountReservationPath {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountReservationAdd))(w, r, v)\n\t} else if r.Method == http.MethodDelete && apiAccountReservationSingleRegex.MatchString(r.URL.Path) {\n\t\treturn s.ensureUser(s.withAccountSync(s.handleAccountReservationDelete))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingSubscriptionPath {\n\t\treturn s.ensurePaymentsEnabled(s.ensureUser(s.handleAccountBillingSubscriptionCreate))(w, r, v) // Account sync via incoming Stripe webhook\n\t} else if r.Method == http.MethodGet && apiAccountBillingSubscriptionCheckoutSuccessRegex.MatchString(r.URL.Path) {\n\t\treturn s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingSubscriptionCreateSuccess))(w, r, v) // No user context!\n\t} else if r.Method == http.MethodPut && r.URL.Path == apiAccountBillingSubscriptionPath {\n\t\treturn s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionUpdate))(w, r, v) // Account sync via incoming Stripe webhook\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountBillingSubscriptionPath {\n\t\treturn s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionDelete))(w, r, v) // Account sync via incoming Stripe webhook\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingPortalPath {\n\t\treturn s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {\n\t\treturn s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!\n\t} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath {\n\t\treturn s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v)\n\t} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {\n\t\treturn s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)\n\t} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {\n\t\treturn s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)\n\t} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {\n\t\treturn s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)\n\t} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {\n\t\treturn s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushDelete))(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {\n\t\treturn s.handleStats(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {\n\t\treturn s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)\n\t} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {\n\t\treturn s.handleMatrixDiscovery(w)\n\t} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {\n\t\treturn s.handleMetrics(w, r, v)\n\t} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {\n\t\treturn s.ensureWebEnabled(s.handleStatic)(w, r, v)\n\t} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {\n\t\treturn s.ensureWebEnabled(s.handleDocs)(w, r, v)\n\t} else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != \"\" {\n\t\treturn s.limitRequests(s.handleFile)(w, r, v)\n\t} else if r.Method == http.MethodOptions {\n\t\treturn s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598\n\t} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == \"/\" {\n\t\treturn s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)\n\t} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {\n\t\treturn s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)\n\t} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {\n\t\treturn s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)\n\t} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)\n\t} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)\n\t} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)\n\t} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v)\n\t} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequests(s.authorizeTopicRead(s.handleSubscribeSSE))(w, r, v)\n\t} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequests(s.authorizeTopicRead(s.handleSubscribeRaw))(w, r, v)\n\t} else if r.Method == http.MethodGet && wsPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)\n\t} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {\n\t\treturn s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)\n\t} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {\n\t\treturn s.ensureWebEnabled(s.handleTopic)(w, r, v)\n\t}\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tr.URL.Path = webAppIndex\n\treturn s.handleStatic(w, r, v)\n}\n\nfunc (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tunifiedpush := readBoolParam(r, false, \"x-unifiedpush\", \"unifiedpush\", \"up\") // see PUT/POST too!\n\tif unifiedpush {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\t\t_, err := io.WriteString(w, `{\"unifiedpush\":{\"version\":1}}`+\"\\n\")\n\t\treturn err\n\t}\n\tr.URL.Path = webAppIndex\n\treturn s.handleStatic(w, r, v)\n}\n\nfunc (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\treturn nil\n}\n\nfunc (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\tresponse := &apiHealthResponse{\n\t\tHealthy: true,\n\t}\n\treturn s.writeJSON(w, response)\n}\n\nfunc (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\treturn s.writeJSON(w, s.configResponse())\n}\n\nfunc (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\tb, err := json.MarshalIndent(s.configResponse(), \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\tw.Header().Set(\"Content-Type\", \"text/javascript\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t_, err = io.WriteString(w, fmt.Sprintf(\"// Generated server configuration\\nvar config = %s;\\n\", string(b)))\n\treturn err\n}\n\nfunc (s *Server) configResponse() *apiConfigResponse {\n\treturn &apiConfigResponse{\n\t\tBaseURL:            \"\", // Will translate to window.location.origin\n\t\tAppRoot:            s.config.WebRoot,\n\t\tEnableLogin:        s.config.EnableLogin,\n\t\tRequireLogin:       s.config.RequireLogin,\n\t\tEnableSignup:       s.config.EnableSignup,\n\t\tEnablePayments:     s.config.StripeSecretKey != \"\",\n\t\tEnableCalls:        s.config.TwilioAccount != \"\",\n\t\tEnableEmails:       s.config.SMTPSenderFrom != \"\",\n\t\tEnableReservations: s.config.EnableReservations,\n\t\tEnableWebPush:      s.config.WebPushPublicKey != \"\",\n\t\tBillingContact:     s.config.BillingContact,\n\t\tWebPushPublicKey:   s.config.WebPushPublicKey,\n\t\tDisallowedTopics:   s.config.DisallowedTopics,\n\t\tConfigHash:         s.config.Hash(),\n\t}\n}\n\n// handleWebManifest serves the web app manifest for the progressive web app (PWA)\nfunc (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\tresponse := &webManifestResponse{\n\t\tName:            \"ntfy\",\n\t\tDescription:     \"ntfy lets you send push notifications via scripts from any computer or phone\",\n\t\tShortName:       \"ntfy\",\n\t\tScope:           \"/\",\n\t\tStartURL:        s.config.WebRoot,\n\t\tDisplay:         \"standalone\",\n\t\tBackgroundColor: \"#ffffff\",\n\t\tThemeColor:      \"#317f6f\",\n\t\tIcons: []*webManifestIcon{\n\t\t\t{SRC: \"/static/images/pwa-192x192.png\", Sizes: \"192x192\", Type: \"image/png\"},\n\t\t\t{SRC: \"/static/images/pwa-512x512.png\", Sizes: \"512x512\", Type: \"image/png\"},\n\t\t},\n\t}\n\treturn s.writeJSONWithContentType(w, response, \"application/manifest+json\")\n}\n\n// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,\n// and listen-metrics-http is not set.\nfunc (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {\n\ts.metricsHandler.ServeHTTP(w, r)\n\treturn nil\n}\n\n// handleStatic returns all static resources (excluding the docs), including the web app\nfunc (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {\n\tr.URL.Path = webSiteDir + r.URL.Path\n\tutil.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)\n\treturn nil\n}\n\n// handleDocs returns static resources related to the docs\nfunc (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {\n\tutil.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)\n\treturn nil\n}\n\n// handleStats returns the publicly available server stats\nfunc (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\ts.mu.RLock()\n\tmessages, n, rate := s.messages, len(s.messagesHistory), float64(0)\n\tif n > 1 {\n\t\trate = float64(s.messagesHistory[n-1]-s.messagesHistory[0]) / (float64(n-1) * s.config.ManagerInterval.Seconds())\n\t}\n\ts.mu.RUnlock()\n\tresponse := &apiStatsResponse{\n\t\tMessages:     messages,\n\t\tMessagesRate: rate,\n\t}\n\treturn s.writeJSON(w, response)\n}\n\n// handleFile processes the download of attachment files. The method handles GET and HEAD requests against a file.\n// Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it\n// can associate the download bandwidth with the uploader.\nfunc (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tif s.config.AttachmentCacheDir == \"\" {\n\t\treturn errHTTPInternalError\n\t}\n\tmatches := fileRegex.FindStringSubmatch(r.URL.Path)\n\tif len(matches) != 2 {\n\t\treturn errHTTPInternalErrorInvalidPath\n\t}\n\tmessageID := matches[1]\n\tfile := filepath.Join(s.config.AttachmentCacheDir, messageID)\n\tstat, err := os.Stat(file)\n\tif err != nil {\n\t\treturn errHTTPNotFound.Fields(log.Context{\n\t\t\t\"message_id\":    messageID,\n\t\t\t\"error_context\": \"filesystem\",\n\t\t})\n\t}\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", stat.Size()))\n\tif r.Method == http.MethodHead {\n\t\treturn nil\n\t}\n\t// Find message in database, and associate bandwidth to the uploader user\n\t// This is an easy way to\n\t//   - avoid abuse (e.g. 1 uploader, 1k downloaders)\n\t//   - and also uses the higher bandwidth limits of a paying user\n\tm, err := s.messageCache.Message(messageID)\n\tif errors.Is(err, model.ErrMessageNotFound) {\n\t\tif s.config.CacheBatchTimeout > 0 {\n\t\t\t// Strange edge case: If we immediately after upload request the file (the web app does this for images),\n\t\t\t// and messages are persisted asynchronously, retry fetching from the database\n\t\t\tm, err = util.Retry(func() (*model.Message, error) {\n\t\t\t\treturn s.messageCache.Message(messageID)\n\t\t\t}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn errHTTPNotFound.Fields(log.Context{\n\t\t\t\t\"message_id\":    messageID,\n\t\t\t\t\"error_context\": \"message_cache\",\n\t\t\t})\n\t\t}\n\t} else if err != nil {\n\t\treturn err\n\t}\n\tbandwidthVisitor := v\n\tif s.userManager != nil && m.User != \"\" {\n\t\tu, err := s.userManager.UserByID(m.User)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbandwidthVisitor = s.visitor(v.IP(), u)\n\t} else if m.Sender.IsValid() {\n\t\tbandwidthVisitor = s.visitor(m.Sender, nil)\n\t}\n\tif !bandwidthVisitor.BandwidthAllowed(stat.Size()) {\n\t\treturn errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m)\n\t}\n\t// Actually send file\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\tif m.Attachment.Name != \"\" {\n\t\tw.Header().Set(\"Content-Disposition\", \"attachment; filename=\"+strconv.Quote(m.Attachment.Name))\n\t}\n\t_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)\n\treturn err\n}\n\nfunc (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {\n\tif s.config.BaseURL == \"\" {\n\t\treturn errHTTPInternalErrorMissingBaseURL\n\t}\n\treturn writeMatrixDiscoveryResponse(w)\n}\n\nfunc (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {\n\tstart := time.Now()\n\tt, err := fromContext[*topic](r, contextTopic)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvrate, err := fromContext[*visitor](r, contextRateVisitor)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbody, err := util.Peek(r.Body, s.config.MessageSizeLimit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tm := model.NewDefaultMessage(t.ID, \"\")\n\tcache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m)\n\tif e != nil {\n\t\treturn nil, e.With(t)\n\t}\n\tif unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {\n\t\t// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting.\n\t\t// The 5xx response is because some app servers (in particular Mastodon) will remove\n\t\t// the subscription as invalid if any 400-499 code (except 429/408) is returned.\n\t\t// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46\n\t\treturn nil, errHTTPInsufficientStorageUnifiedPush.With(t)\n\t} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {\n\t\treturn nil, errHTTPTooManyRequestsLimitMessages.With(t)\n\t} else if email != \"\" && !vrate.EmailAllowed() {\n\t\treturn nil, errHTTPTooManyRequestsLimitEmails.With(t)\n\t} else if call != \"\" {\n\t\tvar httpErr *errHTTP\n\t\tcall, httpErr = s.convertPhoneNumber(v.User(), call)\n\t\tif httpErr != nil {\n\t\t\treturn nil, httpErr.With(t)\n\t\t} else if !vrate.CallAllowed() {\n\t\t\treturn nil, errHTTPTooManyRequestsLimitCalls.With(t)\n\t\t}\n\t}\n\tif m.PollID != \"\" {\n\t\tm = model.NewPollRequestMessage(t.ID, m.PollID)\n\t}\n\tm.Sender = v.IP()\n\tm.User = v.MaybeUserID()\n\tif cache {\n\t\tm.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()\n\t}\n\tif err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil {\n\t\treturn nil, err\n\t}\n\tif m.Message == \"\" {\n\t\tm.Message = emptyMessageBody\n\t}\n\tm.SanitizeUTF8()\n\tdelayed := m.Time > time.Now().Unix()\n\tev := logvrm(v, r, m).\n\t\tTag(tagPublish).\n\t\tWith(t).\n\t\tFields(log.Context{\n\t\t\t\"message_delayed\":     delayed,\n\t\t\t\"message_firebase\":    firebase,\n\t\t\t\"message_unifiedpush\": unifiedpush,\n\t\t\t\"message_email\":       email,\n\t\t\t\"message_call\":        call,\n\t\t})\n\tif ev.IsTrace() {\n\t\tev.Field(\"message_body\", util.MaybeMarshalJSON(m)).Trace(\"Received message\")\n\t} else if ev.IsDebug() {\n\t\tev.Debug(\"Received message\")\n\t}\n\tif !delayed {\n\t\tif err := t.Publish(v, m); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif s.firebaseClient != nil && firebase {\n\t\t\tgo s.sendToFirebase(v, m)\n\t\t}\n\t\tif s.smtpSender != nil && email != \"\" {\n\t\t\tgo s.sendEmail(v, m, email)\n\t\t}\n\t\tif s.config.TwilioAccount != \"\" && call != \"\" {\n\t\t\tgo s.callPhone(v, r, m, call)\n\t\t}\n\t\tif s.config.UpstreamBaseURL != \"\" && !unifiedpush { // UP messages are not sent to upstream\n\t\t\tgo s.forwardPollRequest(v, m)\n\t\t}\n\t\tif s.config.WebPushPublicKey != \"\" {\n\t\t\tgo s.publishToWebPushEndpoints(v, m)\n\t\t}\n\t} else {\n\t\tlogvrm(v, r, m).Tag(tagPublish).Debug(\"Message delayed, will process later\")\n\t}\n\tif cache {\n\t\t// Delete any existing scheduled message with the same sequence ID\n\t\tdeletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// Delete attachment files for deleted scheduled messages\n\t\tif s.fileCache != nil && len(deletedIDs) > 0 {\n\t\t\tif err := s.fileCache.Remove(deletedIDs...); err != nil {\n\t\t\t\tlogvrm(v, r, m).Tag(tagPublish).Err(err).Warn(\"Error removing attachments for deleted scheduled messages\")\n\t\t\t}\n\t\t}\n\t\tlogvrm(v, r, m).Tag(tagPublish).Debug(\"Adding message to cache\")\n\t\tif err := s.messageCache.AddMessage(m); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tu := v.User()\n\tif s.userManager != nil && u != nil && u.Tier != nil {\n\t\tgo s.userManager.EnqueueUserStats(u.ID, v.Stats())\n\t}\n\ts.mu.Lock()\n\ts.messages++\n\ts.mu.Unlock()\n\tif unifiedpush {\n\t\tminc(metricUnifiedPushPublishedSuccess)\n\t}\n\tmset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds())\n\treturn m, nil\n}\n\nfunc (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tm, err := s.handlePublishInternal(r, v)\n\tif err != nil {\n\t\tminc(metricMessagesPublishedFailure)\n\t\treturn err\n\t}\n\tminc(metricMessagesPublishedSuccess)\n\treturn s.writeJSON(w, m.ForJSON())\n}\n\nfunc (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t_, err := s.handlePublishInternal(r, v)\n\tif err != nil {\n\t\tminc(metricMessagesPublishedFailure)\n\t\tminc(metricMatrixPublishedFailure)\n\t\tif e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {\n\t\t\ttopic, err := fromContext[*topic](r, contextTopic)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tpushKey, err := fromContext[string](r, contextMatrixPushKey)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter {\n\t\t\t\treturn writeMatrixResponse(w, pushKey)\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\tminc(metricMessagesPublishedSuccess)\n\tminc(metricMatrixPublishedSuccess)\n\treturn writeMatrixSuccess(w)\n}\n\nfunc (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn s.handleActionMessage(w, r, v, model.MessageDeleteEvent)\n}\n\nfunc (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn s.handleActionMessage(w, r, v, model.MessageClearEvent)\n}\n\nfunc (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {\n\tt, err := fromContext[*topic](r, contextTopic)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvrate, err := fromContext[*visitor](r, contextRateVisitor)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {\n\t\treturn errHTTPTooManyRequestsLimitMessages.With(t)\n\t}\n\tsequenceID, e := s.sequenceIDFromPath(r.URL.Path)\n\tif e != nil {\n\t\treturn e.With(t)\n\t}\n\t// Create an action message with the given event type\n\tm := model.NewActionMessage(event, t.ID, sequenceID)\n\tm.Sender = v.IP()\n\tm.User = v.MaybeUserID()\n\tm.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()\n\t// Publish to subscribers\n\tif err := t.Publish(v, m); err != nil {\n\t\treturn err\n\t}\n\t// Send to Firebase for Android clients\n\tif s.firebaseClient != nil {\n\t\tgo s.sendToFirebase(v, m)\n\t}\n\t// Send to web push endpoints\n\tif s.config.WebPushPublicKey != \"\" {\n\t\tgo s.publishToWebPushEndpoints(v, m)\n\t}\n\tif event == model.MessageDeleteEvent {\n\t\t// Delete any existing scheduled message with the same sequence ID\n\t\tdeletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Delete attachment files for deleted scheduled messages\n\t\tif s.fileCache != nil && len(deletedIDs) > 0 {\n\t\t\tif err := s.fileCache.Remove(deletedIDs...); err != nil {\n\t\t\t\tlogvrm(v, r, m).Tag(tagPublish).Err(err).Warn(\"Error removing attachments for deleted scheduled messages\")\n\t\t\t}\n\t\t}\n\t}\n\t// Add to message cache\n\tif err := s.messageCache.AddMessage(m); err != nil {\n\t\treturn err\n\t}\n\tlogvrm(v, r, m).Tag(tagPublish).Debug(\"Published %s for sequence ID %s\", event, sequenceID)\n\ts.mu.Lock()\n\ts.messages++\n\ts.mu.Unlock()\n\treturn s.writeJSON(w, m.ForJSON())\n}\n\nfunc (s *Server) sendToFirebase(v *visitor, m *model.Message) {\n\tlogvm(v, m).Tag(tagFirebase).Debug(\"Publishing to Firebase\")\n\tif err := s.firebaseClient.Send(v, m); err != nil {\n\t\tminc(metricFirebasePublishedFailure)\n\t\tif errors.Is(err, errFirebaseTemporarilyBanned) {\n\t\t\tlogvm(v, m).Tag(tagFirebase).Err(err).Debug(\"Unable to publish to Firebase: %v\", err.Error())\n\t\t} else {\n\t\t\tlogvm(v, m).Tag(tagFirebase).Err(err).Warn(\"Unable to publish to Firebase: %v\", err.Error())\n\t\t}\n\t\treturn\n\t}\n\tminc(metricFirebasePublishedSuccess)\n}\n\nfunc (s *Server) sendEmail(v *visitor, m *model.Message, email string) {\n\tlogvm(v, m).Tag(tagEmail).Field(\"email\", email).Debug(\"Sending email to %s\", email)\n\tif err := s.smtpSender.Send(v, m, email); err != nil {\n\t\tlogvm(v, m).Tag(tagEmail).Field(\"email\", email).Err(err).Warn(\"Unable to send email to %s: %v\", email, err.Error())\n\t\tminc(metricEmailsPublishedFailure)\n\t\treturn\n\t}\n\tminc(metricEmailsPublishedSuccess)\n}\n\nfunc (s *Server) forwardPollRequest(v *visitor, m *model.Message) {\n\ttopicURL := fmt.Sprintf(\"%s/%s\", s.config.BaseURL, m.Topic)\n\ttopicHash := fmt.Sprintf(\"%x\", sha256.Sum256([]byte(topicURL)))\n\tforwardURL := fmt.Sprintf(\"%s/%s\", s.config.UpstreamBaseURL, topicHash)\n\tlogvm(v, m).Debug(\"Publishing poll request to %s\", forwardURL)\n\treq, err := http.NewRequest(\"POST\", forwardURL, strings.NewReader(\"\"))\n\tif err != nil {\n\t\tlogvm(v, m).Err(err).Warn(\"Unable to publish poll request\")\n\t\treturn\n\t}\n\treq.Header.Set(\"User-Agent\", \"ntfy/\"+s.config.BuildVersion)\n\treq.Header.Set(\"X-Poll-ID\", m.ID)\n\tif s.config.UpstreamAccessToken != \"\" {\n\t\treq.Header.Set(\"Authorization\", util.BearerAuth(s.config.UpstreamAccessToken))\n\t}\n\tvar httpClient = &http.Client{\n\t\tTimeout: time.Second * 10,\n\t}\n\tresponse, err := httpClient.Do(req)\n\tif err != nil {\n\t\tlogvm(v, m).Err(err).Warn(\"Unable to publish poll request\")\n\t\treturn\n\t} else if response.StatusCode != http.StatusOK {\n\t\tif response.StatusCode == http.StatusTooManyRequests {\n\t\t\tlogvm(v, m).Err(err).Warn(\"Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) \", s.config.UpstreamBaseURL, response.Status)\n\t\t} else {\n\t\t\tlogvm(v, m).Err(err).Warn(\"Unable to publish poll request, the upstream server %s responded with HTTP %s\", s.config.UpstreamBaseURL, response.Status)\n\t\t}\n\t\treturn\n\t}\n}\n\nfunc (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) {\n\tif r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {\n\t\tpathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)\n\t\tif err != nil {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", err\n\t\t}\n\t\tm.SequenceID = pathSequenceID\n\t} else {\n\t\tsequenceID := readParam(r, \"x-sequence-id\", \"sequence-id\", \"sid\")\n\t\tif sequenceID != \"\" {\n\t\t\tif sequenceIDRegex.MatchString(sequenceID) {\n\t\t\t\tm.SequenceID = sequenceID\n\t\t\t} else {\n\t\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestSequenceIDInvalid\n\t\t\t}\n\t\t} else {\n\t\t\tm.SequenceID = m.ID\n\t\t}\n\t}\n\tcache = readBoolParam(r, true, \"x-cache\", \"cache\")\n\tfirebase = readBoolParam(r, true, \"x-firebase\", \"firebase\")\n\tm.Title = readParam(r, \"x-title\", \"title\", \"t\")\n\tm.Click = readParam(r, \"x-click\", \"click\")\n\ticon := readParam(r, \"x-icon\", \"icon\")\n\tfilename := readParam(r, \"x-filename\", \"filename\", \"file\", \"f\")\n\tattach := readParam(r, \"x-attach\", \"attach\", \"a\")\n\tif attach != \"\" || filename != \"\" {\n\t\tm.Attachment = &model.Attachment{}\n\t}\n\tif filename != \"\" {\n\t\tm.Attachment.Name = filename\n\t}\n\tif attach != \"\" {\n\t\tif !urlRegex.MatchString(attach) {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestAttachmentURLInvalid\n\t\t}\n\t\tm.Attachment.URL = attach\n\t\tif m.Attachment.Name == \"\" {\n\t\t\tu, err := url.Parse(m.Attachment.URL)\n\t\t\tif err == nil {\n\t\t\t\tm.Attachment.Name = path.Base(u.Path)\n\t\t\t\tif m.Attachment.Name == \".\" || m.Attachment.Name == \"/\" {\n\t\t\t\t\tm.Attachment.Name = \"\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif m.Attachment.Name == \"\" {\n\t\t\tm.Attachment.Name = \"attachment\"\n\t\t}\n\t}\n\tif icon != \"\" {\n\t\tif !urlRegex.MatchString(icon) {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestIconURLInvalid\n\t\t}\n\t\tm.Icon = icon\n\t}\n\temail = readParam(r, \"x-email\", \"x-e-mail\", \"email\", \"e-mail\", \"mail\", \"e\")\n\tif email != \"\" && !emailAddressRegex.MatchString(email) {\n\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestEmailAddressInvalid\n\t}\n\tif s.smtpSender == nil && email != \"\" {\n\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestEmailDisabled\n\t}\n\tcall = readParam(r, \"x-call\", \"call\")\n\tif call != \"\" && (s.config.TwilioAccount == \"\" || s.userManager == nil) {\n\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestPhoneCallsDisabled\n\t} else if call != \"\" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {\n\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestPhoneNumberInvalid\n\t}\n\ttemplate = templateMode(readParam(r, \"x-template\", \"template\", \"tpl\"))\n\tmessageStr := readParam(r, \"x-message\", \"message\", \"m\")\n\tif !template.InlineMode() {\n\t\t// Convert \"\\n\" to literal newline everything but inline mode\n\t\tmessageStr = strings.ReplaceAll(messageStr, \"\\\\n\", \"\\n\")\n\t}\n\tif messageStr != \"\" {\n\t\tm.Message = messageStr\n\t}\n\tvar e error\n\tpriorityStr = readParam(r, \"x-priority\", \"priority\", \"prio\", \"p\")\n\tif !template.Enabled() {\n\t\tm.Priority, e = util.ParsePriority(priorityStr)\n\t\tif e != nil {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestPriorityInvalid\n\t\t}\n\t\tpriorityStr = \"\" // Clear since it's already parsed\n\t}\n\tm.Tags = readCommaSeparatedParam(r, \"x-tags\", \"tags\", \"tag\", \"ta\")\n\tdelayStr := readParam(r, \"x-delay\", \"delay\", \"x-at\", \"at\", \"x-in\", \"in\")\n\tif delayStr != \"\" {\n\t\tif !cache {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayNoCache\n\t\t}\n\t\tif email != \"\" {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)\n\t\t}\n\t\tif call != \"\" {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)\n\t\t}\n\t\tdelay, err := util.ParseFutureTime(delayStr, time.Now())\n\t\tif err != nil {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayCannotParse\n\t\t} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayTooSmall\n\t\t} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestDelayTooLarge\n\t\t}\n\t\tm.Time = delay.Unix()\n\t}\n\tactionsStr := readParam(r, \"x-actions\", \"actions\", \"action\")\n\tif actionsStr != \"\" {\n\t\tm.Actions, e = parseActions(actionsStr)\n\t\tif e != nil {\n\t\t\treturn false, false, \"\", \"\", \"\", false, \"\", errHTTPBadRequestActionsInvalid.Wrap(\"%s\", e.Error())\n\t\t}\n\t}\n\tcontentType, markdown := readParam(r, \"content-type\", \"content_type\"), readBoolParam(r, false, \"x-markdown\", \"markdown\", \"md\")\n\tif markdown || strings.ToLower(contentType) == \"text/markdown\" {\n\t\tm.ContentType = \"text/markdown\"\n\t}\n\tunifiedpush = readBoolParam(r, false, \"x-unifiedpush\", \"unifiedpush\", \"up\") // see GET too!\n\tcontentEncoding := readParam(r, \"content-encoding\")\n\tif unifiedpush || contentEncoding == \"aes128gcm\" {\n\t\tfirebase = false\n\t\tunifiedpush = true\n\t}\n\tm.PollID = readParam(r, \"x-poll-id\", \"poll-id\")\n\tif m.PollID != \"\" {\n\t\tunifiedpush = false\n\t\tcache = false\n\t\temail = \"\"\n\t}\n\treturn cache, firebase, email, call, template, unifiedpush, priorityStr, nil\n}\n\n// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.\n//\n//  1. curl -X POST -H \"Poll: 1234\" ntfy.sh/...\n//     If a message is flagged as poll request, the body does not matter and is discarded\n//  2. curl -T somebinarydata.bin \"ntfy.sh/mytopic?up=1\"\n//     If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim\n//  3. curl -H \"Attach: http://example.com/file.jpg\" ntfy.sh/mytopic\n//     Body must be a message, because we attached an external URL\n//  4. curl -T short.txt -H \"Filename: short.txt\" ntfy.sh/mytopic\n//     Body must be attachment, because we passed a filename\n//  5. curl -H \"Template: yes\" -T file.txt ntfy.sh/mytopic\n//     If templating is enabled, read up to 32k and treat message body as JSON\n//  6. curl -T file.txt ntfy.sh/mytopic\n//     If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message\n//  7. curl -T file.txt ntfy.sh/mytopic\n//     In all other cases, mostly if file.txt is > message limit, treat it as an attachment\nfunc (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error {\n\tif m.Event == model.PollRequestEvent { // Case 1\n\t\treturn s.handleBodyDiscard(body)\n\t} else if unifiedpush {\n\t\treturn s.handleBodyAsMessageAutoDetect(m, body) // Case 2\n\t} else if m.Attachment != nil && m.Attachment.URL != \"\" {\n\t\treturn s.handleBodyAsTextMessage(m, body) // Case 3\n\t} else if m.Attachment != nil && m.Attachment.Name != \"\" {\n\t\treturn s.handleBodyAsAttachment(r, v, m, body) // Case 4\n\t} else if template.Enabled() {\n\t\treturn s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5\n\t} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {\n\t\treturn s.handleBodyAsTextMessage(m, body) // Case 6\n\t}\n\treturn s.handleBodyAsAttachment(r, v, m, body) // Case 7\n}\n\nfunc (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {\n\t_, err := io.Copy(io.Discard, body)\n\t_ = body.Close()\n\treturn err\n}\n\nfunc (s *Server) handleBodyAsMessageAutoDetect(m *model.Message, body *util.PeekedReadCloser) error {\n\tif utf8.Valid(body.PeekedBytes) {\n\t\tm.Message = string(body.PeekedBytes) // Do not trim\n\t} else {\n\t\tm.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)\n\t\tm.Encoding = encodingBase64\n\t}\n\treturn nil\n}\n\nfunc (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedReadCloser) error {\n\tif !utf8.Valid(body.PeekedBytes) {\n\t\treturn errHTTPBadRequestMessageNotUTF8.With(m)\n\t}\n\tif len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)\n\t\tm.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required\n\t}\n\tif m.Attachment != nil && m.Attachment.Name != \"\" && m.Message == \"\" {\n\t\tm.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)\n\t}\n\treturn nil\n}\n\nfunc (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {\n\tbody, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))\n\tif err != nil {\n\t\treturn err\n\t} else if body.LimitReached {\n\t\treturn errHTTPEntityTooLargeJSONBody\n\t}\n\tpeekedBody := strings.TrimSpace(string(body.PeekedBytes))\n\tif template.FileMode() {\n\t\tif err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tif err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {\n\t\treturn errHTTPBadRequestTemplateMessageTooLarge\n\t}\n\treturn nil\n}\n\n// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.\n// The template file must be in the templates directory, or in the configured template directory.\nfunc (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {\n\tif !templateNameRegex.MatchString(templateName) {\n\t\treturn errHTTPBadRequestTemplateFileNotFound\n\t}\n\ttemplateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first\n\tif s.config.TemplateDir != \"\" {\n\t\tif b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {\n\t\t\ttemplateContent = b\n\t\t}\n\t}\n\tif len(templateContent) == 0 {\n\t\treturn errHTTPBadRequestTemplateFileNotFound\n\t}\n\tvar tpl templateFile\n\tif err := yaml.Unmarshal(templateContent, &tpl); err != nil {\n\t\treturn errHTTPBadRequestTemplateFileInvalid\n\t}\n\tvar err error\n\tif tpl.Message != nil {\n\t\tif m.Message, err = s.renderTemplate(templateName+\" (message)\", *tpl.Message, peekedBody); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif tpl.Title != nil {\n\t\tif m.Title, err = s.renderTemplate(templateName+\" (title)\", *tpl.Title, peekedBody); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif tpl.Priority != nil {\n\t\trenderedPriority, err := s.renderTemplate(templateName+\" (priority)\", *tpl.Priority, peekedBody)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif m.Priority, err = util.ParsePriority(renderedPriority); err != nil {\n\t\t\treturn errHTTPBadRequestPriorityInvalid\n\t\t}\n\t}\n\treturn nil\n}\n\n// renderTemplateFromParams transforms the JSON message body according to the inline template in the\n// message, title, and priority parameters.\nfunc (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {\n\tvar err error\n\tif m.Message, err = s.renderTemplate(\"priority query parameter\", m.Message, peekedBody); err != nil {\n\t\treturn err\n\t}\n\tif m.Title, err = s.renderTemplate(\"title query parameter\", m.Title, peekedBody); err != nil {\n\t\treturn err\n\t}\n\tif priorityStr != \"\" {\n\t\trenderedPriority, err := s.renderTemplate(\"priority query parameter\", priorityStr, peekedBody)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif m.Priority, err = util.ParsePriority(renderedPriority); err != nil {\n\t\t\treturn errHTTPBadRequestPriorityInvalid\n\t\t}\n\t}\n\treturn nil\n}\n\n// renderTemplate renders a template with the given JSON source data.\nfunc (s *Server) renderTemplate(name, tpl, source string) (string, error) {\n\tif templateDisallowedRegex.MatchString(tpl) {\n\t\treturn \"\", errHTTPBadRequestTemplateDisallowedFunctionCalls\n\t}\n\tvar data any\n\tif err := json.Unmarshal([]byte(source), &data); err != nil {\n\t\treturn \"\", errHTTPBadRequestTemplateMessageNotJSON\n\t}\n\tt, err := template.New(\"\").Funcs(sprig.TxtFuncMap()).Parse(tpl)\n\tif err != nil {\n\t\treturn \"\", errHTTPBadRequestTemplateInvalid.Wrap(\"%s\", err.Error())\n\t}\n\tvar buf bytes.Buffer\n\tlimitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))\n\tif err := t.Execute(limitWriter, data); err != nil {\n\t\treturn \"\", errHTTPBadRequestTemplateExecuteFailed.Wrap(\"template %s: %s\", name, err.Error())\n\t}\n\treturn strings.TrimSpace(strings.ReplaceAll(buf.String(), \"\\\\n\", \"\\n\")), nil // replace any remaining \"\\n\" (those outside of template curly braces) with newlines\n}\n\nfunc (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error {\n\tif s.fileCache == nil || s.config.BaseURL == \"\" || s.config.AttachmentCacheDir == \"\" {\n\t\treturn errHTTPBadRequestAttachmentsDisallowed.With(m)\n\t}\n\tvinfo, err := v.Info()\n\tif err != nil {\n\t\treturn err\n\t}\n\tattachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()\n\tif m.Time > attachmentExpiry {\n\t\treturn errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m)\n\t}\n\tcontentLengthStr := r.Header.Get(\"Content-Length\")\n\tif contentLengthStr != \"\" { // Early \"do-not-trust\" check, hard limit see below\n\t\tcontentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)\n\t\tif err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {\n\t\t\treturn errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{\n\t\t\t\t\"message_content_length\":          contentLength,\n\t\t\t\t\"attachment_total_size_remaining\": vinfo.Stats.AttachmentTotalSizeRemaining,\n\t\t\t\t\"attachment_file_size_limit\":      vinfo.Limits.AttachmentFileSizeLimit,\n\t\t\t})\n\t\t}\n\t}\n\tif m.Attachment == nil {\n\t\tm.Attachment = &model.Attachment{}\n\t}\n\tvar ext string\n\tm.Attachment.Expires = attachmentExpiry\n\tm.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)\n\tm.Attachment.URL = fmt.Sprintf(\"%s/file/%s%s\", s.config.BaseURL, m.ID, ext)\n\tif m.Attachment.Name == \"\" {\n\t\tm.Attachment.Name = fmt.Sprintf(\"attachment%s\", ext)\n\t}\n\tif m.Message == \"\" {\n\t\tm.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)\n\t}\n\tlimiters := []util.Limiter{\n\t\tv.BandwidthLimiter(),\n\t\tutil.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),\n\t\tutil.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),\n\t}\n\tm.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)\n\tif errors.Is(err, util.ErrLimitReached) {\n\t\treturn errHTTPEntityTooLargeAttachment.With(m)\n\t} else if err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tencoder := func(msg *model.Message) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\tif err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn buf.String(), nil\n\t}\n\treturn s.handleSubscribeHTTP(w, r, v, \"application/x-ndjson\", encoder)\n}\n\nfunc (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tencoder := func(msg *model.Message) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\tif err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {\n\t\t\treturn fmt.Sprintf(\"event: %s\\ndata: %s\\n\", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!\n\t\t}\n\t\treturn fmt.Sprintf(\"data: %s\\n\", buf.String()), nil\n\t}\n\treturn s.handleSubscribeHTTP(w, r, v, \"text/event-stream\", encoder)\n}\n\nfunc (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tencoder := func(msg *model.Message) (string, error) {\n\t\tif msg.Event == model.MessageEvent { // only handle default events\n\t\t\treturn strings.ReplaceAll(msg.Message, \"\\n\", \" \") + \"\\n\", nil\n\t\t}\n\t\treturn \"\\n\", nil // \"keepalive\" and \"open\" events just send an empty line\n\t}\n\treturn s.handleSubscribeHTTP(w, r, v, \"text/plain\", encoder)\n}\n\nfunc (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {\n\tlogvr(v, r).Tag(tagSubscribe).Debug(\"HTTP stream connection opened\")\n\tdefer logvr(v, r).Tag(tagSubscribe).Debug(\"HTTP stream connection closed\")\n\tif !v.SubscriptionAllowed() {\n\t\treturn errHTTPTooManyRequestsLimitSubscriptions\n\t}\n\tdefer v.RemoveSubscription()\n\ttopics, topicsStr, err := s.topicsFromPath(r.URL.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpoll, since, scheduled, filters, err := parseSubscribeParams(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar wlock sync.Mutex\n\tvar closed bool\n\tdefer func() {\n\t\t// This blocks until any in-flight sub() call finishes writing/flushing the response writer,\n\t\t// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic\n\t\t// from writing to a response writer that has been cleaned up after the handler returns.\n\t\t// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889\n\t\t// and https://github.com/binwiederhier/ntfy/pull/1598.\n\t\twlock.Lock()\n\t\tclosed = true\n\t\twlock.Unlock()\n\t}()\n\tsub := func(v *visitor, msg *model.Message) error {\n\t\tif !filters.Pass(msg) {\n\t\t\treturn nil\n\t\t}\n\t\tm, err := encoder(msg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\twlock.Lock()\n\t\tdefer wlock.Unlock()\n\t\tif closed {\n\t\t\treturn nil\n\t\t}\n\t\tif _, err := w.Write([]byte(m)); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif fl, ok := w.(http.Flusher); ok {\n\t\t\tfl.Flush()\n\t\t}\n\t\treturn nil\n\t}\n\tif err := s.maybeSetRateVisitors(r, v, topics); err != nil {\n\t\treturn err\n\t}\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tw.Header().Set(\"Content-Type\", contentType+\"; charset=utf-8\")                    // Android/Volley client needs charset!\n\tif poll {\n\t\tfor _, t := range topics {\n\t\t\tt.Keepalive()\n\t\t}\n\t\treturn s.sendOldMessages(topics, since, scheduled, v, sub)\n\t}\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tsubscriberIDs := make([]int, 0)\n\tfor _, t := range topics {\n\t\tsubscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel))\n\t}\n\tdefer func() {\n\t\tfor i, subscriberID := range subscriberIDs {\n\t\t\ttopics[i].Unsubscribe(subscriberID) // Order!\n\t\t}\n\t}()\n\tif err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message\n\t\treturn err\n\t}\n\tif err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {\n\t\treturn err\n\t}\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil\n\t\tcase <-r.Context().Done():\n\t\t\treturn nil\n\t\tcase <-time.After(s.config.KeepaliveInterval):\n\t\t\tev := logvr(v, r).Tag(tagSubscribe)\n\t\t\tif len(topics) == 1 {\n\t\t\t\tev.With(topics[0]).Trace(\"Sending keepalive message to %s\", topics[0].ID)\n\t\t\t} else {\n\t\t\t\tev.Trace(\"Sending keepalive message to %d topics\", len(topics))\n\t\t\t}\n\t\t\tv.Keepalive()\n\t\t\tfor _, t := range topics {\n\t\t\t\tt.Keepalive()\n\t\t\t}\n\t\t\tif err := sub(v, model.NewKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tif strings.ToLower(r.Header.Get(\"Upgrade\")) != \"websocket\" {\n\t\treturn errHTTPBadRequestWebSocketsUpgradeHeaderMissing\n\t}\n\tif !v.SubscriptionAllowed() {\n\t\treturn errHTTPTooManyRequestsLimitSubscriptions\n\t}\n\tdefer v.RemoveSubscription()\n\tlogvr(v, r).Tag(tagWebsocket).Debug(\"WebSocket connection opened\")\n\tdefer logvr(v, r).Tag(tagWebsocket).Debug(\"WebSocket connection closed\")\n\ttopics, topicsStr, err := s.topicsFromPath(r.URL.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpoll, since, scheduled, filters, err := parseSubscribeParams(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\tupgrader := &websocket.Upgrader{\n\t\tReadBufferSize:  wsBufferSize,\n\t\tWriteBufferSize: wsBufferSize,\n\t\tCheckOrigin: func(r *http.Request) bool {\n\t\t\treturn true // We're open for business!\n\t\t},\n\t}\n\tconn, err := upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer conn.Close()\n\n\t// Subscription connections can be canceled externally, see topic.CancelSubscribersExceptUser\n\tcancelCtx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Use errgroup to run WebSocket reader and writer in Go routines\n\tvar wlock sync.Mutex\n\tg, gctx := errgroup.WithContext(cancelCtx)\n\tg.Go(func() error {\n\t\tpongWait := s.config.KeepaliveInterval + wsPongWait\n\t\tconn.SetReadLimit(wsReadLimit)\n\t\tif err := conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tconn.SetPongHandler(func(appData string) error {\n\t\t\tlogvr(v, r).Tag(tagWebsocket).Trace(\"Received WebSocket pong\")\n\t\t\treturn conn.SetReadDeadline(time.Now().Add(pongWait))\n\t\t})\n\t\tfor {\n\t\t\t_, _, err := conn.NextReader()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-gctx.Done():\n\t\t\t\treturn nil\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t})\n\tg.Go(func() error {\n\t\tping := func() error {\n\t\t\twlock.Lock()\n\t\t\tdefer wlock.Unlock()\n\t\t\tif err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlogvr(v, r).Tag(tagWebsocket).Trace(\"Sending WebSocket ping\")\n\t\t\treturn conn.WriteMessage(websocket.PingMessage, nil)\n\t\t}\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-gctx.Done():\n\t\t\t\treturn nil\n\t\t\tcase <-cancelCtx.Done():\n\t\t\t\tlogvr(v, r).Tag(tagWebsocket).Trace(\"Cancel received, closing subscriber connection\")\n\t\t\t\tconn.Close()\n\t\t\t\treturn &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: \"subscription was canceled\"}\n\t\t\tcase <-time.After(s.config.KeepaliveInterval):\n\t\t\t\tv.Keepalive()\n\t\t\t\tfor _, t := range topics {\n\t\t\t\t\tt.Keepalive()\n\t\t\t\t}\n\t\t\t\tif err := ping(); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\tsub := func(v *visitor, msg *model.Message) error {\n\t\tif !filters.Pass(msg) {\n\t\t\treturn nil\n\t\t}\n\t\twlock.Lock()\n\t\tdefer wlock.Unlock()\n\t\tif err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn conn.WriteJSON(msg)\n\t}\n\tif err := s.maybeSetRateVisitors(r, v, topics); err != nil {\n\t\treturn err\n\t}\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tif poll {\n\t\tfor _, t := range topics {\n\t\t\tt.Keepalive()\n\t\t}\n\t\treturn s.sendOldMessages(topics, since, scheduled, v, sub)\n\t}\n\tsubscriberIDs := make([]int, 0)\n\tfor _, t := range topics {\n\t\tsubscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel))\n\t}\n\tdefer func() {\n\t\tfor i, subscriberID := range subscriberIDs {\n\t\t\ttopics[i].Unsubscribe(subscriberID) // Order!\n\t\t}\n\t}()\n\tif err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message\n\t\treturn err\n\t}\n\tif err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {\n\t\treturn err\n\t}\n\terr = g.Wait()\n\tif err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) {\n\t\tlogvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace(\"WebSocket connection closed\")\n\t\treturn nil // Normal closures are not errors; note: \"1006 (abnormal closure)\" is treated as normal, because people disconnect a lot\n\t}\n\tif err != nil {\n\t\treturn &errWebSocketPostUpgrade{err}\n\t}\n\treturn nil\n}\n\nfunc parseSubscribeParams(r *http.Request) (poll bool, since model.SinceMarker, scheduled bool, filters *queryFilter, err error) {\n\tpoll = readBoolParam(r, false, \"x-poll\", \"poll\", \"po\")\n\tscheduled = readBoolParam(r, false, \"x-scheduled\", \"scheduled\", \"sched\")\n\tsince, err = parseSince(r, poll)\n\tif err != nil {\n\t\treturn\n\t}\n\tfilters, err = parseQueryFilters(r)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn\n}\n\n// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published\n// to that topic will be rate limited against the rate visitor instead of the publishing visitor.\n//\n// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND\n// - auth-file is not set (everything is open by default)\n// - or the topic is reserved, and v.user is the owner\n// - or the topic is not reserved, and v.user has write access\n//\n// This only applies to UnifiedPush topics (\"up...\").\nfunc (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic) error {\n\t// Bail out if not enabled\n\tif !s.config.VisitorSubscriberRateLimiting {\n\t\treturn nil\n\t}\n\n\t// Make a list of topics that we'll actually set the RateVisitor on\n\teligibleRateTopics := make([]*topic, 0)\n\tfor _, t := range topics {\n\t\tif strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength {\n\t\t\teligibleRateTopics = append(eligibleRateTopics, t)\n\t\t}\n\t}\n\tif len(eligibleRateTopics) == 0 {\n\t\treturn nil\n\t}\n\n\t// If access controls are turned off, v has access to everything, and we can set the rate visitor\n\tif s.userManager == nil {\n\t\treturn s.setRateVisitors(r, v, eligibleRateTopics)\n\t}\n\n\t// If access controls are enabled, only set rate visitor if\n\t// - topic is reserved, and v.user is the owner\n\t// - topic is not reserved, and v.user has write access\n\twritableRateTopics := make([]*topic, 0)\n\tfor _, t := range topics {\n\t\tif !util.Contains(eligibleRateTopics, t) {\n\t\t\tcontinue\n\t\t}\n\t\townerUserID, err := s.userManager.ReservationOwner(t.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif ownerUserID == \"\" {\n\t\t\tif err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil {\n\t\t\t\twritableRateTopics = append(writableRateTopics, t)\n\t\t\t}\n\t\t} else if ownerUserID == v.MaybeUserID() {\n\t\t\twritableRateTopics = append(writableRateTopics, t)\n\t\t}\n\t}\n\treturn s.setRateVisitors(r, v, writableRateTopics)\n}\n\nfunc (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topic) error {\n\tfor _, t := range rateTopics {\n\t\tlogvr(v, r).\n\t\t\tTag(tagSubscribe).\n\t\t\tWith(t).\n\t\t\tDebug(\"Setting visitor as rate visitor for topic %s\", t.ID)\n\t\tt.SetRateVisitor(v)\n\t}\n\treturn nil\n}\n\n// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the\n// marker, returning only messages that are newer than the marker.\nfunc (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {\n\tif since.IsNone() {\n\t\treturn nil\n\t}\n\tmessages := make([]*model.Message, 0)\n\tfor _, t := range topics {\n\t\ttopicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tmessages = append(messages, topicMessages...)\n\t}\n\tsort.Slice(messages, func(i, j int) bool {\n\t\treturn messages[i].Time < messages[j].Time\n\t})\n\tfor _, m := range messages {\n\t\tif err := sub(v, m); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// parseSince returns a timestamp identifying the time span from which cached messages should be received.\n//\n// Values in the \"since=...\" parameter can be either a unix timestamp or a duration (e.g. 12h),\n// \"all\" for all messages, or \"latest\" for the most recent message for a topic\nfunc parseSince(r *http.Request, poll bool) (model.SinceMarker, error) {\n\tsince := readParam(r, \"x-since\", \"since\", \"si\")\n\n\t// Easy cases (empty, all, none)\n\tif since == \"\" {\n\t\tif poll {\n\t\t\treturn model.SinceAllMessages, nil\n\t\t}\n\t\treturn model.SinceNoMessages, nil\n\t} else if since == \"all\" {\n\t\treturn model.SinceAllMessages, nil\n\t} else if since == \"latest\" {\n\t\treturn model.SinceLatestMessage, nil\n\t} else if since == \"none\" {\n\t\treturn model.SinceNoMessages, nil\n\t}\n\n\t// ID, timestamp, duration\n\tif model.ValidMessageID(since) {\n\t\treturn model.NewSinceID(since), nil\n\t} else if s, err := strconv.ParseInt(since, 10, 64); err == nil {\n\t\treturn model.NewSinceTime(s), nil\n\t} else if d, err := time.ParseDuration(since); err == nil {\n\t\treturn model.NewSinceTime(time.Now().Add(-1 * d).Unix()), nil\n\t}\n\treturn model.SinceNoMessages, errHTTPBadRequestSinceInvalid\n}\n\nfunc (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\tw.Header().Set(\"Access-Control-Allow-Methods\", \"GET, PUT, POST, PATCH, DELETE\")\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tw.Header().Set(\"Access-Control-Allow-Headers\", \"*\")                              // CORS, allow auth via JS // FIXME is this terrible?\n\treturn nil\n}\n\n// topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist.\nfunc (s *Server) topicFromPath(path string) (*topic, error) {\n\tparts := strings.Split(path, \"/\")\n\tif len(parts) < 2 {\n\t\treturn nil, errHTTPBadRequestTopicInvalid\n\t}\n\treturn s.topicFromID(parts[1])\n}\n\n// topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist.\nfunc (s *Server) topicsFromPath(path string) ([]*topic, string, error) {\n\tparts := strings.Split(path, \"/\")\n\tif len(parts) < 2 {\n\t\treturn nil, \"\", errHTTPBadRequestTopicInvalid\n\t}\n\ttopicIDs := util.SplitNoEmpty(parts[1], \",\")\n\ttopics, err := s.topicsFromIDs(topicIDs...)\n\tif err != nil {\n\t\treturn nil, \"\", errHTTPBadRequestTopicInvalid\n\t}\n\treturn topics, parts[1], nil\n}\n\n// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere\nfunc (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {\n\tparts := strings.Split(path, \"/\")\n\tif len(parts) < 3 {\n\t\treturn \"\", errHTTPBadRequestSequenceIDInvalid\n\t}\n\treturn parts[2], nil\n}\n\n// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.\nfunc (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ttopics := make([]*topic, 0)\n\tfor _, id := range ids {\n\t\tif util.Contains(s.config.DisallowedTopics, id) {\n\t\t\treturn nil, errHTTPBadRequestTopicDisallowed\n\t\t}\n\t\tif _, ok := s.topics[id]; !ok {\n\t\t\tif len(s.topics) >= s.config.TotalTopicLimit {\n\t\t\t\treturn nil, errHTTPTooManyRequestsLimitTotalTopics\n\t\t\t}\n\t\t\ts.topics[id] = newTopic(id)\n\t\t}\n\t\ttopics = append(topics, s.topics[id])\n\t}\n\treturn topics, nil\n}\n\n// topicFromID returns the topic with the given ID, creating it if it doesn't exist.\nfunc (s *Server) topicFromID(id string) (*topic, error) {\n\ttopics, err := s.topicsFromIDs(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn topics[0], nil\n}\n\n// topicsFromPattern returns a list of topics matching the given pattern, but it does not create them.\nfunc (s *Server) topicsFromPattern(pattern string) ([]*topic, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\tpatternRegexp, err := regexp.Compile(\"^\" + strings.ReplaceAll(pattern, \"*\", \".*\") + \"$\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttopics := make([]*topic, 0)\n\tfor _, t := range s.topics {\n\t\tif patternRegexp.MatchString(t.ID) {\n\t\t\ttopics = append(topics, t)\n\t\t}\n\t}\n\treturn topics, nil\n}\n\nfunc (s *Server) runSMTPServer() error {\n\ts.smtpServerBackend = newMailBackend(s.config, s.handle)\n\ts.smtpServer = smtp.NewServer(s.smtpServerBackend)\n\ts.smtpServer.Addr = s.config.SMTPServerListen\n\ts.smtpServer.Domain = s.config.SMTPServerDomain\n\ts.smtpServer.ReadTimeout = 10 * time.Second\n\ts.smtpServer.WriteTimeout = 10 * time.Second\n\ts.smtpServer.MaxMessageBytes = 1024 * 1024 // Must be much larger than message size (headers, multipart, etc.)\n\ts.smtpServer.MaxRecipients = 1\n\ts.smtpServer.AllowInsecureAuth = true\n\treturn s.smtpServer.ListenAndServe()\n}\n\nfunc (s *Server) runManager() {\n\tfor {\n\t\tselect {\n\t\tcase <-time.After(s.config.ManagerInterval):\n\t\t\tlog.\n\t\t\t\tTag(tagManager).\n\t\t\t\tTiming(s.execManager).\n\t\t\t\tDebug(\"Manager finished\")\n\t\tcase <-s.closeChan:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// runStatsResetter runs once a day (usually midnight UTC) to reset all the visitor's message and\n// email counters. The stats are used to display the counters in the web app, as well as for rate limiting.\nfunc (s *Server) runStatsResetter() {\n\tfor {\n\t\trunAt := util.NextOccurrenceUTC(s.config.VisitorStatsResetTime, time.Now())\n\t\ttimer := time.NewTimer(time.Until(runAt))\n\t\tlog.Tag(tagResetter).Debug(\"Waiting until %v to reset visitor stats\", runAt)\n\t\tselect {\n\t\tcase <-timer.C:\n\t\t\tlog.Tag(tagResetter).Debug(\"Running stats resetter\")\n\t\t\ts.resetStats()\n\t\tcase <-s.closeChan:\n\t\t\tlog.Tag(tagResetter).Debug(\"Stopping stats resetter\")\n\t\t\ttimer.Stop()\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) resetStats() {\n\tlog.Info(\"Resetting all visitor stats (daily task)\")\n\ts.mu.Lock()\n\tdefer s.mu.Unlock() // Includes the database query to avoid races with other processes\n\tfor _, v := range s.visitors {\n\t\tv.ResetStats()\n\t}\n\tif s.userManager != nil {\n\t\tif err := s.userManager.ResetStats(); err != nil {\n\t\t\tlog.Tag(tagResetter).Warn(\"Failed to write to database: %s\", err.Error())\n\t\t}\n\t}\n}\n\nfunc (s *Server) runFirebaseKeepaliver() {\n\tif s.firebaseClient == nil {\n\t\treturn\n\t}\n\tv := newVisitor(s.config, s.messageCache, s.userManager, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0\n\tfor {\n\t\tselect {\n\t\tcase <-time.After(s.config.FirebaseKeepaliveInterval):\n\t\t\ts.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))\n\t\t/*\n\t\t\tFIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)\n\t\t\t       To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.\n\t\t\t       Given that it's not really necessary to poll, turning it off for now should not have any impact.\n\n\t\t\tcase <-time.After(s.config.FirebasePollInterval):\n\t\t\t\ts.sendToFirebase(v, model.NewKeepaliveMessage(firebasePollTopic))\n\t\t*/\n\t\tcase <-s.closeChan:\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) runDelayedSender() {\n\tfor {\n\t\tselect {\n\t\tcase <-time.After(s.config.DelayedSenderInterval):\n\t\t\tif err := s.sendDelayedMessages(); err != nil {\n\t\t\t\tlog.Tag(tagPublish).Err(err).Warn(\"Error sending delayed messages\")\n\t\t\t}\n\t\tcase <-s.closeChan:\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) sendDelayedMessages() error {\n\tmessages, err := s.messageCache.MessagesDue()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, m := range messages {\n\t\tvar u *user.User\n\t\tif s.userManager != nil && m.User != \"\" {\n\t\t\tu, err = s.userManager.UserByID(m.User)\n\t\t\tif err != nil {\n\t\t\t\tlog.With(m).Err(err).Warn(\"Error sending delayed message\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tv := s.visitor(m.Sender, u)\n\t\tif err := s.sendDelayedMessage(v, m); err != nil {\n\t\t\tlogvm(v, m).Err(err).Warn(\"Error sending delayed message\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {\n\tlogvm(v, m).Debug(\"Sending delayed message\")\n\ts.mu.RLock()\n\tt, ok := s.topics[m.Topic] // If no subscribers, just mark message as published\n\ts.mu.RUnlock()\n\tif ok {\n\t\tgo func() {\n\t\t\t// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler\n\t\t\tif err := t.Publish(v, m); err != nil {\n\t\t\t\tlogvm(v, m).Err(err).Warn(\"Unable to publish message\")\n\t\t\t}\n\t\t}()\n\t}\n\tif s.firebaseClient != nil { // Firebase subscribers may not show up in topics map\n\t\tgo s.sendToFirebase(v, m)\n\t}\n\tif s.config.UpstreamBaseURL != \"\" {\n\t\tgo s.forwardPollRequest(v, m)\n\t}\n\tif s.config.WebPushPublicKey != \"\" {\n\t\tgo s.publishToWebPushEndpoints(v, m)\n\t}\n\tif err := s.messageCache.MarkPublished(m); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers\n// before passing it on to the next handler. This is meant to be used in combination with handlePublish.\nfunc (s *Server) transformBodyJSON(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tm, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !topicRegex.MatchString(m.Topic) {\n\t\t\treturn errHTTPBadRequestTopicInvalid\n\t\t}\n\t\tif m.Message == \"\" {\n\t\t\tm.Message = emptyMessageBody\n\t\t}\n\t\tr.URL.Path = \"/\" + m.Topic\n\t\tr.Body = io.NopCloser(strings.NewReader(m.Message))\n\t\tif m.Title != \"\" {\n\t\t\tr.Header.Set(\"X-Title\", m.Title)\n\t\t}\n\t\tif m.Priority != 0 {\n\t\t\tr.Header.Set(\"X-Priority\", fmt.Sprintf(\"%d\", m.Priority))\n\t\t}\n\t\tif len(m.Tags) > 0 {\n\t\t\tr.Header.Set(\"X-Tags\", strings.Join(m.Tags, \",\"))\n\t\t}\n\t\tif m.Attach != \"\" {\n\t\t\tr.Header.Set(\"X-Attach\", m.Attach)\n\t\t}\n\t\tif m.Filename != \"\" {\n\t\t\tr.Header.Set(\"X-Filename\", m.Filename)\n\t\t}\n\t\tif m.Click != \"\" {\n\t\t\tr.Header.Set(\"X-Click\", m.Click)\n\t\t}\n\t\tif m.Icon != \"\" {\n\t\t\tr.Header.Set(\"X-Icon\", m.Icon)\n\t\t}\n\t\tif m.Markdown {\n\t\t\tr.Header.Set(\"X-Markdown\", \"yes\")\n\t\t}\n\t\tif len(m.Actions) > 0 {\n\t\t\tactionsStr, err := json.Marshal(m.Actions)\n\t\t\tif err != nil {\n\t\t\t\treturn errHTTPBadRequestMessageJSONInvalid\n\t\t\t}\n\t\t\tr.Header.Set(\"X-Actions\", string(actionsStr))\n\t\t}\n\t\tif m.Email != \"\" {\n\t\t\tr.Header.Set(\"X-Email\", m.Email)\n\t\t}\n\t\tif m.Delay != \"\" {\n\t\t\tr.Header.Set(\"X-Delay\", m.Delay)\n\t\t}\n\t\tif m.Call != \"\" {\n\t\t\tr.Header.Set(\"X-Call\", m.Call)\n\t\t}\n\t\tif m.Cache != \"\" {\n\t\t\tr.Header.Set(\"X-Cache\", m.Cache)\n\t\t}\n\t\tif m.Firebase != \"\" {\n\t\t\tr.Header.Set(\"X-Firebase\", m.Firebase)\n\t\t}\n\t\tif m.SequenceID != \"\" {\n\t\t\tr.Header.Set(\"X-Sequence-ID\", m.SequenceID)\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) transformMatrixJSON(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tnewRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)\n\t\tif err != nil {\n\t\t\tlogvr(v, r).Tag(tagMatrix).Err(err).Debug(\"Invalid Matrix request\")\n\t\t\tif e, ok := err.(*errMatrixPushkeyRejected); ok {\n\t\t\t\treturn writeMatrixResponse(w, e.rejectedPushKey)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\tif err := next(w, newRequest, v); err != nil {\n\t\t\tlogvr(v, r).Tag(tagMatrix).Err(err).Debug(\"Error handling Matrix request\")\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}\n}\n\nfunc (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {\n\treturn s.authorizeTopic(next, user.PermissionWrite)\n}\n\nfunc (s *Server) authorizeTopicRead(next handleFunc) handleFunc {\n\treturn s.authorizeTopic(next, user.PermissionRead)\n}\n\nfunc (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.userManager == nil {\n\t\t\treturn next(w, r, v)\n\t\t}\n\t\ttopics, _, err := s.topicsFromPath(r.URL.Path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu := v.User()\n\t\tfor _, t := range topics {\n\t\t\tif err := s.userManager.Authorize(u, t.ID, perm); err != nil {\n\t\t\t\tlogvr(v, r).With(t).Err(err).Debug(\"Access to topic %s not authorized\", t.ID)\n\t\t\t\treturn errHTTPForbidden.With(t)\n\t\t\t}\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\n// maybeAuthenticate reads the \"Authorization\" header and will try to authenticate the user\n// if it is set.\n//\n//   - If auth-file is not configured, immediately return an IP-based visitor\n//   - If the header is not set or not supported (anything non-Basic and non-Bearer),\n//     an IP-based visitor is returned\n//   - If the header is set, authenticate will be called to check the username/password (Basic auth),\n//     or the token (Bearer auth), and read the user from the database\n//\n// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so\n// that subsequent logging calls still have a visitor context.\nfunc (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {\n\t// Read the \"Authorization\" header value and exit out early if it's not set\n\tip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)\n\tvip := s.visitor(ip, nil)\n\tif s.userManager == nil {\n\t\treturn vip, nil\n\t}\n\theader, err := readAuthHeader(r)\n\tif err != nil {\n\t\treturn vip, err\n\t} else if !supportedAuthHeader(header) {\n\t\treturn vip, nil\n\t}\n\t// If we're trying to auth, check the rate limiter first\n\tif !vip.AuthAllowed() {\n\t\treturn vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!\n\t}\n\tu, err := s.authenticate(r, header)\n\tif err != nil {\n\t\tvip.AuthFailed()\n\t\tlogr(r).Err(err).Debug(\"Authentication failed\")\n\t\treturn vip, errHTTPUnauthorized // Always return visitor, even when error occurs!\n\t}\n\t// Authentication with user was successful\n\treturn s.visitor(ip, u), nil\n}\n\n// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).\n// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to\n// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth\n// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).\nfunc (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {\n\tif strings.HasPrefix(header, \"Bearer\") {\n\t\treturn s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, \"Bearer\")))\n\t}\n\treturn s.authenticateBasicAuth(r, header)\n}\n\n// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,\n// or from the ?auth... query parameter\nfunc readAuthHeader(r *http.Request) (string, error) {\n\tvalue := strings.TrimSpace(r.Header.Get(\"Authorization\"))\n\tqueryParam := readQueryParam(r, \"authorization\", \"auth\")\n\tif queryParam != \"\" {\n\t\ta, err := base64.RawURLEncoding.DecodeString(queryParam)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tvalue = strings.TrimSpace(string(a))\n\t}\n\treturn value, nil\n}\n\n// supportedAuthHeader returns true only if the Authorization header value starts\n// with \"Basic\" or \"Bearer\". In particular, an empty value is not supported, and neither\n// are things like \"WebPush\", or \"vapid\" (see #629).\nfunc supportedAuthHeader(value string) bool {\n\tvalue = strings.ToLower(value)\n\treturn strings.HasPrefix(value, \"basic \") || strings.HasPrefix(value, \"bearer \")\n}\n\nfunc (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {\n\tr.Header.Set(\"Authorization\", value)\n\tusername, password, ok := r.BasicAuth()\n\tif !ok {\n\t\treturn nil, errors.New(\"invalid basic auth\")\n\t} else if username == \"\" {\n\t\treturn s.authenticateBearerAuth(r, password) // Treat password as token\n\t}\n\treturn s.userManager.Authenticate(username, password)\n}\n\nfunc (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {\n\tu, err := s.userManager.AuthenticateToken(token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)\n\tgo s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{\n\t\tLastAccess: time.Now(),\n\t\tLastOrigin: ip,\n\t})\n\treturn u, nil\n}\n\nfunc (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tid := visitorID(ip, user, s.config)\n\tv, exists := s.visitors[id]\n\tif !exists {\n\t\ts.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)\n\t\treturn s.visitors[id]\n\t}\n\tv.Keepalive()\n\tv.SetUser(user) // Always update with the latest user, may be nil!\n\treturn v\n}\n\nfunc (s *Server) writeJSON(w http.ResponseWriter, v any) error {\n\treturn s.writeJSONWithContentType(w, v, \"application/json\")\n}\n\nfunc (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {\n\tw.Header().Set(\"Content-Type\", contentType)\n\tw.Header().Set(\"Access-Control-Allow-Origin\", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests\n\tif err := json.NewEncoder(w).Encode(v); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *Server) updateAndWriteStats(messagesCount int64) {\n\ts.mu.Lock()\n\ts.messagesHistory = append(s.messagesHistory, messagesCount)\n\tif len(s.messagesHistory) > messagesHistoryMax {\n\t\ts.messagesHistory = s.messagesHistory[1:]\n\t}\n\ts.mu.Unlock()\n\tif err := s.messageCache.UpdateStats(messagesCount); err != nil {\n\t\tlog.Tag(tagManager).Err(err).Warn(\"Cannot write messages stats\")\n\t}\n}\n"
  },
  {
    "path": "server/server.yml",
    "content": "# ntfy server config file\n#\n# Please refer to the documentation at https://ntfy.sh/docs/config/ for details.\n# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.\n\n# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)\n#\n# This setting is required for any of the following features:\n# - attachments (to return a download URL)\n# - e-mail sending (for the topic URL in the email footer)\n# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic)\n# - Matrix Push Gateway (to validate that the pushkey is correct)\n#\n# base-url:\n\n# Listen address for the HTTP & HTTPS web server. If \"listen-https\" is set, you must also\n# set \"key-file\" and \"cert-file\". Format: [<ip>]:<port>, e.g. \"1.2.3.4:8080\".\n#\n# To listen on all interfaces, you may omit the IP address, e.g. \":443\".\n# To disable HTTP, set \"listen-http\" to \"-\".\n#\n# listen-http: \":80\"\n# listen-https:\n\n# Listen on a Unix socket, e.g. /var/lib/ntfy/ntfy.sock\n# This can be useful to avoid port issues on local systems, and to simplify permissions.\n#\n# listen-unix: <socket-path>\n# listen-unix-mode: <linux permissions, e.g. 0700>\n\n# Path to the private key & cert file for the HTTPS web server. Not used if \"listen-https\" is not set.\n#\n# key-file: <filename>\n# cert-file: <filename>\n\n# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.\n# This is optional and only required to save battery when using the Android app.\n#\n# firebase-key-file: <filename>\n\n# If \"database-url\" is set, ntfy will use PostgreSQL for all database-backed stores (message cache,\n# user manager, and web push subscriptions) instead of SQLite. When set, the \"cache-file\",\n# \"auth-file\", and \"web-push-file\" options must not be set.\n#\n# Note: Setting \"database-url\" implicitly enables authentication and access control.\n# The default access is \"read-write\" (see \"auth-default-access\").\n#\n# The URL supports standard PostgreSQL parameters (sslmode, connect_timeout, sslcert, etc.),\n# as well as ntfy-specific connection pool parameters:\n#   pool_max_conns=10          - Maximum number of open connections (default: 10)\n#   pool_max_idle_conns=N      - Maximum number of idle connections\n#   pool_conn_max_lifetime=5m  - Maximum lifetime of a connection (Go duration)\n#   pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration)\n#\n# See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS\n# for the full list of supported PostgreSQL connection parameters.\n#\n# Examples:\n#   database-url: \"postgres://user:pass@host:5432/ntfy\"\n#   database-url: \"postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50\"\n#\n# database-url: <connection-string>\n\n# If \"cache-file\" is set, messages are cached in a local SQLite database instead of only in-memory.\n# This allows for service restarts without losing messages in support of the since= parameter.\n# Not required if \"database-url\" is set (messages are stored in PostgreSQL instead).\n#\n# The \"cache-duration\" parameter defines the duration for which messages will be buffered\n# before they are deleted. This is required to support the \"since=...\" and \"poll=1\" parameter.\n# To disable the cache entirely (on-disk/in-memory), set \"cache-duration\" to 0.\n# The cache file is created automatically, provided that the correct permissions are set.\n#\n# The \"cache-startup-queries\" parameter allows you to run commands when the database is initialized,\n# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)).\n# Example:\n#    cache-startup-queries: |\n#       pragma journal_mode = WAL;\n#       pragma synchronous = normal;\n#       pragma temp_store = memory;\n#       pragma busy_timeout = 15000;\n#       vacuum;\n#\n# The \"cache-batch-size\" and \"cache-batch-timeout\" parameter allow enabling async batch writing\n# of messages. If set, messages will be queued and written to the database in batches of the given\n# size, or after the given timeout. This is only required for high volume servers.\n#\n# Debian/RPM package users:\n#   Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package\n#   creates this folder for you.\n#\n# Check your permissions:\n#   If you are running ntfy with systemd, make sure this cache file is owned by the\n#   ntfy user and group by running: chown ntfy.ntfy <filename>.\n#\n# cache-file: <filename>\n# cache-duration: \"12h\"\n# cache-startup-queries:\n# cache-batch-size: 0\n# cache-batch-timeout: \"0ms\"\n\n# If set, access to the ntfy server and API can be controlled on a granular level using\n# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.\n#\n# Note: If \"database-url\" is set, auth is implicitly enabled and \"auth-file\" must not be set.\n#\n# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist\n# - auth-default-access defines the default/fallback access if no access control entry is found; it can be\n#   set to \"read-write\" (default), \"read-only\", \"write-only\" or \"deny-all\".\n# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable\n#   WAL mode. This is similar to cache-startup-queries. See above for details.\n# - auth-users is a list of users that are automatically created when the server starts.\n#   Each entry is in the format \"<username>:<password-hash>:<role>\", e.g. \"phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user\"\n#   Use 'ntfy user hash' to generate the password hash from a password.\n# - auth-access is a list of access control entries that are automatically created when the server starts.\n#   Each entry is in the format \"<username>:<topic-pattern>:<access>\", e.g. \"phil:mytopic:rw\" or \"phil:phil-*:rw\".\n# - auth-tokens is a list of access tokens that are automatically created when the server starts.\n#   Each entry is in the format \"<username>:<token>[:<label>]\", e.g. \"phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token\".\n#   Use 'ntfy token generate' to generate a new access token.\n#\n# Debian/RPM package users:\n#   Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package\n#   creates this folder for you.\n#\n# Check your permissions:\n#   If you are running ntfy with systemd, make sure this user database file is owned by the\n#   ntfy user and group by running: chown ntfy.ntfy <filename>.\n#\n# auth-file: <filename>\n# auth-default-access: \"read-write\"\n# auth-startup-queries:\n# auth-users:\n# auth-access:\n# auth-tokens:\n\n# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine\n# the visitor IP address instead of the remote address of the connection.\n#\n# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited\n#          as if they are one.\n#\n# - behind-proxy makes it so that the real visitor IP address is extracted from the header defined in\n#   proxy-forwarded-header. Without this, the remote address of the incoming connection is used.\n# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),\n#   a comma-separated list of IP addresses (e.g. \"1.2.3.4, 5.6.7.8\"), or an RFC 7239-style header (e.g. \"for=1.2.3.4;by=proxy.example.com, for=5.6.7.8\").\n# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header\n#   to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to\n#   the forwarded header.\n#\n# behind-proxy: false\n# proxy-forwarded-header: \"X-Forwarded-For\"\n# proxy-trusted-hosts:\n\n# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments\n# are \"attachment-cache-dir\" and \"base-url\".\n#\n# - attachment-cache-dir is the cache directory for attached files\n# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)\n# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)\n# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)\n#\n# attachment-cache-dir:\n# attachment-total-size-limit: \"5G\"\n# attachment-file-size-limit: \"15M\"\n# attachment-expiry-duration: \"3h\"\n\n# Template directory for message templates.\n#\n# When \"X-Template: <name>\" (aliases: \"Template: <name>\", \"Tpl: <name>\") or \"?template=<name>\" is set, transform the message\n# based on one of the built-in pre-defined templates, or on a template defined in the \"template-dir\" directory.\n#\n# Template files must have the \".yml\" extension and must be formatted as YAML. They may contain \"title\" and \"message\" keys,\n# which are interpreted as Go templates.\n#\n# Example template file (e.g. /etc/ntfy/templates/grafana.yml):\n#   title: |\n#     {{- if eq .status \"firing\" }}\n#     {{ .title | default \"Alert firing\" }}\n#     {{- else if eq .status \"resolved\" }}\n#     {{ .title | default \"Alert resolved\" }}\n#     {{- end }}\n#   message: |\n#     {{ .message | trunc 2000 }}\n#\n# template-dir: \"/etc/ntfy/templates\"\n\n# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,\n# messages will additionally be sent out as e-mail using an external SMTP server.\n#\n# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.\n# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).\n#\n# - smtp-sender-addr is the hostname:port of the SMTP server\n# - smtp-sender-from is the e-mail address of the sender\n# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)\n#\n# smtp-sender-addr:\n# smtp-sender-from:\n# smtp-sender-user:\n# smtp-sender-pass:\n\n# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send\n# emails to a topic e-mail address to publish messages to a topic.\n#\n# - smtp-server-listen defines the IP address and port the SMTP server will listen on, e.g. :25 or 1.2.3.4:25\n# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh\n# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to \"ntfy-\",\n#   for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to\n#   $topic@ntfy.sh will be accepted (which may be a spam problem).\n#\n# smtp-server-listen:\n# smtp-server-domain:\n# smtp-server-addr-prefix:\n\n# Web Push support (background notifications for browsers)\n#\n# If enabled, allows the ntfy web app to receive push notifications, even when the web app is closed. When enabled, users\n# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push\n# endpoint, which will then forward it to the browser.\n#\n# You must configure web-push-public/private key, web-push-file, and web-push-email-address below to enable Web Push.\n# Run \"ntfy webpush keys\" to generate the keys.\n#\n# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890\n# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890\n# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db\n#   Not required if \"database-url\" is set (subscriptions are stored in PostgreSQL instead).\n# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com\n# - web-push-startup-queries is an optional list of queries to run on startup\n# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d)\n# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)\n#\n# web-push-public-key:\n# web-push-private-key:\n# web-push-file:\n# web-push-email-address:\n# web-push-startup-queries:\n# web-push-expiry-warning-duration: \"55d\"\n# web-push-expiry-duration: \"60d\"\n\n# If enabled, ntfy can perform voice calls via Twilio via the \"X-Call\" header.\n#\n# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586\n# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586\n# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586\n# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586\n# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)\n#\n# twilio-account:\n# twilio-auth-token:\n# twilio-phone-number:\n# twilio-verify-service:\n# twilio-call-format:\n\n# Interval in which keepalive messages are sent to the client. This is to prevent\n# intermediaries closing the connection for inactivity.\n#\n# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.\n#\n# keepalive-interval: \"45s\"\n\n# Interval in which the manager prunes old messages, deletes topics\n# and prints the stats.\n#\n# manager-interval: \"1m\"\n\n# Defines topic names that are not allowed, because they are otherwise used. There are a few default topics\n# that cannot be used (e.g. app, account, settings, ...). To extend the default list, define them here.\n#\n# Example:\n#   disallowed-topics:\n#     - about\n#     - pricing\n#     - contact\n#\n# disallowed-topics:\n\n# Defines the root path of the web app, or disables the web app entirely.\n#\n# Can be any simple path, e.g. \"/\", \"/app\", or \"/ntfy\". For backwards-compatibility reasons,\n# the values \"app\" (maps to \"/\"), \"home\" (maps to \"/app\"), or \"disable\" (maps to \"\") to disable\n# the web app entirely.\n#\n# web-root: /\n\n# Various feature flags used to control the web app, and API access, mainly around user and\n# account management.\n#\n# - enable-signup allows users to sign up via the web app, or API\n# - enable-login allows users to log in via the web app, or API\n# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)\n# - enable-reservations allows users to reserve topics (if their tier allows it)\n#\n# enable-signup: false\n# require-login: false\n# enable-login: false\n# enable-reservations: false\n\n# Server URL of a Firebase/APNS-connected ntfy server (likely \"https://ntfy.sh\").\n#\n# iOS users:\n#   If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:\n#   upstream-base-url: \"https://ntfy.sh\"\n#\n# If set, all incoming messages will publish a \"poll_request\" message to the configured upstream server, containing\n# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.\n# This is to prevent the upstream server and Firebase/APNS from being able to read the message.\n#\n# - upstream-base-url is the base URL of the upstream server. Should be \"https://ntfy.sh\".\n# - upstream-access-token is the token used to authenticate with the upstream server. This is only required\n#   if you exceed the upstream rate limits, or the upstream server requires authentication.\n#\n# upstream-base-url:\n# upstream-access-token:\n\n# Configures message-specific limits\n#\n# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,\n#   and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.\n#   If you increase this size limit regardless, FCM and APNS will NOT work for large messages.\n# - message-delay-limit defines the max delay of a message when using the \"Delay\" header.\n#\n# message-size-limit: \"4k\"\n# message-delay-limit: \"3d\"\n\n# Rate limiting: Total number of topics before the server rejects new topics.\n#\n# global-topic-limit: 15000\n\n# Rate limiting: Number of subscriptions per visitor (IP address)\n#\n# visitor-subscription-limit: 30\n\n# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:\n# - visitor-request-limit-burst is the initial bucket of requests each visitor has\n# - visitor-request-limit-replenish is the rate at which the bucket is refilled\n# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames, IPs or CIDRs to be\n#   exempt from request rate limiting. Hostnames are resolved at the time the server is started.\n#   Example: \"1.2.3.4,ntfy.example.com,8.7.6.0/24\"\n#\n# visitor-request-limit-burst: 60\n# visitor-request-limit-replenish: \"5s\"\n# visitor-request-limit-exempt-hosts: \"\"\n\n# Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset\n# every day at midnight UTC. If the limit is not set (or set to zero), the request\n# limit (see above) governs the upper limit.\n#\n# visitor-message-daily-limit: 0\n\n# Rate limiting: Allowed emails per visitor:\n# - visitor-email-limit-burst is the initial bucket of emails each visitor has\n# - visitor-email-limit-replenish is the rate at which the bucket is refilled\n#\n# visitor-email-limit-burst: 16\n# visitor-email-limit-replenish: \"1h\"\n\n# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting\n# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)\n# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)\n#\n# This is used to group visitors by their IP address or subnet. For example, if you set visitor-prefix-bits-ipv4 to 24,\n# all visitors in the 1.2.3.0/24 network are treated as one.\n#\n# By default, ntfy uses the full IPv4 address (32 bits) and the /64 subnet of the IPv6 address (64 bits).\n#\n# visitor-prefix-bits-ipv4: 32\n# visitor-prefix-bits-ipv6: 64\n\n# Rate limiting: Attachment size and bandwidth limits per visitor:\n# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor\n# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor\n#\n# visitor-attachment-total-size-limit: \"100M\"\n# visitor-attachment-daily-bandwidth-limit: \"500M\"\n\n# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)\n#\n# If subscriber-based rate limiting is enabled, messages published on UnifiedPush topics** (topics starting with \"up\")\n# will be counted towards the \"rate visitor\" of the topic. A \"rate visitor\" is the first subscriber to the topic.\n#\n# Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as\n# a \"rate visitor\", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor\n# requires **read-write permission** on the topic.\n#\n# If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if\n# no \"rate visitor\" has been previously registered. This is to avoid burning the publisher's \"visitor-message-daily-limit\".\n#\n# visitor-subscriber-rate-limiting: false\n\n# Payments integration via Stripe\n#\n# - stripe-secret-key is the key used for the Stripe API communication. Setting this values\n#   enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.\n# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.\n#   Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.\n# - billing-contact is an email address or website displayed in the \"Upgrade tier\" dialog to let people reach\n#   out with billing questions. If unset, nothing will be displayed.\n#\n# stripe-secret-key:\n# stripe-webhook-key:\n# billing-contact:\n\n# Metrics\n#\n# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port.\n# Metrics may be considered sensitive information, so before you enable them, be sure you know what you are\n# doing, and/or secure access to the endpoint in your reverse proxy.\n#\n# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)\n# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly\n#   enables metrics as well, e.g. \"10.0.1.1:9090\" or \":9090\"\n#\n# enable-metrics: false\n# metrics-listen-http:\n\n# Profiling\n#\n# ntfy can expose Go's net/http/pprof endpoints to support profiling of the ntfy server. If enabled, ntfy will listen\n# on a dedicated listen IP/port, which can be accessed via the web browser on http://<ip>:<port>/debug/pprof/.\n# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details.\n#\n# profile-listen-http:\n\n# Logging options\n#\n# By default, ntfy logs to the console (stderr), with an \"info\" log level, and in a human-readable text format.\n# ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular\n# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded\n# by calling \"kill -HUP $pid\" or \"systemctl reload ntfy\".\n#\n# - log-format defines the output format, can be \"text\" (default) or \"json\"\n# - log-file is a filename to write logs to. If this is not set, ntfy logs to stderr.\n# - log-level defines the default log level, can be one of \"trace\", \"debug\", \"info\" (default), \"warn\" or \"error\".\n#   Be aware that \"debug\" (and particularly \"trace\") can be VERY CHATTY. Only turn them on briefly for debugging purposes.\n# - log-level-overrides lets you override the log level if certain fields match. This is incredibly powerful\n#   for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).\n#   This is an array of strings in the format:\n#      - \"field=value -> level\" to match a value exactly, e.g. \"tag=manager -> trace\"\n#      - \"field -> level\" to match any value, e.g. \"time_taken_ms -> debug\"\n#   Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging.\n#\n# Check your permissions:\n#   If you are running ntfy with systemd, make sure this log file is owned by the\n#   ntfy user and group by running: chown ntfy.ntfy <filename>.\n#\n# Example (good for production):\n#   log-level: info\n#   log-format: json\n#   log-file: /var/log/ntfy.log\n#\n# Example level overrides (for debugging, only use temporarily):\n#   log-level-overrides:\n#      - \"tag=manager -> trace\"\n#      - \"visitor_ip=1.2.3.4 -> debug\"\n#      - \"time_taken_ms -> debug\"\n#\n# log-level: info\n# log-level-overrides:\n# log-format: text\n# log-file:\n"
  },
  {
    "path": "server/server_account.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\tsyncTopicAccountSyncEvent = \"sync\"\n\ttokenExpiryDuration       = 72 * time.Hour // Extend tokens by this much\n)\n\nfunc (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\tif !u.IsAdmin() { // u may be nil, but that's fine\n\t\tif !s.config.EnableSignup {\n\t\t\treturn errHTTPBadRequestSignupNotEnabled\n\t\t} else if u != nil {\n\t\t\treturn errHTTPUnauthorized // Cannot create account from user context\n\t\t}\n\t\tif !v.AccountCreationAllowed() {\n\t\t\treturn errHTTPTooManyRequestsLimitAccountCreation\n\t\t}\n\t}\n\tnewAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {\n\t\treturn errHTTPConflictUserExists\n\t}\n\tlogvr(v, r).Tag(tagAccount).Field(\"user_name\", newAccount.Username).Info(\"Creating user %s\", newAccount.Username)\n\tif err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, false); err != nil {\n\t\tif errors.Is(err, user.ErrInvalidArgument) {\n\t\t\treturn errHTTPBadRequestInvalidUsername\n\t\t}\n\t\treturn err\n\t}\n\tv.AccountCreated()\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tinfo, err := v.Info()\n\tif err != nil {\n\t\treturn err\n\t}\n\tlogvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug(\"Retrieving account stats\")\n\tlimits, stats := info.Limits, info.Stats\n\tresponse := &apiAccountResponse{\n\t\tLimits: &apiAccountLimits{\n\t\t\tBasis:                    string(limits.Basis),\n\t\t\tMessages:                 limits.MessageLimit,\n\t\t\tMessagesExpiryDuration:   int64(limits.MessageExpiryDuration.Seconds()),\n\t\t\tEmails:                   limits.EmailLimit,\n\t\t\tCalls:                    limits.CallLimit,\n\t\t\tReservations:             limits.ReservationsLimit,\n\t\t\tAttachmentTotalSize:      limits.AttachmentTotalSizeLimit,\n\t\t\tAttachmentFileSize:       limits.AttachmentFileSizeLimit,\n\t\t\tAttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),\n\t\t\tAttachmentBandwidth:      limits.AttachmentBandwidthLimit,\n\t\t},\n\t\tStats: &apiAccountStats{\n\t\t\tMessages:                     stats.Messages,\n\t\t\tMessagesRemaining:            stats.MessagesRemaining,\n\t\t\tEmails:                       stats.Emails,\n\t\t\tEmailsRemaining:              stats.EmailsRemaining,\n\t\t\tCalls:                        stats.Calls,\n\t\t\tCallsRemaining:               stats.CallsRemaining,\n\t\t\tReservations:                 stats.Reservations,\n\t\t\tReservationsRemaining:        stats.ReservationsRemaining,\n\t\t\tAttachmentTotalSize:          stats.AttachmentTotalSize,\n\t\t\tAttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,\n\t\t},\n\t}\n\tu := v.User()\n\tif u != nil {\n\t\tresponse.Username = u.Name\n\t\tresponse.Role = string(u.Role)\n\t\tresponse.SyncTopic = u.SyncTopic\n\t\tresponse.Provisioned = u.Provisioned\n\t\tif u.Prefs != nil {\n\t\t\tif u.Prefs.Language != nil {\n\t\t\t\tresponse.Language = *u.Prefs.Language\n\t\t\t}\n\t\t\tif u.Prefs.Notification != nil {\n\t\t\t\tresponse.Notification = u.Prefs.Notification\n\t\t\t}\n\t\t\tif u.Prefs.Subscriptions != nil {\n\t\t\t\tresponse.Subscriptions = u.Prefs.Subscriptions\n\t\t\t}\n\t\t}\n\t\tif u.Tier != nil {\n\t\t\tresponse.Tier = &apiAccountTier{\n\t\t\t\tCode: u.Tier.Code,\n\t\t\t\tName: u.Tier.Name,\n\t\t\t}\n\t\t}\n\t\tif u.Billing.StripeCustomerID != \"\" {\n\t\t\tresponse.Billing = &apiAccountBilling{\n\t\t\t\tCustomer:     true,\n\t\t\t\tSubscription: u.Billing.StripeSubscriptionID != \"\",\n\t\t\t\tStatus:       string(u.Billing.StripeSubscriptionStatus),\n\t\t\t\tInterval:     string(u.Billing.StripeSubscriptionInterval),\n\t\t\t\tPaidUntil:    u.Billing.StripeSubscriptionPaidUntil.Unix(),\n\t\t\t\tCancelAt:     u.Billing.StripeSubscriptionCancelAt.Unix(),\n\t\t\t}\n\t\t}\n\t\tif s.config.EnableReservations {\n\t\t\treservations, err := s.userManager.Reservations(u.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(reservations) > 0 {\n\t\t\t\tresponse.Reservations = make([]*apiAccountReservation, 0)\n\t\t\t\tfor _, r := range reservations {\n\t\t\t\t\tresponse.Reservations = append(response.Reservations, &apiAccountReservation{\n\t\t\t\t\t\tTopic:    r.Topic,\n\t\t\t\t\t\tEveryone: r.Everyone.String(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttokens, err := s.userManager.Tokens(u.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(tokens) > 0 {\n\t\t\tresponse.Tokens = make([]*apiAccountTokenResponse, 0)\n\t\t\tfor _, t := range tokens {\n\t\t\t\tvar lastOrigin string\n\t\t\t\tif t.LastOrigin != netip.IPv4Unspecified() {\n\t\t\t\t\tlastOrigin = t.LastOrigin.String()\n\t\t\t\t}\n\t\t\t\tresponse.Tokens = append(response.Tokens, &apiAccountTokenResponse{\n\t\t\t\t\tToken:       t.Value,\n\t\t\t\t\tLabel:       t.Label,\n\t\t\t\t\tLastAccess:  t.LastAccess.Unix(),\n\t\t\t\t\tLastOrigin:  lastOrigin,\n\t\t\t\t\tExpires:     t.Expires.Unix(),\n\t\t\t\t\tProvisioned: t.Provisioned,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\tif s.config.TwilioAccount != \"\" {\n\t\t\tphoneNumbers, err := s.userManager.PhoneNumbers(u.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(phoneNumbers) > 0 {\n\t\t\t\tresponse.PhoneNumbers = phoneNumbers\n\t\t\t}\n\t\t}\n\t} else {\n\t\tresponse.Username = user.Everyone\n\t\tresponse.Role = string(user.RoleAnonymous)\n\t}\n\treturn s.writeJSON(w, response)\n}\n\nfunc (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t} else if req.Password == \"\" {\n\t\treturn errHTTPBadRequest\n\t}\n\tu := v.User()\n\tif _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {\n\t\treturn errHTTPBadRequestIncorrectPasswordConfirmation\n\t}\n\tif err := s.userManager.CanChangeUser(u.Name); err != nil {\n\t\tif errors.Is(err, user.ErrProvisionedUserChange) {\n\t\t\treturn errHTTPConflictProvisionedUserChange\n\t\t}\n\t\treturn err\n\t}\n\tif s.webPush != nil && u.ID != \"\" {\n\t\tif err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {\n\t\t\tlogvr(v, r).Err(err).Warn(\"Error removing web push subscriptions for %s\", u.Name)\n\t\t}\n\t}\n\tif u.Billing.StripeSubscriptionID != \"\" {\n\t\tlogvr(v, r).Tag(tagStripe).Info(\"Canceling billing subscription for user %s\", u.Name)\n\t\tif _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, 0); err != nil {\n\t\treturn err\n\t}\n\tlogvr(v, r).Tag(tagAccount).Info(\"Marking user %s as deleted\", u.Name)\n\tif err := s.userManager.MarkUserRemoved(u); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t} else if req.Password == \"\" || req.NewPassword == \"\" {\n\t\treturn errHTTPBadRequest\n\t}\n\tu := v.User()\n\tif _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {\n\t\treturn errHTTPBadRequestIncorrectPasswordConfirmation\n\t}\n\tlogvr(v, r).Tag(tagAccount).Debug(\"Changing password for user %s\", u.Name)\n\tif err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {\n\t\tif errors.Is(err, user.ErrProvisionedUserChange) {\n\t\t\treturn errHTTPConflictProvisionedUserChange\n\t\t}\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar label string\n\tif req.Label != nil {\n\t\tlabel = *req.Label\n\t}\n\texpires := time.Now().Add(tokenExpiryDuration)\n\tif req.Expires != nil {\n\t\texpires = time.Unix(*req.Expires, 0)\n\t}\n\tu := v.User()\n\tlogvr(v, r).\n\t\tTag(tagAccount).\n\t\tFields(log.Context{\n\t\t\t\"token_label\":   label,\n\t\t\t\"token_expires\": expires,\n\t\t}).\n\t\tDebug(\"Creating token for user %s\", u.Name)\n\ttoken, err := s.userManager.CreateToken(u.ID, label, expires, v.IP(), false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresponse := &apiAccountTokenResponse{\n\t\tToken:      token.Value,\n\t\tLabel:      token.Label,\n\t\tLastAccess: token.LastAccess.Unix(),\n\t\tLastOrigin: token.LastOrigin.String(),\n\t\tExpires:    token.Expires.Unix(),\n\t}\n\treturn s.writeJSON(w, response)\n}\n\nfunc (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\treq, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!\n\tif err != nil {\n\t\treturn err\n\t} else if req.Token == \"\" {\n\t\treq.Token = u.Token\n\t\tif req.Token == \"\" {\n\t\t\treturn errHTTPBadRequestNoTokenProvided\n\t\t}\n\t}\n\tvar expires *time.Time\n\tif req.Expires != nil {\n\t\texpires = util.Time(time.Unix(*req.Expires, 0))\n\t} else if req.Label == nil {\n\t\texpires = util.Time(time.Now().Add(tokenExpiryDuration)) // If label/expires not set, extend token by 72 hours\n\t}\n\tlogvr(v, r).\n\t\tTag(tagAccount).\n\t\tFields(log.Context{\n\t\t\t\"token_label\":   req.Label,\n\t\t\t\"token_expires\": expires,\n\t\t}).\n\t\tDebug(\"Updating token for user %s as deleted\", u.Name)\n\ttoken, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)\n\tif err != nil {\n\t\tif errors.Is(err, user.ErrProvisionedTokenChange) {\n\t\t\treturn errHTTPConflictProvisionedTokenChange\n\t\t}\n\t\treturn err\n\t}\n\tresponse := &apiAccountTokenResponse{\n\t\tToken:      token.Value,\n\t\tLabel:      token.Label,\n\t\tLastAccess: token.LastAccess.Unix(),\n\t\tLastOrigin: token.LastOrigin.String(),\n\t\tExpires:    token.Expires.Unix(),\n\t}\n\treturn s.writeJSON(w, response)\n}\n\nfunc (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\ttoken := readParam(r, \"X-Token\", \"Token\") // DELETEs cannot have a body, and we don't want it in the path\n\tif token == \"\" {\n\t\ttoken = u.Token\n\t\tif token == \"\" {\n\t\t\treturn errHTTPBadRequestNoTokenProvided\n\t\t}\n\t}\n\tif err := s.userManager.RemoveToken(u.ID, token); err != nil {\n\t\tif errors.Is(err, user.ErrProvisionedTokenChange) {\n\t\t\treturn errHTTPConflictProvisionedTokenChange\n\t\t}\n\t\treturn err\n\t}\n\tlogvr(v, r).\n\t\tTag(tagAccount).\n\t\tField(\"token\", token).\n\t\tDebug(\"Deleted token for user %s\", u.Name)\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tnewPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu := v.User()\n\tif u.Prefs == nil {\n\t\tu.Prefs = &user.Prefs{}\n\t}\n\tprefs := u.Prefs\n\tif newPrefs.Language != nil {\n\t\tprefs.Language = newPrefs.Language\n\t}\n\tif newPrefs.Notification != nil {\n\t\tif prefs.Notification == nil {\n\t\t\tprefs.Notification = &user.NotificationPrefs{}\n\t\t}\n\t\tif newPrefs.Notification.DeleteAfter != nil {\n\t\t\tprefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter\n\t\t}\n\t\tif newPrefs.Notification.Sound != nil {\n\t\t\tprefs.Notification.Sound = newPrefs.Notification.Sound\n\t\t}\n\t\tif newPrefs.Notification.MinPriority != nil {\n\t\t\tprefs.Notification.MinPriority = newPrefs.Notification.MinPriority\n\t\t}\n\t}\n\tlogvr(v, r).Tag(tagAccount).Debug(\"Changing account settings for user %s\", u.Name)\n\tif err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tnewSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu := v.User()\n\tprefs := u.Prefs\n\tif prefs == nil {\n\t\tprefs = &user.Prefs{}\n\t}\n\tfor _, subscription := range prefs.Subscriptions {\n\t\tif newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {\n\t\t\treturn errHTTPConflictSubscriptionExists\n\t\t}\n\t}\n\tprefs.Subscriptions = append(prefs.Subscriptions, newSubscription)\n\tlogvr(v, r).Tag(tagAccount).With(newSubscription).Debug(\"Adding subscription for user %s\", u.Name)\n\tif err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSubscription)\n}\n\nfunc (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tupdatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu := v.User()\n\tprefs := u.Prefs\n\tif prefs == nil || prefs.Subscriptions == nil {\n\t\treturn errHTTPNotFound\n\t}\n\tvar subscription *user.Subscription\n\tfor _, sub := range prefs.Subscriptions {\n\t\tif sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic {\n\t\t\tsub.DisplayName = updatedSubscription.DisplayName\n\t\t\tsubscription = sub\n\t\t\tbreak\n\t\t}\n\t}\n\tif subscription == nil {\n\t\treturn errHTTPNotFound\n\t}\n\tlogvr(v, r).Tag(tagAccount).With(subscription).Debug(\"Changing subscription for user %s\", u.Name)\n\tif err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, subscription)\n}\n\nfunc (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t// DELETEs cannot have a body, and we don't want it in the path\n\tdeleteBaseURL := readParam(r, \"X-BaseURL\", \"BaseURL\")\n\tdeleteTopic := readParam(r, \"X-Topic\", \"Topic\")\n\tu := v.User()\n\tprefs := u.Prefs\n\tif prefs == nil || prefs.Subscriptions == nil {\n\t\treturn nil\n\t}\n\tnewSubscriptions := make([]*user.Subscription, 0)\n\tfor _, sub := range u.Prefs.Subscriptions {\n\t\tif sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic {\n\t\t\tlogvr(v, r).Tag(tagAccount).With(sub).Debug(\"Removing subscription for user %s\", u.Name)\n\t\t} else {\n\t\t\tnewSubscriptions = append(newSubscriptions, sub)\n\t\t}\n\t}\n\tif len(newSubscriptions) < len(prefs.Subscriptions) {\n\t\tprefs.Subscriptions = newSubscriptions\n\t\tif err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// handleAccountReservationAdd adds a topic reservation for the logged-in user, but only if the user has a tier\n// with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless\n// it is already reserved by someone else.\nfunc (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\treq, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !topicRegex.MatchString(req.Topic) {\n\t\treturn errHTTPBadRequestTopicInvalid\n\t}\n\teveryone, err := user.ParsePermission(req.Everyone)\n\tif err != nil {\n\t\treturn errHTTPBadRequestPermissionInvalid\n\t}\n\t// Check if we are allowed to reserve this topic\n\tif u.IsUser() && u.Tier == nil {\n\t\treturn errHTTPUnauthorized\n\t} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {\n\t\treturn errHTTPConflictTopicReserved\n\t}\n\t// Actually add the reservation (with limit check inside the transaction to avoid races)\n\tlogvr(v, r).\n\t\tTag(tagAccount).\n\t\tFields(log.Context{\n\t\t\t\"topic\":    req.Topic,\n\t\t\t\"everyone\": everyone.String(),\n\t\t}).\n\t\tDebug(\"Adding topic reservation\")\n\tvar limit int64\n\tif u.IsUser() && u.Tier != nil {\n\t\tlimit = u.Tier.ReservationLimit\n\t}\n\tif err := s.userManager.AddReservation(u.Name, req.Topic, everyone, limit); err != nil {\n\t\tif errors.Is(err, user.ErrTooManyReservations) {\n\t\t\treturn errHTTPTooManyRequestsLimitReservations\n\t\t}\n\t\treturn err\n\t}\n\t// Kill existing subscribers\n\tt, err := s.topicFromID(req.Topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\tt.CancelSubscribersExceptUser(u.ID)\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// handleAccountReservationDelete deletes a topic reservation if it is owned by the current user\nfunc (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tmatches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)\n\tif len(matches) != 2 {\n\t\treturn errHTTPInternalErrorInvalidPath\n\t}\n\ttopic := matches[1]\n\tif !topicRegex.MatchString(topic) {\n\t\treturn errHTTPBadRequestTopicInvalid\n\t}\n\tu := v.User()\n\tauthorized, err := s.userManager.HasReservation(u.Name, topic)\n\tif err != nil {\n\t\treturn err\n\t} else if !authorized {\n\t\treturn errHTTPUnauthorized\n\t}\n\tdeleteMessages := readBoolParam(r, false, \"X-Delete-Messages\", \"Delete-Messages\")\n\tlogvr(v, r).\n\t\tTag(tagAccount).\n\t\tFields(log.Context{\n\t\t\t\"topic\":           topic,\n\t\t\t\"delete_messages\": deleteMessages,\n\t\t}).\n\t\tDebug(\"Removing topic reservation\")\n\tif err := s.userManager.RemoveReservations(u.Name, topic); err != nil {\n\t\treturn err\n\t}\n\tif deleteMessages {\n\t\tif err := s.messageCache.ExpireMessages(topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.pruneMessages()\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),\n// and marks associated messages for the topics as deleted. This also eventually deletes attachments.\n// The process relies on the manager to perform the actual deletions (see runManager).\nfunc (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {\n\tremovedTopics, err := s.userManager.RemoveExcessReservations(u.Name, reservationsLimit)\n\tif err != nil {\n\t\treturn err\n\t} else if len(removedTopics) == 0 {\n\t\tlogvr(v, r).Tag(tagAccount).Debug(\"No excess reservations to remove\")\n\t\treturn nil\n\t}\n\tlogvr(v, r).Tag(tagAccount).Info(\"Removed excess topic reservations, now removing messages for topics %s\", strings.Join(removedTopics, \", \"))\n\tif err := s.messageCache.ExpireMessages(removedTopics...); err != nil {\n\t\treturn err\n\t}\n\tgo s.pruneMessages()\n\treturn nil\n}\n\nfunc (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\treq, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t} else if !phoneNumberRegex.MatchString(req.Number) {\n\t\treturn errHTTPBadRequestPhoneNumberInvalid\n\t} else if req.Channel != \"sms\" && req.Channel != \"call\" {\n\t\treturn errHTTPBadRequestPhoneNumberVerifyChannelInvalid\n\t}\n\t// Check user is allowed to add phone numbers\n\tif u == nil || (u.IsUser() && u.Tier == nil) {\n\t\treturn errHTTPUnauthorized\n\t} else if u.IsUser() && u.Tier.CallLimit == 0 {\n\t\treturn errHTTPUnauthorized\n\t}\n\t// Check if phone number exists\n\tphoneNumbers, err := s.userManager.PhoneNumbers(u.ID)\n\tif err != nil {\n\t\treturn err\n\t} else if util.Contains(phoneNumbers, req.Number) {\n\t\treturn errHTTPConflictPhoneNumberExists\n\t}\n\t// Actually add the unverified number, and send verification\n\tlogvr(v, r).Tag(tagAccount).Field(\"phone_number\", req.Number).Debug(\"Sending phone number verification\")\n\tif err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\treq, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !phoneNumberRegex.MatchString(req.Number) {\n\t\treturn errHTTPBadRequestPhoneNumberInvalid\n\t}\n\tif err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {\n\t\treturn err\n\t}\n\tlogvr(v, r).Tag(tagAccount).Field(\"phone_number\", req.Number).Debug(\"Adding phone number as verified\")\n\tif err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\treq, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !phoneNumberRegex.MatchString(req.Number) {\n\t\treturn errHTTPBadRequestPhoneNumberInvalid\n\t}\n\tlogvr(v, r).Tag(tagAccount).Field(\"phone_number\", req.Number).Debug(\"Deleting phone number\")\n\tif err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic\nfunc (s *Server) publishSyncEventAsync(v *visitor) {\n\tgo func() {\n\t\tif err := s.publishSyncEvent(v); err != nil {\n\t\t\tlogv(v).Err(err).Trace(\"Error publishing to user's sync topic\")\n\t\t}\n\t}()\n}\n\n// publishSyncEvent publishes a sync message to the user's sync topic\nfunc (s *Server) publishSyncEvent(v *visitor) error {\n\tu := v.User()\n\tif u == nil || u.SyncTopic == \"\" {\n\t\treturn nil\n\t}\n\tlogv(v).Field(\"sync_topic\", u.SyncTopic).Trace(\"Publishing sync event to user's sync topic\")\n\tsyncTopic, err := s.topicFromID(u.SyncTopic)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmessageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})\n\tif err != nil {\n\t\treturn err\n\t}\n\tm := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))\n\tif err := syncTopic.Publish(v, m); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/server_account_test.go",
    "content": "package server\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAccount_Signup_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\ttoken, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))\n\t\trequire.NotEmpty(t, token.Token)\n\t\trequire.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)\n\t\trequire.True(t, strings.HasPrefix(token.Token, \"tk_\"))\n\t\trequire.Equal(t, \"9.9.9.9\", token.LastOrigin)\n\t\trequire.True(t, token.LastAccess > time.Now().Unix()-2)\n\t\trequire.True(t, token.LastAccess < time.Now().Unix()+2)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Token),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"phil\", account.Username)\n\t\trequire.Equal(t, \"user\", account.Role)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"\", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :<token>)\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"phil\", account.Username)\n\t})\n}\n\nfunc TestAccount_Signup_UserExists(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 409, rr.Code)\n\t\trequire.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_Signup_LimitReached(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\tfor i := 0; i < 3; i++ {\n\t\t\trr := request(t, s, \"POST\", \"/v1/account\", fmt.Sprintf(`{\"username\":\"phil%d\", \"password\":\"mypass\"}`, i), nil)\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"thiswontwork\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t\trequire.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_Signup_AsUser(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\tlog.Info(\"1\")\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\tlog.Info(\"2\")\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\tlog.Info(\"3\")\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"emma\", \"password\":\"emma\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tlog.Info(\"4\")\n\t\trr = request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"marian\", \"password\":\"marian\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestAccount_Signup_Disabled(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = false\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_Signup_Rate_Limit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\tfor i := 0; i < 3; i++ {\n\t\t\trr := request(t, s, \"POST\", \"/v1/account\", fmt.Sprintf(`{\"username\":\"phil%d\", \"password\":\"mypass\"}`, i), nil)\n\t\t\trequire.Equal(t, 200, rr.Code, \"failed on iteration %d\", i)\n\t\t}\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"notallowed\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t\trequire.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_Get_Anonymous(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.VisitorRequestLimitReplenish = 86 * time.Second\n\t\tconf.VisitorEmailLimitReplenish = time.Hour\n\t\tconf.VisitorAttachmentTotalSizeLimit = 5123\n\t\tconf.AttachmentFileSizeLimit = 512\n\t\ts := newTestServer(t, conf)\n\t\ts.smtpSender = &testMailer{}\n\t\tdefer s.closeDatabases()\n\n\t\trr := request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"*\", account.Username)\n\t\trequire.Equal(t, string(user.RoleAnonymous), account.Role)\n\t\trequire.Equal(t, \"ip\", account.Limits.Basis)\n\t\trequire.Equal(t, int64(1004), account.Limits.Messages) // I hate this\n\t\trequire.Equal(t, int64(24), account.Limits.Emails)     // I hate this\n\t\trequire.Equal(t, int64(5123), account.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(512), account.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(0), account.Stats.Messages)\n\t\trequire.Equal(t, int64(1004), account.Stats.MessagesRemaining)\n\t\trequire.Equal(t, int64(0), account.Stats.Emails)\n\t\trequire.Equal(t, int64(24), account.Stats.EmailsRemaining)\n\t\trequire.Equal(t, int64(0), account.Stats.Calls)\n\t\trequire.Equal(t, int64(0), account.Stats.CallsRemaining)\n\n\t\trr = request(t, s, \"POST\", \"/mytopic\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trr = request(t, s, \"POST\", \"/mytopic\", \"\", map[string]string{\n\t\t\t\"Email\": \"phil@ntfy.sh\",\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, int64(2), account.Stats.Messages)\n\t\trequire.Equal(t, int64(1002), account.Stats.MessagesRemaining)\n\t\trequire.Equal(t, int64(1), account.Stats.Emails)\n\t\trequire.Equal(t, int64(23), account.Stats.EmailsRemaining)\n\t})\n}\n\nfunc TestAccount_ChangeSettings(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\tu, _ := s.userManager.User(\"phil\")\n\t\ttoken, _ := s.userManager.CreateToken(u.ID, \"\", time.Unix(0, 0), netip.IPv4Unspecified(), false)\n\n\t\trr := request(t, s, \"PATCH\", \"/v1/account/settings\", `{\"notification\": {\"sound\": \"juntos\"},\"ignored\": true}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"PATCH\", \"/v1/account/settings\", `{\"notification\": {\"delete_after\": 86400}, \"language\": \"de\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Value),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", `{\"username\":\"marian\", \"password\":\"marian\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Value),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"de\", account.Language)\n\t\trequire.Equal(t, util.Int(86400), account.Notification.DeleteAfter)\n\t\trequire.Equal(t, util.String(\"juntos\"), account.Notification.Sound)\n\t\trequire.Nil(t, account.Notification.MinPriority) // Not set\n\t})\n}\n\nfunc TestAccount_Subscription_AddUpdateDelete(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\trr := request(t, s, \"POST\", \"/v1/account/subscription\", `{\"base_url\": \"http://abc.com\", \"topic\": \"def\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, 1, len(account.Subscriptions))\n\t\trequire.Equal(t, \"http://abc.com\", account.Subscriptions[0].BaseURL)\n\t\trequire.Equal(t, \"def\", account.Subscriptions[0].Topic)\n\t\trequire.Nil(t, account.Subscriptions[0].DisplayName)\n\n\t\trr = request(t, s, \"PATCH\", \"/v1/account/subscription\", `{\"base_url\": \"http://abc.com\", \"topic\": \"def\", \"display_name\": \"ding dong\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, 1, len(account.Subscriptions))\n\t\trequire.Equal(t, \"http://abc.com\", account.Subscriptions[0].BaseURL)\n\t\trequire.Equal(t, \"def\", account.Subscriptions[0].Topic)\n\t\trequire.Equal(t, util.String(\"ding dong\"), account.Subscriptions[0].DisplayName)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/subscription\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"X-BaseURL\":     \"http://abc.com\",\n\t\t\t\"X-Topic\":       \"def\",\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, 0, len(account.Subscriptions))\n\t})\n}\n\nfunc TestAccount_ChangePassword(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.AuthUsers = []*user.User{\n\t\t\t{Name: \"philuser\", Hash: \"$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC\", Role: user.RoleUser}, // philuser:philpass\n\t\t}\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\trr := request(t, s, \"POST\", \"/v1/account/password\", `{\"password\": \"WRONG\", \"new_password\": \"\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/password\", `{\"password\": \"WRONG\", \"new_password\": \"new password\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/password\", `{\"password\": \"phil\", \"new_password\": \"new password\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"new password\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Cannot change password of provisioned user\n\t\trr = request(t, s, \"POST\", \"/v1/account/password\", `{\"password\": \"philpass\", \"new_password\": \"new password\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"philuser\", \"philpass\"),\n\t\t})\n\t\trequire.Equal(t, 409, rr.Code)\n\t})\n}\n\nfunc TestAccount_ChangePassword_NoAccount(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trr := request(t, s, \"POST\", \"/v1/account/password\", `{\"password\": \"new password\"}`, nil)\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestAccount_ExtendToken(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\trr := request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\ttoken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\n\t\ttime.Sleep(time.Second)\n\n\t\trr = request(t, s, \"PATCH\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Token),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\textendedToken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, token.Token, extendedToken.Token)\n\t\trequire.True(t, token.Expires < extendedToken.Expires)\n\n\t\texpires := time.Now().Add(999 * time.Hour)\n\t\tbody := fmt.Sprintf(`{\"token\":\"%s\", \"label\":\"some label\", \"expires\": %d}`, token.Token, expires.Unix())\n\t\trr = request(t, s, \"PATCH\", \"/v1/account/token\", body, map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Token),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\ttoken, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"some label\", token.Label)\n\t\trequire.Equal(t, expires.Unix(), token.Expires)\n\t})\n}\n\nfunc TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\trr := request(t, s, \"PATCH\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"), // Not Bearer!\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_DeleteToken(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\trr := request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\ttoken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix())\n\n\t\t// Delete token failure (using basic auth)\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"), // Not Bearer!\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code)\n\n\t\t// Delete token with wrong token\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(\"invalidtoken\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Delete token with correct token\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Token),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Cannot get account anymore\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BearerAuth(token.Token),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestAccount_Delete_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account\", `{\"password\":\"mypass\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Account was marked deleted\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Cannot re-create account, since still exists\n\t\trr = request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 409, rr.Code)\n\t})\n}\n\nfunc TestAccount_Delete_Not_Allowed(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account\", `{\"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account\", `{\"password\":\"INCORRECT\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\":\"mytopic\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestAccount_Reservation_AddAdminSuccess(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\t// A user, an admin, and a reservation walk into a bar\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:             \"pro\",\n\t\t\tReservationLimit: 2,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"noadmin1\", \"pass\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"noadmin1\", \"pro\"))\n\t\trequire.Nil(t, s.userManager.AddReservation(\"noadmin1\", \"mytopic\", user.PermissionDenyAll, 0))\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"noadmin2\", \"pass\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"noadmin2\", \"pro\"))\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"adminpass\", user.RoleAdmin, false))\n\n\t\t// Admin can reserve topic\n\t\trr := request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\":\"sometopic\",\"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"adminpass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// User cannot reserve already reserved topic\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\":\"mytopic\",\"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"noadmin2\", \"pass\"),\n\t\t})\n\t\trequire.Equal(t, 409, rr.Code)\n\n\t\t// Admin cannot reserve already reserved topic\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\":\"mytopic\",\"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"adminpass\"),\n\t\t})\n\t\trequire.Equal(t, 409, rr.Code)\n\n\t\treservations, err := s.userManager.Reservations(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(reservations))\n\t\trequire.Equal(t, \"sometopic\", reservations[0].Topic)\n\n\t\treservations, err = s.userManager.Reservations(\"noadmin1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(reservations))\n\t\trequire.Equal(t, \"mytopic\", reservations[0].Topic)\n\n\t\treservations, err = s.userManager.Reservations(\"noadmin2\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(reservations))\n\t})\n}\n\nfunc TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.EnableSignup = true\n\t\tconf.EnableReservations = true\n\t\tconf.TwilioAccount = \"dummy\"\n\t\ts := newTestServer(t, conf)\n\n\t\t// Create user\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Create a tier\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                     \"pro\",\n\t\t\tMessageLimit:             123,\n\t\t\tMessageExpiryDuration:    86400 * time.Second,\n\t\t\tEmailLimit:               32,\n\t\t\tCallLimit:                10,\n\t\t\tReservationLimit:         2,\n\t\t\tAttachmentFileSizeLimit:  1231231,\n\t\t\tAttachmentTotalSizeLimit: 123123,\n\t\t\tAttachmentExpiryDuration: 10800 * time.Second,\n\t\t\tAttachmentBandwidthLimit: 21474836480,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\n\t\t// Reserve two topics\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"mytopic\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"another\", \"everyone\":\"read-only\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Trying to reserve a third should fail\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"yet-another\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// Modify existing should still work\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"another\", \"everyone\":\"write-only\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check account result\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"pro\", account.Tier.Code)\n\t\trequire.Equal(t, int64(123), account.Limits.Messages)\n\t\trequire.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)\n\t\trequire.Equal(t, int64(32), account.Limits.Emails)\n\t\trequire.Equal(t, int64(10), account.Limits.Calls)\n\t\trequire.Equal(t, int64(2), account.Limits.Reservations)\n\t\trequire.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)\n\t\trequire.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth)\n\t\trequire.Equal(t, 2, len(account.Reservations))\n\t\trequire.Equal(t, \"another\", account.Reservations[0].Topic)\n\t\trequire.Equal(t, \"write-only\", account.Reservations[0].Everyone)\n\t\trequire.Equal(t, \"mytopic\", account.Reservations[1].Topic)\n\t\trequire.Equal(t, \"deny-all\", account.Reservations[1].Everyone)\n\n\t\t// Delete and re-check\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/reservation/another\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, 1, len(account.Reservations))\n\t\trequire.Equal(t, \"mytopic\", account.Reservations[0].Topic)\n\t})\n}\n\nfunc TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.AuthDefault = user.PermissionReadWrite\n\t\tconf.EnableSignup = true\n\t\ts := newTestServer(t, conf)\n\n\t\t// Create user with tier\n\t\trr := request(t, s, \"POST\", \"/v1/account\", `{\"username\":\"phil\", \"password\":\"mypass\"}`, nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:             \"pro\",\n\t\t\tMessageLimit:     20,\n\t\t\tReservationLimit: 2,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\n\t\t// Reserve a topic\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"mytopic\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Publish a message\n\t\trr = request(t, s, \"POST\", \"/mytopic\", `Howdy`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Publish a message (as anonymous)\n\t\trr = request(t, s, \"POST\", \"/mytopic\", `Howdy`, nil)\n\t\trequire.Equal(t, 403, rr.Code)\n\t})\n}\n\nfunc TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\tconf.AuthDefault = user.PermissionReadWrite\n\t\ts := newTestServer(t, conf)\n\n\t\t// Create user with tier\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"mypass\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                     \"pro\",\n\t\t\tMessageLimit:             20,\n\t\t\tMessageExpiryDuration:    time.Hour,\n\t\t\tReservationLimit:         2,\n\t\t\tAttachmentTotalSizeLimit: 10000,\n\t\t\tAttachmentFileSizeLimit:  10000,\n\t\t\tAttachmentExpiryDuration: time.Hour,\n\t\t\tAttachmentBandwidthLimit: 10000,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\n\t\t// Reserve two topics \"mytopic1\" and \"mytopic2\"\n\t\trr := request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"mytopic1\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"POST\", \"/v1/account/reservation\", `{\"topic\": \"mytopic2\", \"everyone\":\"deny-all\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Publish a message with attachment to each topic\n\t\trr = request(t, s, \"POST\", \"/mytopic1?f=attach.txt\", `Howdy`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tm1 := toMessage(t, rr.Body.String())\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))\n\n\t\trr = request(t, s, \"POST\", \"/mytopic2?f=attach.txt\", `Howdy`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tm2 := toMessage(t, rr.Body.String())\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))\n\n\t\t// Pre-verify message count and file\n\t\tms, err := s.messageCache.Messages(\"mytopic1\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(ms))\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))\n\n\t\tms, err = s.messageCache.Messages(\"mytopic2\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(ms))\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))\n\n\t\t// Delete reservation\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/reservation/mytopic1\", ``, map[string]string{\n\t\t\t\"X-Delete-Messages\": \"true\",\n\t\t\t\"Authorization\":     util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"DELETE\", \"/v1/account/reservation/mytopic2\", ``, map[string]string{\n\t\t\t\"X-Delete-Messages\": \"false\",\n\t\t\t\"Authorization\":     util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Verify that messages and attachments were deleted\n\t\t// This does not explicitly call the manager!\n\t\twaitFor(t, func() bool {\n\t\t\tms, err := s.messageCache.Messages(\"mytopic1\", model.SinceAllMessages, false)\n\t\t\trequire.Nil(t, err)\n\t\t\treturn len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))\n\t\t})\n\n\t\tms, err = s.messageCache.Messages(\"mytopic1\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(ms))\n\t\trequire.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))\n\n\t\tms, err = s.messageCache.Messages(\"mytopic2\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(ms))\n\t\trequire.Equal(t, m2.ID, ms[0].ID)\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))\n\t})\n}\n\n/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {\n\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\tconf.AuthDefault = user.PermissionReadWrite\n\tconf.AuthStatsQueueWriterInterval = 300 * time.Millisecond\n\ts := newTestServer(t, conf)\n\tdefer s.closeDatabases()\n\n\t// Create user with tier\n\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser))\n\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\tCode:         \"starter\",\n\t\tMessageSizeLimit: 10,\n\t}))\n\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\tCode:         \"pro\",\n\t\tMessageSizeLimit: 20,\n\t}))\n\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"starter\"))\n\n\t// Publish a message\n\trr := request(t, s, \"POST\", \"/mytopic\", \"hi\", map[string]string{\n\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t})\n\trequire.Equal(t, 200, rr.Code)\n\n\t// Wait for stats queue writer, verify that message stats were persisted\n\twaitFor(t, func() bool {\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\treturn int64(1) == u.Stats.Messages\n\t})\n\n\t// Change tier, make a request (to reset limiters)\n\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t})\n\trequire.Equal(t, 200, rr.Code)\n\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\trequire.Equal(t, int64(1), account.Stats.Messages) // Is not reset!\n\n\t// Publish another message\n\trr = request(t, s, \"POST\", \"/mytopic\", \"hi\", map[string]string{\n\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t})\n\trequire.Equal(t, 200, rr.Code)\n\n\t// Verify that message stats were persisted\n\twaitFor(t, func() bool {\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\treturn int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!\n\t})\n\n\t// Stats keep counting\n\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t})\n\trequire.Equal(t, 200, rr.Code)\n\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\trequire.Equal(t, int64(2), account.Stats.Messages) // Is not reset!\n}*/\n"
  },
  {
    "path": "server/server_admin.go",
    "content": "package server\n\nimport (\n\t\"errors\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"net/http\"\n)\n\nfunc (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn s.writeJSON(w, &apiVersionResponse{\n\t\tVersion: s.config.BuildVersion,\n\t\tCommit:  s.config.BuildCommit,\n\t\tDate:    s.config.BuildDate,\n\t})\n}\n\nfunc (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tusers, err := s.userManager.Users()\n\tif err != nil {\n\t\treturn err\n\t}\n\tgrants, err := s.userManager.AllGrants()\n\tif err != nil {\n\t\treturn err\n\t}\n\tusersResponse := make([]*apiUserResponse, len(users))\n\tfor i, u := range users {\n\t\ttier := \"\"\n\t\tif u.Tier != nil {\n\t\t\ttier = u.Tier.Code\n\t\t}\n\t\tuserGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))\n\t\tfor i, g := range grants[u.ID] {\n\t\t\tuserGrants[i] = &apiUserGrantResponse{\n\t\t\t\tTopic:      g.TopicPattern,\n\t\t\t\tPermission: g.Permission.String(),\n\t\t\t}\n\t\t}\n\t\tusersResponse[i] = &apiUserResponse{\n\t\t\tUsername: u.Name,\n\t\t\tRole:     string(u.Role),\n\t\t\tTier:     tier,\n\t\t\tGrants:   userGrants,\n\t\t}\n\t}\n\treturn s.writeJSON(w, usersResponse)\n}\n\nfunc (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t} else if !user.AllowedUsername(req.Username) || (req.Password == \"\" && req.Hash == \"\") {\n\t\treturn errHTTPBadRequest.Wrap(\"username invalid, or password/password_hash missing\")\n\t}\n\tu, err := s.userManager.User(req.Username)\n\tif err != nil && !errors.Is(err, user.ErrUserNotFound) {\n\t\treturn err\n\t} else if u != nil {\n\t\treturn errHTTPConflictUserExists\n\t}\n\tvar tier *user.Tier\n\tif req.Tier != \"\" {\n\t\ttier, err = s.userManager.Tier(req.Tier)\n\t\tif errors.Is(err, user.ErrTierNotFound) {\n\t\t\treturn errHTTPBadRequestTierInvalid\n\t\t} else if err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tpassword, hashed := req.Password, false\n\tif req.Hash != \"\" {\n\t\tpassword, hashed = req.Hash, true\n\t}\n\tif err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {\n\t\treturn err\n\t}\n\tif tier != nil {\n\t\tif err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleUsersUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t} else if !user.AllowedUsername(req.Username) {\n\t\treturn errHTTPBadRequest.Wrap(\"username invalid\")\n\t} else if req.Password == \"\" && req.Hash == \"\" && req.Tier == \"\" {\n\t\treturn errHTTPBadRequest.Wrap(\"need to provide at least one of \\\"password\\\", \\\"password_hash\\\" or \\\"tier\\\"\")\n\t}\n\tu, err := s.userManager.User(req.Username)\n\tif err != nil && !errors.Is(err, user.ErrUserNotFound) {\n\t\treturn err\n\t} else if u != nil {\n\t\tif u.IsAdmin() {\n\t\t\treturn errHTTPForbidden\n\t\t}\n\t\tif req.Hash != \"\" {\n\t\t\tif err := s.userManager.ChangePassword(req.Username, req.Hash, true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else if req.Password != \"\" {\n\t\t\tif err := s.userManager.ChangePassword(req.Username, req.Password, false); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t} else {\n\t\tpassword, hashed := req.Password, false\n\t\tif req.Hash != \"\" {\n\t\t\tpassword, hashed = req.Hash, true\n\t\t}\n\t\tif err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif req.Tier != \"\" {\n\t\tif _, err = s.userManager.Tier(req.Tier); errors.Is(err, user.ErrTierNotFound) {\n\t\t\treturn errHTTPBadRequestTierInvalid\n\t\t} else if err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := s.userManager.User(req.Username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn errHTTPBadRequestUserNotFound\n\t} else if err != nil {\n\t\treturn err\n\t} else if !u.IsUser() {\n\t\treturn errHTTPUnauthorized.Wrap(\"can only remove regular users from API\")\n\t}\n\tif err := s.userManager.RemoveUser(req.Username); err != nil {\n\t\treturn err\n\t}\n\tif err := s.killUserSubscriber(u, \"*\"); err != nil { // FIXME super inefficient\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.userManager.User(req.Username)\n\tif errors.Is(err, user.ErrUserNotFound) {\n\t\treturn errHTTPBadRequestUserNotFound\n\t} else if err != nil {\n\t\treturn err\n\t}\n\tpermission, err := user.ParsePermission(req.Permission)\n\tif err != nil {\n\t\treturn errHTTPBadRequestPermissionInvalid\n\t}\n\tif err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := s.userManager.User(req.Username)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil {\n\t\treturn err\n\t}\n\tif err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) killUserSubscriber(u *user.User, topicPattern string) error {\n\ttopics, err := s.topicsFromPattern(topicPattern)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, t := range topics {\n\t\tt.CancelSubscriberUser(u.ID)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/server_admin_test.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestVersion_Admin(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.BuildVersion = \"1.2.3\"\n\t\tc.BuildCommit = \"abcdef0\"\n\t\tc.BuildDate = \"2026-02-08T00:00:00Z\"\n\t\ts := newTestServer(t, c)\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin and regular user\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\n\t\t// Admin can access /v1/version\n\t\trr := request(t, s, \"GET\", \"/v1/version\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\tvar versionResponse apiVersionResponse\n\t\trequire.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))\n\t\trequire.Equal(t, \"1.2.3\", versionResponse.Version)\n\t\trequire.Equal(t, \"abcdef0\", versionResponse.Commit)\n\t\trequire.Equal(t, \"2026-02-08T00:00:00Z\", versionResponse.Date)\n\n\t\t// Non-admin user cannot access /v1/version\n\t\trr = request(t, s, \"GET\", \"/v1/version\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Unauthenticated user cannot access /v1/version\n\t\trr = request(t, s, \"GET\", \"/v1/version\", \"\", nil)\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestUser_AddRemove(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin, tier\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"tier1\",\n\t\t}))\n\n\t\t// Create user via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"password\":\"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Create user with tier via API\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"emma\", \"password\":\"emma\", \"tier\": \"tier1\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check users\n\t\tusers, err := s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 4, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"ben\", users[1].Name)\n\t\trequire.Equal(t, user.RoleUser, users[1].Role)\n\t\trequire.Nil(t, users[1].Tier)\n\t\trequire.Equal(t, \"emma\", users[2].Name)\n\t\trequire.Equal(t, user.RoleUser, users[2].Role)\n\t\trequire.Equal(t, \"tier1\", users[2].Tier.Code)\n\t\trequire.Equal(t, user.Everyone, users[3].Name)\n\n\t\t// Delete user via API\n\t\trr = request(t, s, \"DELETE\", \"/v1/users\", `{\"username\": \"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check user was deleted\n\t\tusers, err = s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"emma\", users[1].Name)\n\t\trequire.Equal(t, user.Everyone, users[2].Name)\n\n\t\t// Reject invalid user change\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\t})\n}\n\nfunc TestUser_AddWithPasswordHash(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\n\t\t// Create user via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"hash\":\"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check that user can login with password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check users\n\t\tusers, err := s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, user.RoleAdmin, users[0].Role)\n\t\trequire.Equal(t, \"ben\", users[1].Name)\n\t\trequire.Equal(t, user.RoleUser, users[1].Role)\n\t})\n}\n\nfunc TestUser_ChangeUserPassword(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\n\t\t// Create user via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"password\": \"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Try to login with first password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Change password via API\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"ben\", \"password\": \"ben-two\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Make sure first password fails\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Try to login with second password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben-two\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\n\nfunc TestUser_ChangeUserTier(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin, tier\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"tier1\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"tier2\",\n\t\t}))\n\n\t\t// Create user with tier via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"password\":\"ben\", \"tier\": \"tier1\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check users\n\t\tusers, err := s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"ben\", users[1].Name)\n\t\trequire.Equal(t, user.RoleUser, users[1].Role)\n\t\trequire.Equal(t, \"tier1\", users[1].Tier.Code)\n\n\t\t// Change user tier via API\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"ben\", \"tier\": \"tier2\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check users again\n\t\tusers, err = s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"tier2\", users[1].Tier.Code)\n\t})\n}\n\nfunc TestUser_ChangeUserPasswordAndTier(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin, tier\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"tier1\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"tier2\",\n\t\t}))\n\n\t\t// Create user with tier via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"password\":\"ben\", \"tier\": \"tier1\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check users\n\t\tusers, err := s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"ben\", users[1].Name)\n\t\trequire.Equal(t, user.RoleUser, users[1].Role)\n\t\trequire.Equal(t, \"tier1\", users[1].Tier.Code)\n\n\t\t// Change user password and tier via API\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"ben\", \"password\":\"ben-two\", \"tier\": \"tier2\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Make sure first password fails\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Try to login with second password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben-two\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Check new tier\n\t\tusers, err = s.userManager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"tier2\", users[1].Tier.Code)\n\t})\n}\n\nfunc TestUser_ChangeUserPasswordWithHash(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\n\t\t// Create user with tier via API\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"ben\", \"password\":\"not-ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Try to login with first password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"not-ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Change user password and tier via API\n\t\trr = request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"ben\", \"hash\":\"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Try to login with second password\n\t\trr = request(t, s, \"POST\", \"/v1/account/token\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\n\nfunc TestUser_DontChangeAdminPassword(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"admin\", \"admin\", user.RoleAdmin, false))\n\n\t\t// Try to change password via API\n\t\trr := request(t, s, \"PUT\", \"/v1/users\", `{\"username\": \"admin\", \"password\": \"admin-new\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 403, rr.Code)\n\t})\n}\n\nfunc TestUser_AddRemove_Failures(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\t\tdefer s.closeDatabases()\n\n\t\t// Create admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\n\t\t// Cannot create user with invalid username\n\t\trr := request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"not valid\", \"password\":\"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 400, rr.Code)\n\n\t\t// Cannot create user if user already exists\n\t\trr = request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"phil\", \"password\":\"phil\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)\n\n\t\t// Cannot create user with invalid tier\n\t\trr = request(t, s, \"POST\", \"/v1/users\", `{\"username\": \"emma\", \"password\":\"emma\", \"tier\": \"invalid\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)\n\n\t\t// Cannot delete user as non-admin\n\t\trr = request(t, s, \"DELETE\", \"/v1/users\", `{\"username\": \"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\n\t\t// Delete user via API\n\t\trr = request(t, s, \"DELETE\", \"/v1/users\", `{\"username\": \"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\n\nfunc TestAccess_AllowReset(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\t\tdefer s.closeDatabases()\n\n\t\t// User and admin\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\n\t\t// Subscribing not allowed\n\t\trr := request(t, s, \"GET\", \"/gold/json?poll=1\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 403, rr.Code)\n\n\t\t// Grant access\n\t\trr = request(t, s, \"POST\", \"/v1/users/access\", `{\"username\": \"ben\", \"topic\":\"gold\", \"permission\":\"ro\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Now subscribing is allowed\n\t\trr = request(t, s, \"GET\", \"/gold/json?poll=1\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Reset access\n\t\trr = request(t, s, \"DELETE\", \"/v1/users/access\", `{\"username\": \"ben\", \"topic\":\"gold\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Subscribing not allowed (again)\n\t\trr = request(t, s, \"GET\", \"/gold/json?poll=1\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 403, rr.Code)\n\t})\n}\n\nfunc TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\t\tdefer s.closeDatabases()\n\n\t\t// User\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\n\t\t// Grant access fails, because non-admin\n\t\trr := request(t, s, \"POST\", \"/v1/users/access\", `{\"username\": \"ben\", \"topic\":\"gold\", \"permission\":\"ro\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestAccess_AllowReset_KillConnection(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\t\tdefer s.closeDatabases()\n\n\t\t// User and admin, grant access to \"gol*\" topics\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"gol*\", user.PermissionRead)) // Wildcard!\n\n\t\tstart, timeTaken := time.Now(), atomic.Int64{}\n\t\tgo func() {\n\t\t\trr := request(t, s, \"GET\", \"/gold/json\", \"\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t\ttimeTaken.Store(time.Since(start).Milliseconds())\n\t\t}()\n\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\t// Reset access\n\t\trr := request(t, s, \"DELETE\", \"/v1/users/access\", `{\"username\": \"ben\", \"topic\":\"gol*\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Wait for connection to be killed; this will fail if the connection is never killed\n\t\twaitFor(t, func() bool {\n\t\t\treturn timeTaken.Load() >= 500\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "server/server_firebase.go",
    "content": "//go:build !nofirebase\n\npackage server\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\tfirebase \"firebase.google.com/go/v4\"\n\t\"firebase.google.com/go/v4/messaging\"\n\t\"fmt\"\n\t\"google.golang.org/api/option\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"strings\"\n)\n\nconst (\n\t// FirebaseAvailable is a constant used to indicate that Firebase support is available.\n\t// It can be disabled with the 'nofirebase' build tag.\n\tFirebaseAvailable = true\n\n\tfcmMessageLimit         = 4000\n\tfcmApnsBodyMessageLimit = 100\n)\n\nvar (\n\terrFirebaseQuotaExceeded     = errors.New(\"quota exceeded for Firebase messages to topic\")\n\terrFirebaseTemporarilyBanned = errors.New(\"visitor temporarily banned from using Firebase\")\n)\n\n// firebaseClient is a generic client that formats and sends messages to Firebase.\n// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.\ntype firebaseClient struct {\n\tsender firebaseSender\n\tauther user.Auther\n}\n\nfunc newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {\n\treturn &firebaseClient{\n\t\tsender: sender,\n\t\tauther: auther,\n\t}\n}\n\nfunc (c *firebaseClient) Send(v *visitor, m *model.Message) error {\n\tif !v.FirebaseAllowed() {\n\t\treturn errFirebaseTemporarilyBanned\n\t}\n\tfbm, err := toFirebaseMessage(m, c.auther)\n\tif err != nil {\n\t\treturn err\n\t}\n\tev := logvm(v, m).Tag(tagFirebase)\n\tif ev.IsTrace() {\n\t\tev.Field(\"firebase_message\", util.MaybeMarshalJSON(fbm)).Trace(\"Firebase message\")\n\t}\n\terr = c.sender.Send(fbm)\n\tif errors.Is(err, errFirebaseQuotaExceeded) {\n\t\tlogvm(v, m).\n\t\t\tTag(tagFirebase).\n\t\t\tErr(err).\n\t\t\tWarn(\"Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor\")\n\t\tv.FirebaseTemporarilyDeny()\n\t}\n\treturn err\n}\n\n// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging.\n// In tests, this can be implemented with a mock.\ntype firebaseSender interface {\n\t// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded\n\t// if a rate limit has reached.\n\tSend(m *messaging.Message) error\n}\n\n// firebaseSenderImpl is a firebaseSender that actually talks to Firebase\ntype firebaseSenderImpl struct {\n\tclient *messaging.Client\n}\n\nfunc newFirebaseSender(credentialsFile string) (firebaseSender, error) {\n\tfb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclient, err := fb.Messaging(context.Background())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &firebaseSenderImpl{\n\t\tclient: client,\n\t}, nil\n}\n\nfunc (c *firebaseSenderImpl) Send(m *messaging.Message) error {\n\t_, err := c.client.Send(context.Background(), m)\n\tif err != nil && messaging.IsQuotaExceeded(err) {\n\t\treturn errFirebaseQuotaExceeded\n\t}\n\treturn err\n}\n\n// toFirebaseMessage converts a message to a Firebase message.\n//\n// Normal messages (\"message\"):\n//   - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields\n//     in the \"data\" attribute. In the Android app, we then turn those into a notification and display it.\n//   - On iOS, we are not allowed to receive data-only messages, so we build messages with an \"alert\" (with title and\n//     message), and still send the rest of the data along in the \"aps\" attribute. We can then locally modify the\n//     message in the Notification Service Extension.\n//\n// Keepalive messages (\"keepalive\"):\n//   - On Android, we subscribe to the \"~control\" topic, which is used to restart the foreground service (if it died,\n//     e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).\n//   - On iOS, we subscribe to the \"~poll\" topic, which is used to poll all topics regularly. This is because iOS\n//     does not allow any background or scheduled activity at all.\n//\n// Poll request messages (\"poll_request\"):\n//   - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.\n//     On Android, this will trigger the app to poll the topic and thereby displaying new messages.\n//   - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded\n//     to Firebase here. This is mainly for iOS to support self-hosted servers.\nfunc toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message, error) {\n\tvar data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format\n\tvar apnsConfig *messaging.APNSConfig\n\tswitch m.Event {\n\tcase model.KeepaliveEvent, model.OpenEvent:\n\t\tdata = map[string]string{\n\t\t\t\"id\":    m.ID,\n\t\t\t\"time\":  fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\"event\": m.Event,\n\t\t\t\"topic\": m.Topic,\n\t\t}\n\t\tapnsConfig = createAPNSBackgroundConfig(data)\n\tcase model.PollRequestEvent:\n\t\tdata = map[string]string{\n\t\t\t\"id\":      m.ID,\n\t\t\t\"time\":    fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\"event\":   m.Event,\n\t\t\t\"topic\":   m.Topic,\n\t\t\t\"message\": newMessageBody,\n\t\t\t\"poll_id\": m.PollID,\n\t\t}\n\t\tapnsConfig = createAPNSAlertConfig(m, data)\n\tcase model.MessageDeleteEvent, model.MessageClearEvent:\n\t\tdata = map[string]string{\n\t\t\t\"id\":          m.ID,\n\t\t\t\"time\":        fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\"event\":       m.Event,\n\t\t\t\"topic\":       m.Topic,\n\t\t\t\"sequence_id\": m.SequenceID,\n\t\t}\n\t\tapnsConfig = createAPNSBackgroundConfig(data)\n\tcase model.MessageEvent:\n\t\tif auther != nil {\n\t\t\t// If \"anonymous read\" for a topic is not allowed, we cannot send the message along\n\t\t\t// via Firebase. Instead, we send a \"poll_request\" message, asking the client to poll.\n\t\t\t//\n\t\t\t// The data map needs to contain all the fields for it to function properly. If not all\n\t\t\t// fields are set, the iOS app fails to decode the message.\n\t\t\t//\n\t\t\t// See https://github.com/binwiederhier/ntfy/pull/1345\n\t\t\tif err := auther.Authorize(nil, m.Topic, user.PermissionRead); err != nil {\n\t\t\t\tm = toPollRequest(m)\n\t\t\t}\n\t\t}\n\t\tdata = map[string]string{\n\t\t\t\"id\":           m.ID,\n\t\t\t\"time\":         fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\"event\":        m.Event,\n\t\t\t\"topic\":        m.Topic,\n\t\t\t\"sequence_id\":  m.SequenceID,\n\t\t\t\"priority\":     fmt.Sprintf(\"%d\", m.Priority),\n\t\t\t\"tags\":         strings.Join(m.Tags, \",\"),\n\t\t\t\"click\":        m.Click,\n\t\t\t\"icon\":         m.Icon,\n\t\t\t\"title\":        m.Title,\n\t\t\t\"message\":      m.Message,\n\t\t\t\"content_type\": m.ContentType,\n\t\t\t\"encoding\":     m.Encoding,\n\t\t}\n\t\tif len(m.Actions) > 0 {\n\t\t\tactions, err := json.Marshal(m.Actions)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tdata[\"actions\"] = string(actions)\n\t\t}\n\t\tif m.Attachment != nil {\n\t\t\tdata[\"attachment_name\"] = m.Attachment.Name\n\t\t\tdata[\"attachment_type\"] = m.Attachment.Type\n\t\t\tdata[\"attachment_size\"] = fmt.Sprintf(\"%d\", m.Attachment.Size)\n\t\t\tdata[\"attachment_expires\"] = fmt.Sprintf(\"%d\", m.Attachment.Expires)\n\t\t\tdata[\"attachment_url\"] = m.Attachment.URL\n\t\t}\n\t\tif m.PollID != \"\" {\n\t\t\tdata[\"poll_id\"] = m.PollID\n\t\t}\n\t\tapnsConfig = createAPNSAlertConfig(m, data)\n\t}\n\tvar androidConfig *messaging.AndroidConfig\n\tif m.Priority >= 4 {\n\t\tandroidConfig = &messaging.AndroidConfig{\n\t\t\tPriority: \"high\",\n\t\t}\n\t}\n\treturn maybeTruncateFCMMessage(&messaging.Message{\n\t\tTopic:   m.Topic,\n\t\tData:    data,\n\t\tAndroid: androidConfig,\n\t\tAPNS:    apnsConfig,\n\t}), nil\n}\n\n// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.\n// The docs say the limit is 4000 characters, but during testing it wasn't quite clear\n// what fields matter; so we're just capping the serialized JSON to 4000 bytes.\nfunc maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {\n\ts, err := json.Marshal(m)\n\tif err != nil {\n\t\treturn m\n\t}\n\tif len(s) > fcmMessageLimit {\n\t\tover := len(s) - fcmMessageLimit + 16 // = len(\"truncated\":\"1\",), sigh ...\n\t\tmessage, ok := m.Data[\"message\"]\n\t\tif ok && len(message) > over {\n\t\t\tm.Data[\"truncated\"] = \"1\"\n\t\t\tm.Data[\"message\"] = message[:len(message)-over]\n\t\t}\n\t}\n\treturn m\n}\n\n// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).\n// We must set the Alert struct (\"alert\"), and we need to set MutableContent (\"mutable-content\"), so the Notification Service\n// Extension in iOS can modify the message.\nfunc createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.APNSConfig {\n\tapnsData := make(map[string]any)\n\tfor k, v := range data {\n\t\tapnsData[k] = v\n\t}\n\treturn &messaging.APNSConfig{\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tCustomData: apnsData,\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tMutableContent: true,\n\t\t\t\tAlert: &messaging.ApsAlert{\n\t\t\t\t\tTitle: m.Title,\n\t\t\t\t\tBody:  maybeTruncateAPNSBodyMessage(m.Message),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only\n// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll\n// topic, which triggers the iOS app to poll all topics for changes.\n//\n// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app\nfunc createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {\n\tapnsData := make(map[string]any)\n\tfor k, v := range data {\n\t\tapnsData[k] = v\n\t}\n\treturn &messaging.APNSConfig{\n\t\tHeaders: map[string]string{\n\t\t\t\"apns-push-type\": \"background\",\n\t\t\t\"apns-priority\":  \"5\",\n\t\t},\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tContentAvailable: true,\n\t\t\t},\n\t\t\tCustomData: apnsData,\n\t\t},\n\t}\n}\n\n// maybeTruncateAPNSBodyMessage truncates the body for APNS.\n//\n// The \"body\" of the push notification can contain the entire message, which would count doubly for the overall length\n// of the APNS payload. I set a limit of 100 characters before truncating the notification \"body\" with ellipsis.\n// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),\n// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.\nfunc maybeTruncateAPNSBodyMessage(s string) string {\n\tif len(s) >= fcmApnsBodyMessageLimit {\n\t\tover := len(s) - fcmApnsBodyMessageLimit + 3 // len(\"...\")\n\t\treturn s[:len(s)-over] + \"...\"\n\t}\n\treturn s\n}\n\n// toPollRequest converts a message to a poll request message.\n//\n// This empties all the fields that are not needed for a poll request and just sets the required fields,\n// most importantly, the PollID.\nfunc toPollRequest(m *model.Message) *model.Message {\n\tpr := model.NewPollRequestMessage(m.Topic, m.ID)\n\tpr.ID = m.ID\n\tpr.Time = m.Time\n\tpr.Priority = m.Priority // Keep priority\n\tpr.ContentType = m.ContentType\n\tpr.Encoding = m.Encoding\n\treturn pr\n}\n"
  },
  {
    "path": "server/server_firebase_dummy.go",
    "content": "//go:build nofirebase\n\npackage server\n\nimport (\n\t\"errors\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n)\n\nconst (\n\t// FirebaseAvailable is a constant used to indicate that Firebase support is available.\n\t// It can be disabled with the 'nofirebase' build tag.\n\tFirebaseAvailable = false\n)\n\nvar (\n\terrFirebaseNotAvailable      = errors.New(\"Firebase not available\")\n\terrFirebaseTemporarilyBanned = errors.New(\"visitor temporarily banned from using Firebase\")\n)\n\ntype firebaseClient struct {\n}\n\nfunc (c *firebaseClient) Send(v *visitor, m *model.Message) error {\n\treturn errFirebaseNotAvailable\n}\n\ntype firebaseSender interface {\n\tSend(m string) error\n}\n\nfunc newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {\n\treturn nil\n}\n\nfunc newFirebaseSender(credentialsFile string) (firebaseSender, error) {\n\treturn nil, errFirebaseNotAvailable\n}\n"
  },
  {
    "path": "server/server_firebase_test.go",
    "content": "//go:build !nofirebase\n\npackage server\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"firebase.google.com/go/v4/messaging\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype testAuther struct {\n\tAllow bool\n}\n\nvar _ user.Auther = (*testAuther)(nil)\n\nfunc (t testAuther) Authenticate(_, _ string) (*user.User, error) {\n\treturn nil, errors.New(\"not used\")\n}\n\nfunc (t testAuther) Authorize(_ *user.User, _ string, _ user.Permission) error {\n\tif t.Allow {\n\t\treturn nil\n\t}\n\treturn errors.New(\"unauthorized\")\n}\n\ntype testFirebaseSender struct {\n\tallowed  int\n\tmessages []*messaging.Message\n\tmu       sync.Mutex\n}\n\nfunc newTestFirebaseSender(allowed int) *testFirebaseSender {\n\treturn &testFirebaseSender{\n\t\tallowed:  allowed,\n\t\tmessages: make([]*messaging.Message, 0),\n\t}\n}\n\nfunc (s *testFirebaseSender) Send(m *messaging.Message) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif len(s.messages)+1 > s.allowed {\n\t\treturn errFirebaseQuotaExceeded\n\t}\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc (s *testFirebaseSender) Messages() []*messaging.Message {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn append(make([]*messaging.Message, 0), s.messages...)\n}\n\nfunc TestToFirebaseMessage_Keepalive(t *testing.T) {\n\tm := model.NewKeepaliveMessage(\"mytopic\")\n\tfbm, err := toFirebaseMessage(m, nil)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"mytopic\", fbm.Topic)\n\trequire.Nil(t, fbm.Android)\n\trequire.Equal(t, &messaging.APNSConfig{\n\t\tHeaders: map[string]string{\n\t\t\t\"apns-push-type\": \"background\",\n\t\t\t\"apns-priority\":  \"5\",\n\t\t},\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tContentAvailable: true,\n\t\t\t},\n\t\t\tCustomData: map[string]any{\n\t\t\t\t\"id\":    m.ID,\n\t\t\t\t\"time\":  fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\t\"event\": m.Event,\n\t\t\t\t\"topic\": m.Topic,\n\t\t\t},\n\t\t},\n\t}, fbm.APNS)\n\trequire.Equal(t, map[string]string{\n\t\t\"id\":    m.ID,\n\t\t\"time\":  fmt.Sprintf(\"%d\", m.Time),\n\t\t\"event\": m.Event,\n\t\t\"topic\": m.Topic,\n\t}, fbm.Data)\n}\n\nfunc TestToFirebaseMessage_Open(t *testing.T) {\n\tm := model.NewOpenMessage(\"mytopic\")\n\tfbm, err := toFirebaseMessage(m, nil)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"mytopic\", fbm.Topic)\n\trequire.Nil(t, fbm.Android)\n\trequire.Equal(t, &messaging.APNSConfig{\n\t\tHeaders: map[string]string{\n\t\t\t\"apns-push-type\": \"background\",\n\t\t\t\"apns-priority\":  \"5\",\n\t\t},\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tContentAvailable: true,\n\t\t\t},\n\t\t\tCustomData: map[string]any{\n\t\t\t\t\"id\":    m.ID,\n\t\t\t\t\"time\":  fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\t\"event\": m.Event,\n\t\t\t\t\"topic\": m.Topic,\n\t\t\t},\n\t\t},\n\t}, fbm.APNS)\n\trequire.Equal(t, map[string]string{\n\t\t\"id\":    m.ID,\n\t\t\"time\":  fmt.Sprintf(\"%d\", m.Time),\n\t\t\"event\": m.Event,\n\t\t\"topic\": m.Topic,\n\t}, fbm.Data)\n}\n\nfunc TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {\n\tm := model.NewDefaultMessage(\"mytopic\", \"this is a message\")\n\tm.Priority = 4\n\tm.Tags = []string{\"tag 1\", \"tag2\"}\n\tm.Click = \"https://google.com\"\n\tm.Icon = \"https://ntfy.sh/static/img/ntfy.png\"\n\tm.Title = \"some title\"\n\tm.Actions = []*model.Action{\n\t\t{\n\t\t\tID:     \"123\",\n\t\t\tAction: \"view\",\n\t\t\tLabel:  \"Open page\",\n\t\t\tClear:  true,\n\t\t\tURL:    \"https://ntfy.sh\",\n\t\t},\n\t\t{\n\t\t\tID:     \"456\",\n\t\t\tAction: \"http\",\n\t\t\tLabel:  \"Close door\",\n\t\t\tURL:    \"https://door.com/close\",\n\t\t\tMethod: \"PUT\",\n\t\t\tHeaders: map[string]string{\n\t\t\t\t\"really\": \"yes\",\n\t\t\t},\n\t\t},\n\t}\n\tm.Attachment = &model.Attachment{\n\t\tName:    \"some file.jpg\",\n\t\tType:    \"image/jpeg\",\n\t\tSize:    12345,\n\t\tExpires: 98765543,\n\t\tURL:     \"https://example.com/file.jpg\",\n\t}\n\tfbm, err := toFirebaseMessage(m, &testAuther{Allow: true})\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"mytopic\", fbm.Topic)\n\trequire.Equal(t, &messaging.AndroidConfig{\n\t\tPriority: \"high\",\n\t}, fbm.Android)\n\trequire.Equal(t, &messaging.APNSConfig{\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tMutableContent: true,\n\t\t\t\tAlert: &messaging.ApsAlert{\n\t\t\t\t\tTitle: \"some title\",\n\t\t\t\t\tBody:  \"this is a message\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tCustomData: map[string]any{\n\t\t\t\t\"id\":                 m.ID,\n\t\t\t\t\"time\":               fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\t\"event\":              \"message\",\n\t\t\t\t\"topic\":              \"mytopic\",\n\t\t\t\t\"sequence_id\":        \"\",\n\t\t\t\t\"priority\":           \"4\",\n\t\t\t\t\"tags\":               strings.Join(m.Tags, \",\"),\n\t\t\t\t\"click\":              \"https://google.com\",\n\t\t\t\t\"icon\":               \"https://ntfy.sh/static/img/ntfy.png\",\n\t\t\t\t\"title\":              \"some title\",\n\t\t\t\t\"message\":            \"this is a message\",\n\t\t\t\t\"actions\":            `[{\"id\":\"123\",\"action\":\"view\",\"label\":\"Open page\",\"clear\":true,\"url\":\"https://ntfy.sh\"},{\"id\":\"456\",\"action\":\"http\",\"label\":\"Close door\",\"clear\":false,\"url\":\"https://door.com/close\",\"method\":\"PUT\",\"headers\":{\"really\":\"yes\"}}]`,\n\t\t\t\t\"content_type\":       \"\",\n\t\t\t\t\"encoding\":           \"\",\n\t\t\t\t\"attachment_name\":    \"some file.jpg\",\n\t\t\t\t\"attachment_type\":    \"image/jpeg\",\n\t\t\t\t\"attachment_size\":    \"12345\",\n\t\t\t\t\"attachment_expires\": \"98765543\",\n\t\t\t\t\"attachment_url\":     \"https://example.com/file.jpg\",\n\t\t\t},\n\t\t},\n\t}, fbm.APNS)\n\trequire.Equal(t, map[string]string{\n\t\t\"id\":                 m.ID,\n\t\t\"time\":               fmt.Sprintf(\"%d\", m.Time),\n\t\t\"event\":              \"message\",\n\t\t\"topic\":              \"mytopic\",\n\t\t\"sequence_id\":        \"\",\n\t\t\"priority\":           \"4\",\n\t\t\"tags\":               strings.Join(m.Tags, \",\"),\n\t\t\"click\":              \"https://google.com\",\n\t\t\"icon\":               \"https://ntfy.sh/static/img/ntfy.png\",\n\t\t\"title\":              \"some title\",\n\t\t\"message\":            \"this is a message\",\n\t\t\"actions\":            `[{\"id\":\"123\",\"action\":\"view\",\"label\":\"Open page\",\"clear\":true,\"url\":\"https://ntfy.sh\"},{\"id\":\"456\",\"action\":\"http\",\"label\":\"Close door\",\"clear\":false,\"url\":\"https://door.com/close\",\"method\":\"PUT\",\"headers\":{\"really\":\"yes\"}}]`,\n\t\t\"content_type\":       \"\",\n\t\t\"encoding\":           \"\",\n\t\t\"attachment_name\":    \"some file.jpg\",\n\t\t\"attachment_type\":    \"image/jpeg\",\n\t\t\"attachment_size\":    \"12345\",\n\t\t\"attachment_expires\": \"98765543\",\n\t\t\"attachment_url\":     \"https://example.com/file.jpg\",\n\t}, fbm.Data)\n}\n\nfunc TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {\n\tm := model.NewDefaultMessage(\"mytopic\", \"this is a message\")\n\tm.Priority = 5\n\tfbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed!\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"mytopic\", fbm.Topic)\n\trequire.Equal(t, &messaging.AndroidConfig{\n\t\tPriority: \"high\",\n\t}, fbm.Android)\n\trequire.Equal(t, \"New message\", fbm.Data[\"message\"])\n\trequire.Equal(t, \"5\", fbm.Data[\"priority\"])\n\trequire.Equal(t, map[string]string{\n\t\t\"id\":           m.ID,\n\t\t\"time\":         fmt.Sprintf(\"%d\", m.Time),\n\t\t\"event\":        \"poll_request\",\n\t\t\"topic\":        \"mytopic\",\n\t\t\"sequence_id\":  \"\",\n\t\t\"message\":      \"New message\",\n\t\t\"title\":        \"\",\n\t\t\"tags\":         \"\",\n\t\t\"click\":        \"\",\n\t\t\"icon\":         \"\",\n\t\t\"priority\":     \"5\",\n\t\t\"encoding\":     \"\",\n\t\t\"content_type\": \"\",\n\t\t\"poll_id\":      m.ID,\n\t}, fbm.Data)\n\trequire.Equal(t, \"\", fbm.APNS.Payload.Aps.Alert.Title)\n\trequire.Equal(t, \"New message\", fbm.APNS.Payload.Aps.Alert.Body)\n}\n\nfunc TestToFirebaseMessage_PollRequest(t *testing.T) {\n\tm := model.NewPollRequestMessage(\"mytopic\", \"fOv6k1QbCzo6\")\n\tfbm, err := toFirebaseMessage(m, nil)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"mytopic\", fbm.Topic)\n\trequire.Nil(t, fbm.Android)\n\trequire.Equal(t, &messaging.APNSConfig{\n\t\tPayload: &messaging.APNSPayload{\n\t\t\tAps: &messaging.Aps{\n\t\t\t\tMutableContent: true,\n\t\t\t\tAlert: &messaging.ApsAlert{\n\t\t\t\t\tTitle: \"\",\n\t\t\t\t\tBody:  \"New message\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tCustomData: map[string]any{\n\t\t\t\t\"id\":      m.ID,\n\t\t\t\t\"time\":    fmt.Sprintf(\"%d\", m.Time),\n\t\t\t\t\"event\":   \"poll_request\",\n\t\t\t\t\"topic\":   \"mytopic\",\n\t\t\t\t\"message\": \"New message\",\n\t\t\t\t\"poll_id\": \"fOv6k1QbCzo6\",\n\t\t\t},\n\t\t},\n\t}, fbm.APNS)\n\trequire.Equal(t, map[string]string{\n\t\t\"id\":      m.ID,\n\t\t\"time\":    fmt.Sprintf(\"%d\", m.Time),\n\t\t\"event\":   \"poll_request\",\n\t\t\"topic\":   \"mytopic\",\n\t\t\"message\": \"New message\",\n\t\t\"poll_id\": \"fOv6k1QbCzo6\",\n\t}, fbm.Data)\n}\n\nfunc TestMaybeTruncateFCMMessage(t *testing.T) {\n\torigMessage := strings.Repeat(\"this is a long string\", 300)\n\torigFCMMessage := &messaging.Message{\n\t\tTopic: \"mytopic\",\n\t\tData: map[string]string{\n\t\t\t\"id\":       \"abcdefg\",\n\t\t\t\"time\":     \"1641324761\",\n\t\t\t\"event\":    \"message\",\n\t\t\t\"topic\":    \"mytopic\",\n\t\t\t\"priority\": \"0\",\n\t\t\t\"tags\":     \"\",\n\t\t\t\"title\":    \"\",\n\t\t\t\"message\":  origMessage,\n\t\t},\n\t\tAndroid: &messaging.AndroidConfig{\n\t\t\tPriority: \"high\",\n\t\t},\n\t}\n\torigMessageLength := len(origFCMMessage.Data[\"message\"])\n\tserializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)\n\trequire.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition\n\n\ttruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)\n\ttruncatedMessageLength := len(truncatedFCMMessage.Data[\"message\"])\n\tserializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)\n\trequire.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))\n\trequire.Equal(t, \"1\", truncatedFCMMessage.Data[\"truncated\"])\n\trequire.NotEqual(t, origMessageLength, truncatedMessageLength)\n}\n\nfunc TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {\n\torigMessage := \"not really a long string\"\n\torigFCMMessage := &messaging.Message{\n\t\tTopic: \"mytopic\",\n\t\tData: map[string]string{\n\t\t\t\"id\":       \"abcdefg\",\n\t\t\t\"time\":     \"1641324761\",\n\t\t\t\"event\":    \"message\",\n\t\t\t\"topic\":    \"mytopic\",\n\t\t\t\"priority\": \"0\",\n\t\t\t\"tags\":     \"\",\n\t\t\t\"title\":    \"\",\n\t\t\t\"message\":  origMessage,\n\t\t},\n\t}\n\torigMessageLength := len(origFCMMessage.Data[\"message\"])\n\tserializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)\n\trequire.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition\n\n\tnotTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)\n\tnotTruncatedMessageLength := len(notTruncatedFCMMessage.Data[\"message\"])\n\tserializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)\n\trequire.Equal(t, origMessageLength, notTruncatedMessageLength)\n\trequire.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))\n\trequire.Equal(t, \"\", notTruncatedFCMMessage.Data[\"truncated\"])\n}\n\nfunc TestToFirebaseSender_Abuse(t *testing.T) {\n\tsender := &testFirebaseSender{allowed: 2}\n\tclient := newFirebaseClient(sender, &testAuther{})\n\tvisitor := newVisitor(newTestConfig(t, \"\"), newMemTestCache(t), nil, netip.MustParseAddr(\"1.2.3.4\"), nil)\n\n\trequire.Nil(t, client.Send(visitor, &model.Message{Topic: \"mytopic\"}))\n\trequire.Equal(t, 1, len(sender.Messages()))\n\n\trequire.Nil(t, client.Send(visitor, &model.Message{Topic: \"mytopic\"}))\n\trequire.Equal(t, 2, len(sender.Messages()))\n\n\trequire.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &model.Message{Topic: \"mytopic\"}))\n\trequire.Equal(t, 2, len(sender.Messages()))\n\n\tsender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working\n\trequire.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &model.Message{Topic: \"mytopic\"}))\n\trequire.Equal(t, 0, len(sender.Messages()))\n}\n"
  },
  {
    "path": "server/server_manager.go",
    "content": "package server\n\nimport (\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"strings\"\n)\n\nfunc (s *Server) execManager() {\n\t// WARNING: Make sure to only selectively lock with the mutex, and be aware that this\n\t//          there is no mutex for the entire function.\n\n\t// Prune all the things\n\ts.pruneVisitors()\n\ts.pruneTokens()\n\ts.pruneAttachments()\n\ts.pruneMessages()\n\ts.pruneAndNotifyWebPushSubscriptions()\n\n\t// Message count\n\tmessagesCached, err := s.messageCache.MessagesCount()\n\tif err != nil {\n\t\tlog.Tag(tagManager).Err(err).Warn(\"Cannot get messages count\")\n\t}\n\n\t// Remove subscriptions without subscribers\n\tvar emptyTopics, subscribers int\n\tlog.\n\t\tTag(tagManager).\n\t\tTiming(func() {\n\t\t\ts.mu.Lock()\n\t\t\tdefer s.mu.Unlock()\n\t\t\tfor _, t := range s.topics {\n\t\t\t\tsubs, lastAccess := t.Stats()\n\t\t\t\tev := log.Tag(tagManager).With(t)\n\t\t\t\tif t.Stale() {\n\t\t\t\t\tif ev.IsTrace() {\n\t\t\t\t\t\tev.Trace(\"- topic %s: Deleting stale topic (%d subscribers, accessed %s)\", t.ID, subs, util.FormatTime(lastAccess))\n\t\t\t\t\t}\n\t\t\t\t\temptyTopics++\n\t\t\t\t\tdelete(s.topics, t.ID)\n\t\t\t\t} else {\n\t\t\t\t\tif ev.IsTrace() {\n\t\t\t\t\t\tev.Trace(\"- topic %s: %d subscribers, accessed %s\", t.ID, subs, util.FormatTime(lastAccess))\n\t\t\t\t\t}\n\t\t\t\t\tsubscribers += subs\n\t\t\t\t}\n\t\t\t}\n\t\t}).\n\t\tDebug(\"Removed %d empty topic(s)\", emptyTopics)\n\n\t// Mail stats\n\tvar receivedMailTotal, receivedMailSuccess, receivedMailFailure int64\n\tif s.smtpServerBackend != nil {\n\t\treceivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()\n\t}\n\tvar sentMailTotal, sentMailSuccess, sentMailFailure int64\n\tif s.smtpSender != nil {\n\t\tsentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()\n\t}\n\n\t// Users\n\tvar usersCount int64\n\tif s.userManager != nil {\n\t\tusersCount, err = s.userManager.UsersCount()\n\t\tif err != nil {\n\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error counting users\")\n\t\t}\n\t}\n\n\t// Print stats\n\ts.mu.RLock()\n\tmessagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)\n\ts.mu.RUnlock()\n\n\t// Update stats\n\ts.updateAndWriteStats(messagesCount)\n\n\t// Log stats\n\tlog.\n\t\tTag(tagManager).\n\t\tFields(log.Context{\n\t\t\t\"messages_published\":      messagesCount,\n\t\t\t\"messages_cached\":         messagesCached,\n\t\t\t\"topics_active\":           topicsCount,\n\t\t\t\"subscribers\":             subscribers,\n\t\t\t\"visitors\":                visitorsCount,\n\t\t\t\"users\":                   usersCount,\n\t\t\t\"emails_received\":         receivedMailTotal,\n\t\t\t\"emails_received_success\": receivedMailSuccess,\n\t\t\t\"emails_received_failure\": receivedMailFailure,\n\t\t\t\"emails_sent\":             sentMailTotal,\n\t\t\t\"emails_sent_success\":     sentMailSuccess,\n\t\t\t\"emails_sent_failure\":     sentMailFailure,\n\t\t}).\n\t\tInfo(\"Server stats\")\n\tmset(metricMessagesCached, messagesCached)\n\tmset(metricVisitors, visitorsCount)\n\tmset(metricUsers, usersCount)\n\tmset(metricSubscribers, subscribers)\n\tmset(metricTopics, topicsCount)\n}\n\nfunc (s *Server) pruneVisitors() {\n\tstaleVisitors := 0\n\tlog.\n\t\tTag(tagManager).\n\t\tTiming(func() {\n\t\t\ts.mu.Lock()\n\t\t\tdefer s.mu.Unlock()\n\t\t\tfor ip, v := range s.visitors {\n\t\t\t\tif v.Stale() {\n\t\t\t\t\tlog.Tag(tagManager).With(v).Trace(\"Deleting stale visitor\")\n\t\t\t\t\tdelete(s.visitors, ip)\n\t\t\t\t\tstaleVisitors++\n\t\t\t\t}\n\t\t\t}\n\t\t}).\n\t\tField(\"stale_visitors\", staleVisitors).\n\t\tDebug(\"Deleted %d stale visitor(s)\", staleVisitors)\n}\n\nfunc (s *Server) pruneTokens() {\n\tif s.userManager != nil {\n\t\tlog.\n\t\t\tTag(tagManager).\n\t\t\tTiming(func() {\n\t\t\t\tif err := s.userManager.RemoveExpiredTokens(); err != nil {\n\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error expiring user tokens\")\n\t\t\t\t}\n\t\t\t\tif err := s.userManager.RemoveDeletedUsers(); err != nil {\n\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error deleting soft-deleted users\")\n\t\t\t\t}\n\t\t\t}).\n\t\t\tDebug(\"Removed expired tokens and users\")\n\t}\n}\n\nfunc (s *Server) pruneAttachments() {\n\tif s.fileCache == nil {\n\t\treturn\n\t}\n\tlog.\n\t\tTag(tagManager).\n\t\tTiming(func() {\n\t\t\tids, err := s.messageCache.AttachmentsExpired()\n\t\t\tif err != nil {\n\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error retrieving expired attachments\")\n\t\t\t} else if len(ids) > 0 {\n\t\t\t\tif log.Tag(tagManager).IsDebug() {\n\t\t\t\t\tlog.Tag(tagManager).Debug(\"Deleting attachments %s\", strings.Join(ids, \", \"))\n\t\t\t\t}\n\t\t\t\tif err := s.fileCache.Remove(ids...); err != nil {\n\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error deleting attachments\")\n\t\t\t\t}\n\t\t\t\tif err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {\n\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error marking attachments deleted\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Tag(tagManager).Debug(\"No expired attachments to delete\")\n\t\t\t}\n\t\t}).\n\t\tDebug(\"Deleted expired attachments\")\n}\n\nfunc (s *Server) pruneMessages() {\n\tlog.\n\t\tTag(tagManager).\n\t\tTiming(func() {\n\t\t\texpiredMessageIDs, err := s.messageCache.MessagesExpired()\n\t\t\tif err != nil {\n\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error retrieving expired messages\")\n\t\t\t} else if len(expiredMessageIDs) > 0 {\n\t\t\t\tif s.fileCache != nil {\n\t\t\t\t\tif err := s.fileCache.Remove(expiredMessageIDs...); err != nil {\n\t\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error deleting attachments for expired messages\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {\n\t\t\t\t\tlog.Tag(tagManager).Err(err).Warn(\"Error marking attachments deleted\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Tag(tagManager).Debug(\"No expired messages to delete\")\n\t\t\t}\n\t\t}).\n\t\tDebug(\"Pruned messages\")\n}\n"
  },
  {
    "path": "server/server_manager_test.go",
    "content": "package server\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"testing\"\n)\n\nfunc TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// Tests that the manager runs without attachment-cache-dir set, see #617\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentCacheDir = \"\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Publish a message\n\t\trr := request(t, s, \"POST\", \"/mytopic\", \"hi\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tm := toMessage(t, rr.Body.String())\n\n\t\t// Expire message\n\t\trequire.Nil(t, s.messageCache.ExpireMessages(\"mytopic\"))\n\n\t\t// Does not panic\n\t\ts.pruneMessages()\n\n\t\t// Actually deleted\n\t\t_, err := s.messageCache.Message(m.ID)\n\t\trequire.Equal(t, model.ErrMessageNotFound, err)\n\t})\n}\n"
  },
  {
    "path": "server/server_matrix.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Matrix Push Gateway / UnifiedPush / ntfy integration:\n//\n// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/),\n// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/).\n//\n// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's\n// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the\n// ntfy Android app.\n//\n//                                    +--------------------+  +-------------------+\n//                  Matrix HTTP      |                    |  |                   |\n//             Notification Protocol |   App Developer    |  |   Device Vendor   |\n//                                   |                    |  |                   |\n//           +-------------------+   | +----------------+ |  | +---------------+ |\n//           |                   |   | |                | |  | |               | |\n//           | Matrix homeserver +----->  Push Gateway  +------> Push Provider | |\n//           |                   |   | |                | |  | |               | |\n//           +-^-----------------+   | +----------------+ |  | +----+----------+ |\n//             |                     |                    |  |      |            |\n//    Matrix   |                     |                    |  |      |            |\n// Client/Server API  +              |                    |  |      |            |\n//             |      |              +--------------------+  +-------------------+\n//             |   +--+-+                                           |\n//             |   |    <-------------------------------------------+\n//             +---+    |\n//                 |    |          Provider Push Protocol\n//                 +----+\n//\n//         Mobile Device or Client\n//\n\n// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per\n// this spec: https://spec.matrix.org/v1.2/push-gateway-api/).\n//\n// From the message, we only require the \"pushkey\", as it represents our target topic URL.\n// A message may look like this (excerpt):\n//\n//\t{\n//\t  \"notification\": {\n//\t    \"devices\": [\n//\t       {\n//\t          \"pushkey\": \"https://ntfy.sh/upDAHJKFFDFD?up=1\",\n//\t          ...\n//\t       }\n//\t    ]\n//\t  }\n//\t}\ntype matrixRequest struct {\n\tNotification *struct {\n\t\tDevices []*struct {\n\t\t\tPushKey string `json:\"pushkey\"`\n\t\t} `json:\"devices\"`\n\t} `json:\"notification\"`\n}\n\n// matrixResponse represents the response to a Matrix push gateway message, as defined\n// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/).\ntype matrixResponse struct {\n\tRejected []string `json:\"rejected\"`\n}\n\nconst (\n\t// matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response\n\t// will return an HTTP 200 with the push key (i.e. \"rejected\":[\"<pushkey>\"]}), if no rate visitor has been set on\n\t// the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending\n\t// messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/\n\tmatrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour\n)\n\n// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages\n//\n// If the push key is set, the app server will remove it and will never send messages using the same\n// push key again, until the user repairs it.\ntype errMatrixPushkeyRejected struct {\n\trejectedPushKey   string\n\tconfiguredBaseURL string\n}\n\nfunc (e errMatrixPushkeyRejected) Error() string {\n\treturn fmt.Sprintf(\"push key must be prefixed with base URL, received push key: %s, configured base URL: %s\", e.rejectedPushKey, e.configuredBaseURL)\n}\n\n// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the \"pushkey\", and creates a new\n// HTTP request that looks like a normal ntfy request from it.\n//\n// It basically converts a Matrix push gatewqy request:\n//\n//\tPOST /_matrix/push/v1/notify HTTP/1.1\n//\t{ \"notification\": { \"devices\": [ { \"pushkey\": \"https://ntfy.sh/upDAHJKFFDFD?up=1\", ... } ] } }\n//\n// to a ntfy request, looking like this:\n//\n//\tPOST /upDAHJKFFDFD?up=1 HTTP/1.1\n//\t{ \"notification\": { \"devices\": [ { \"pushkey\": \"https://ntfy.sh/upDAHJKFFDFD?up=1\", ... } ] } }\nfunc newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) {\n\tif baseURL == \"\" {\n\t\treturn nil, errHTTPInternalErrorMissingBaseURL\n\t}\n\tbody, err := util.Peek(r.Body, messageLimit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer r.Body.Close()\n\tif body.LimitReached {\n\t\treturn nil, errHTTPEntityTooLargeMatrixRequest\n\t}\n\tvar m matrixRequest\n\tif err := json.Unmarshal(body.PeekedBytes, &m); err != nil {\n\t\treturn nil, errHTTPBadRequestMatrixMessageInvalid\n\t} else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == \"\" {\n\t\treturn nil, errHTTPBadRequestMatrixMessageInvalid\n\t}\n\tpushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316\n\tif !strings.HasPrefix(pushKey, baseURL+\"/\") {\n\t\treturn nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL}\n\t}\n\tnewRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnewRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted\n\tif r.Header.Get(\"X-Forwarded-For\") != \"\" {\n\t\tnewRequest.Header.Set(\"X-Forwarded-For\", r.Header.Get(\"X-Forwarded-For\"))\n\t}\n\tnewRequest = withContext(newRequest, map[contextKey]any{\n\t\tcontextMatrixPushKey: pushKey,\n\t})\n\treturn newRequest, nil\n}\n\n// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter,\n// as per the spec (https://unifiedpush.org/developers/gateway/).\nfunc writeMatrixDiscoveryResponse(w http.ResponseWriter) error {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_, err := io.WriteString(w, `{\"unifiedpush\":{\"gateway\":\"matrix\"}}`+\"\\n\")\n\treturn err\n}\n\n// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter\nfunc writeMatrixSuccess(w http.ResponseWriter) error {\n\treturn writeMatrixResponse(w, \"\")\n}\n\n// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in\n// the spec (https://spec.matrix.org/v1.2/push-gateway-api/)\nfunc writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {\n\trejected := make([]string, 0)\n\tif rejectedPushKey != \"\" {\n\t\trejected = append(rejected, rejectedPushKey)\n\t}\n\tresponse := &matrixResponse{\n\t\tRejected: rejected,\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(response); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/server_matrix_test.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {\n\tbaseURL := \"https://ntfy.sh\"\n\tmaxLength := 4096\n\tbody := `{\"notification\":{\"content\":{\"body\":\"I'm floating in a most peculiar way.\",\"msgtype\":\"m.text\"},\"counts\":{\"missed_calls\":1,\"unread\":2},\"devices\":[{\"app_id\":\"org.matrix.matrixConsole.ios\",\"data\":{},\"pushkey\":\"https://ntfy.sh/upABCDEFGHI?up=1\",\"pushkey_ts\":12345678,\"tweaks\":{\"sound\":\"bing\"}}],\"event_id\":\"$3957tyerfgewrf384\",\"prio\":\"high\",\"room_alias\":\"#exampleroom:matrix.org\",\"room_id\":\"!slw48wfj34rtnrf:example.com\",\"room_name\":\"Mission Control\",\"sender\":\"@exampleuser:matrix.org\",\"sender_display_name\":\"Major Tom\",\"type\":\"m.room.message\"}}`\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.example.com/_matrix/push/v1/notify\", strings.NewReader(body))\n\tnewRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"POST\", newRequest.Method)\n\trequire.Equal(t, \"https://ntfy.sh/upABCDEFGHI?up=1\", newRequest.URL.String())\n\trequire.Equal(t, body, readAll(t, newRequest.Body))\n}\n\nfunc TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {\n\tbaseURL := \"https://ntfy.sh\"\n\tmaxLength := 10 // Small\n\tbody := `{\"notification\":{\"content\":{\"body\":\"I'm floating in a most peculiar way.\",\"msgtype\":\"m.text\"},\"counts\":{\"missed_calls\":1,\"unread\":2},\"devices\":[{\"app_id\":\"org.matrix.matrixConsole.ios\",\"data\":{},\"pushkey\":\"https://ntfy.sh/upABCDEFGHI?up=1\",\"pushkey_ts\":12345678,\"tweaks\":{\"sound\":\"bing\"}}],\"event_id\":\"$3957tyerfgewrf384\",\"prio\":\"high\",\"room_alias\":\"#exampleroom:matrix.org\",\"room_id\":\"!slw48wfj34rtnrf:example.com\",\"room_name\":\"Mission Control\",\"sender\":\"@exampleuser:matrix.org\",\"sender_display_name\":\"Major Tom\",\"type\":\"m.room.message\"}}`\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.example.com/_matrix/push/v1/notify\", strings.NewReader(body))\n\t_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)\n\trequire.Equal(t, errHTTPEntityTooLargeMatrixRequest, err)\n}\n\nfunc TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {\n\tbaseURL := \"https://ntfy.sh\"\n\tmaxLength := 4096\n\tbody := `this is not json`\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.example.com/_matrix/push/v1/notify\", strings.NewReader(body))\n\t_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)\n\trequire.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)\n}\n\nfunc TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) {\n\tbaseURL := \"https://ntfy.sh\"\n\tmaxLength := 4096\n\tbody := `{\"message\":\"this is not a matrix message, but valid json\"}`\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.example.com/_matrix/push/v1/notify\", strings.NewReader(body))\n\t_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)\n\trequire.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)\n}\n\nfunc TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {\n\tbaseURL := \"https://ntfy.sh\" // Mismatch!\n\tmaxLength := 4096\n\tbody := `{\"notification\":{\"content\":{\"body\":\"I'm floating in a most peculiar way.\",\"msgtype\":\"m.text\"},\"counts\":{\"missed_calls\":1,\"unread\":2},\"devices\":[{\"app_id\":\"org.matrix.matrixConsole.ios\",\"data\":{},\"pushkey\":\"https://ntfy.example.com/upABCDEFGHI?up=1\",\"pushkey_ts\":12345678,\"tweaks\":{\"sound\":\"bing\"}}],\"event_id\":\"$3957tyerfgewrf384\",\"prio\":\"high\",\"room_alias\":\"#exampleroom:matrix.org\",\"room_id\":\"!slw48wfj34rtnrf:example.com\",\"room_name\":\"Mission Control\",\"sender\":\"@exampleuser:matrix.org\",\"sender_display_name\":\"Major Tom\",\"type\":\"m.room.message\"}}`\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.example.com/_matrix/push/v1/notify\", strings.NewReader(body))\n\t_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)\n\tmatrixErr, ok := err.(*errMatrixPushkeyRejected)\n\trequire.True(t, ok)\n\trequire.Equal(t, \"push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh\", matrixErr.Error())\n\trequire.Equal(t, \"https://ntfy.example.com/upABCDEFGHI?up=1\", matrixErr.rejectedPushKey)\n}\n\nfunc TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {\n\tw := httptest.NewRecorder()\n\trequire.Nil(t, writeMatrixDiscoveryResponse(w))\n\trequire.Equal(t, 200, w.Result().StatusCode)\n\trequire.Equal(t, `{\"unifiedpush\":{\"gateway\":\"matrix\"}}`+\"\\n\", w.Body.String())\n}\n\nfunc TestMatrix_WriteMatrixError(t *testing.T) {\n\tw := httptest.NewRecorder()\n\trequire.Nil(t, writeMatrixResponse(w, \"https://ntfy.example.com/upABCDEFGHI?up=1\"))\n\trequire.Equal(t, 200, w.Result().StatusCode)\n\trequire.Equal(t, `{\"rejected\":[\"https://ntfy.example.com/upABCDEFGHI?up=1\"]}`+\"\\n\", w.Body.String())\n}\n\nfunc TestMatrix_WriteMatrixSuccess(t *testing.T) {\n\tw := httptest.NewRecorder()\n\trequire.Nil(t, writeMatrixSuccess(w))\n\trequire.Equal(t, 200, w.Result().StatusCode)\n\trequire.Equal(t, `{\"rejected\":[]}`+\"\\n\", w.Body.String())\n}\n"
  },
  {
    "path": "server/server_metrics.go",
    "content": "package server\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\nvar (\n\tmetricMessagesPublishedSuccess     prometheus.Counter\n\tmetricMessagesPublishedFailure     prometheus.Counter\n\tmetricMessagesCached               prometheus.Gauge\n\tmetricMessagePublishDurationMillis prometheus.Gauge\n\tmetricFirebasePublishedSuccess     prometheus.Counter\n\tmetricFirebasePublishedFailure     prometheus.Counter\n\tmetricEmailsPublishedSuccess       prometheus.Counter\n\tmetricEmailsPublishedFailure       prometheus.Counter\n\tmetricEmailsReceivedSuccess        prometheus.Counter\n\tmetricEmailsReceivedFailure        prometheus.Counter\n\tmetricCallsMadeSuccess             prometheus.Counter\n\tmetricCallsMadeFailure             prometheus.Counter\n\tmetricUnifiedPushPublishedSuccess  prometheus.Counter\n\tmetricMatrixPublishedSuccess       prometheus.Counter\n\tmetricMatrixPublishedFailure       prometheus.Counter\n\tmetricAttachmentsTotalSize         prometheus.Gauge\n\tmetricVisitors                     prometheus.Gauge\n\tmetricSubscribers                  prometheus.Gauge\n\tmetricTopics                       prometheus.Gauge\n\tmetricUsers                        prometheus.Gauge\n\tmetricHTTPRequests                 *prometheus.CounterVec\n)\n\nfunc initMetrics() {\n\tmetricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_messages_published_success\",\n\t})\n\tmetricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_messages_published_failure\",\n\t})\n\tmetricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_messages_cached_total\",\n\t})\n\tmetricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_message_publish_duration_ms\",\n\t})\n\tmetricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_firebase_published_success\",\n\t})\n\tmetricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_firebase_published_failure\",\n\t})\n\tmetricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_emails_sent_success\",\n\t})\n\tmetricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_emails_sent_failure\",\n\t})\n\tmetricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_emails_received_success\",\n\t})\n\tmetricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_emails_received_failure\",\n\t})\n\tmetricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_calls_made_success\",\n\t})\n\tmetricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_calls_made_failure\",\n\t})\n\tmetricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_unifiedpush_published_success\",\n\t})\n\tmetricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_matrix_published_success\",\n\t})\n\tmetricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"ntfy_matrix_published_failure\",\n\t})\n\tmetricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_attachments_total_size\",\n\t})\n\tmetricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_visitors_total\",\n\t})\n\tmetricUsers = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_users_total\",\n\t})\n\tmetricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_subscribers_total\",\n\t})\n\tmetricTopics = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"ntfy_topics_total\",\n\t})\n\tmetricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"ntfy_http_requests_total\",\n\t}, []string{\"http_code\", \"ntfy_code\", \"http_method\"})\n\tprometheus.MustRegister(\n\t\tmetricMessagesPublishedSuccess,\n\t\tmetricMessagesPublishedFailure,\n\t\tmetricMessagesCached,\n\t\tmetricMessagePublishDurationMillis,\n\t\tmetricFirebasePublishedSuccess,\n\t\tmetricFirebasePublishedFailure,\n\t\tmetricEmailsPublishedSuccess,\n\t\tmetricEmailsPublishedFailure,\n\t\tmetricEmailsReceivedSuccess,\n\t\tmetricEmailsReceivedFailure,\n\t\tmetricCallsMadeSuccess,\n\t\tmetricCallsMadeFailure,\n\t\tmetricUnifiedPushPublishedSuccess,\n\t\tmetricMatrixPublishedSuccess,\n\t\tmetricMatrixPublishedFailure,\n\t\tmetricAttachmentsTotalSize,\n\t\tmetricVisitors,\n\t\tmetricUsers,\n\t\tmetricSubscribers,\n\t\tmetricTopics,\n\t\tmetricHTTPRequests,\n\t)\n}\n\n// minc increments a prometheus.Counter if it is non-nil\nfunc minc(counter prometheus.Counter) {\n\tif counter != nil {\n\t\tcounter.Inc()\n\t}\n}\n\n// mset sets a prometheus.Gauge if it is non-nil\nfunc mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {\n\tif gauge != nil {\n\t\tgauge.Set(float64(value))\n\t}\n}\n"
  },
  {
    "path": "server/server_middleware.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\n\t\"heckel.io/ntfy/v2/util\"\n)\n\ntype contextKey int\n\nconst (\n\tcontextRateVisitor contextKey = iota + 2586\n\tcontextTopic\n\tcontextMatrixPushKey\n)\n\nfunc (s *Server) limitRequests(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {\n\t\t\treturn next(w, r, v)\n\t\t} else if !v.RequestAllowed() {\n\t\t\treturn errHTTPTooManyRequestsLimitRequests\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\n// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context\nfunc (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tt, err := s.topicFromPath(r.URL.Path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tvrate := v\n\t\tif rateVisitor := t.RateVisitor(); rateVisitor != nil {\n\t\t\tvrate = rateVisitor\n\t\t}\n\t\tr = withContext(r, map[contextKey]any{\n\t\t\tcontextRateVisitor: vrate,\n\t\t\tcontextTopic:       t,\n\t\t})\n\t\tif util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {\n\t\t\treturn next(w, r, v)\n\t\t} else if !vrate.RequestAllowed() {\n\t\t\treturn errHTTPTooManyRequestsLimitRequests\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensureWebEnabled(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.config.WebRoot == \"\" {\n\t\t\treturn errHTTPNotFound\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.config.WebRoot == \"\" || s.config.WebPushPublicKey == \"\" {\n\t\t\treturn errHTTPNotFound\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensureUserManager(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.userManager == nil {\n\t\t\treturn errHTTPNotFound\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensureUser(next handleFunc) handleFunc {\n\treturn s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif v.User() == nil {\n\t\t\treturn errHTTPUnauthorized\n\t\t}\n\t\treturn next(w, r, v)\n\t})\n}\n\nfunc (s *Server) ensureAdmin(next handleFunc) handleFunc {\n\treturn s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif !v.User().IsAdmin() {\n\t\t\treturn errHTTPUnauthorized\n\t\t}\n\t\treturn next(w, r, v)\n\t})\n}\n\nfunc (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.config.TwilioAccount == \"\" || s.userManager == nil {\n\t\t\treturn errHTTPNotFound\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif s.config.StripeSecretKey == \"\" || s.stripe == nil {\n\t\t\treturn errHTTPNotFound\n\t\t}\n\t\treturn next(w, r, v)\n\t}\n}\n\nfunc (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {\n\treturn s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\tif v.User().Billing.StripeCustomerID == \"\" {\n\t\t\treturn errHTTPBadRequestNotAPaidUser\n\t\t}\n\t\treturn next(w, r, v)\n\t})\n}\n\nfunc (s *Server) withAccountSync(next handleFunc) handleFunc {\n\treturn func(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t\terr := next(w, r, v)\n\t\tif err == nil {\n\t\t\ts.publishSyncEventAsync(v)\n\t\t}\n\t\treturn err\n\t}\n}\n"
  },
  {
    "path": "server/server_payments.go",
    "content": "//go:build !nopayments\n\npackage server\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/stripe/stripe-go/v74\"\n\tportalsession \"github.com/stripe/stripe-go/v74/billingportal/session\"\n\t\"github.com/stripe/stripe-go/v74/checkout/session\"\n\t\"github.com/stripe/stripe-go/v74/customer\"\n\t\"github.com/stripe/stripe-go/v74/price\"\n\t\"github.com/stripe/stripe-go/v74/subscription\"\n\t\"github.com/stripe/stripe-go/v74/webhook\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/payments\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"time\"\n)\n\n// Payments in ntfy are done via Stripe.\n//\n// Pretty much all payments-related things are in this file. The following processes\n// handle payments:\n//\n// - Checkout:\n//      Creating a Stripe customer and subscription via the Checkout flow. This flow is only used if the\n//      ntfy user is not already a Stripe customer. This requires redirecting to the Stripe checkout page.\n//      It is implemented in handleAccountBillingSubscriptionCreate and the success callback\n//      handleAccountBillingSubscriptionCreateSuccess.\n// - Update subscription:\n//      Switching between Stripe subscriptions (upgrade/downgrade) is handled via\n//      handleAccountBillingSubscriptionUpdate. This also handles proration.\n// - Cancel subscription (at period end):\n//      Users can cancel the Stripe subscription via the web app at the end of the billing period. This\n//      simply updates the subscription and Stripe will cancel it. Users cannot immediately cancel the\n//      subscription.\n// - Webhooks:\n//      Whenever a subscription changes (updated, deleted), Stripe sends us a request via a webhook.\n//      This is used to keep the local user database fields up to date. Stripe is the source of truth.\n//      What Stripe says is mirrored and not questioned.\n\nvar (\n\terrNotAPaidTier                 = errors.New(\"tier does not have billing price identifier\")\n\terrMultipleBillingSubscriptions = errors.New(\"cannot have multiple billing subscriptions\")\n\terrNoBillingSubscription        = errors.New(\"user does not have an active billing subscription\")\n)\n\nvar (\n\tretryUserDelays = []time.Duration{3 * time.Second, 5 * time.Second, 7 * time.Second}\n)\n\n// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog\n// in the UI. Note that this endpoint does NOT have a user context (no u!).\nfunc (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\ttiers, err := s.userManager.Tiers()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfreeTier := configBasedVisitorLimits(s.config)\n\tresponse := []*apiAccountBillingTier{\n\t\t{\n\t\t\t// This is a bit of a hack: This is the \"Free\" tier. It has no tier code, name or price.\n\t\t\tLimits: &apiAccountLimits{\n\t\t\t\tBasis:                    string(visitorLimitBasisIP),\n\t\t\t\tMessages:                 freeTier.MessageLimit,\n\t\t\t\tMessagesExpiryDuration:   int64(freeTier.MessageExpiryDuration.Seconds()),\n\t\t\t\tEmails:                   freeTier.EmailLimit,\n\t\t\t\tCalls:                    freeTier.CallLimit,\n\t\t\t\tReservations:             freeTier.ReservationsLimit,\n\t\t\t\tAttachmentTotalSize:      freeTier.AttachmentTotalSizeLimit,\n\t\t\t\tAttachmentFileSize:       freeTier.AttachmentFileSizeLimit,\n\t\t\t\tAttachmentExpiryDuration: int64(freeTier.AttachmentExpiryDuration.Seconds()),\n\t\t\t},\n\t\t},\n\t}\n\tprices, err := s.priceCache.Value()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, tier := range tiers {\n\t\tpriceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID]\n\t\tif priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices!\n\t\t\tcontinue\n\t\t}\n\t\tresponse = append(response, &apiAccountBillingTier{\n\t\t\tCode: tier.Code,\n\t\t\tName: tier.Name,\n\t\t\tPrices: &apiAccountBillingPrices{\n\t\t\t\tMonth: priceMonth,\n\t\t\t\tYear:  priceYear,\n\t\t\t},\n\t\t\tLimits: &apiAccountLimits{\n\t\t\t\tBasis:                    string(visitorLimitBasisTier),\n\t\t\t\tMessages:                 tier.MessageLimit,\n\t\t\t\tMessagesExpiryDuration:   int64(tier.MessageExpiryDuration.Seconds()),\n\t\t\t\tEmails:                   tier.EmailLimit,\n\t\t\t\tCalls:                    tier.CallLimit,\n\t\t\t\tReservations:             tier.ReservationLimit,\n\t\t\t\tAttachmentTotalSize:      tier.AttachmentTotalSizeLimit,\n\t\t\t\tAttachmentFileSize:       tier.AttachmentFileSizeLimit,\n\t\t\t\tAttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),\n\t\t\t},\n\t\t})\n\t}\n\treturn s.writeJSON(w, response)\n}\n\n// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier\n// will be updated by a subsequent webhook from Stripe, once the subscription becomes active.\nfunc (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\tif u.Billing.StripeSubscriptionID != \"\" {\n\t\treturn errHTTPBadRequestBillingSubscriptionExists\n\t}\n\treq, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttier, err := s.userManager.Tier(req.Tier)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar priceID string\n\tif req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != \"\" {\n\t\tpriceID = tier.StripeMonthlyPriceID\n\t} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != \"\" {\n\t\tpriceID = tier.StripeYearlyPriceID\n\t} else {\n\t\treturn errNotAPaidTier\n\t}\n\tlogvr(v, r).\n\t\tWith(tier).\n\t\tFields(log.Context{\n\t\t\t\"stripe_price_id\":              priceID,\n\t\t\t\"stripe_subscription_interval\": req.Interval,\n\t\t}).\n\t\tTag(tagStripe).\n\t\tInfo(\"Creating Stripe checkout flow\")\n\tvar stripeCustomerID *string\n\tif u.Billing.StripeCustomerID != \"\" {\n\t\tstripeCustomerID = &u.Billing.StripeCustomerID\n\t\tstripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {\n\t\t\treturn errMultipleBillingSubscriptions\n\t\t}\n\t}\n\tsuccessURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate\n\tparams := &stripe.CheckoutSessionParams{\n\t\tCustomer:            stripeCustomerID, // A user may have previously deleted their subscription\n\t\tClientReferenceID:   &u.ID,\n\t\tSuccessURL:          &successURL,\n\t\tMode:                stripe.String(string(stripe.CheckoutSessionModeSubscription)),\n\t\tAllowPromotionCodes: stripe.Bool(true),\n\t\tLineItems: []*stripe.CheckoutSessionLineItemParams{\n\t\t\t{\n\t\t\t\tPrice:    stripe.String(priceID),\n\t\t\t\tQuantity: stripe.Int64(1),\n\t\t\t},\n\t\t},\n\t\tAutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{\n\t\t\tEnabled: stripe.Bool(true),\n\t\t},\n\t}\n\tsess, err := s.stripe.NewCheckoutSession(params)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresponse := &apiAccountBillingSubscriptionCreateResponse{\n\t\tRedirectURL: sess.URL,\n\t}\n\treturn s.writeJSON(w, response)\n}\n\n// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use\n// the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first\n// and only time we can map the local username with the Stripe customer ID.\nfunc (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\t// We don't have v.User() in this endpoint, only a userManager!\n\tmatches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)\n\tif len(matches) != 2 {\n\t\treturn errHTTPInternalErrorInvalidPath\n\t}\n\tsessionID := matches[1]\n\tsess, err := s.stripe.GetSession(sessionID) // FIXME How do we rate limit this?\n\tif err != nil {\n\t\treturn err\n\t} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == \"\" {\n\t\treturn errHTTPBadRequestBillingRequestInvalid.Wrap(\"customer or subscription not found\")\n\t}\n\tsub, err := s.stripe.GetSubscription(sess.Subscription.ID)\n\tif err != nil {\n\t\treturn err\n\t} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil {\n\t\treturn errHTTPBadRequestBillingRequestInvalid.Wrap(\"more than one line item in existing subscription\")\n\t}\n\tpriceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval\n\ttier, err := s.userManager.TierByStripePrice(priceID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu, err := s.userManager.UserByID(sess.ClientReferenceID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv.SetUser(u)\n\tlogvr(v, r).\n\t\tWith(tier).\n\t\tTag(tagStripe).\n\t\tFields(log.Context{\n\t\t\t\"stripe_customer_id\":             sess.Customer.ID,\n\t\t\t\"stripe_price_id\":                priceID,\n\t\t\t\"stripe_subscription_id\":         sub.ID,\n\t\t\t\"stripe_subscription_status\":     string(sub.Status),\n\t\t\t\"stripe_subscription_interval\":   string(interval),\n\t\t\t\"stripe_subscription_paid_until\": sub.CurrentPeriodEnd,\n\t\t}).\n\t\tInfo(\"Stripe checkout flow succeeded, updating user tier and subscription\")\n\tcustomerParams := &stripe.CustomerParams{\n\t\tParams: stripe.Params{\n\t\t\tMetadata: map[string]string{\n\t\t\t\t\"user_id\":   u.ID,\n\t\t\t\t\"user_name\": u.Name,\n\t\t\t},\n\t\t},\n\t}\n\tif _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {\n\t\treturn err\n\t}\n\tif err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {\n\t\treturn err\n\t}\n\thttp.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)\n\treturn nil\n}\n\n// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates\n// a user's tier accordingly. This endpoint only works if there is an existing subscription.\nfunc (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tu := v.User()\n\tif u.Billing.StripeSubscriptionID == \"\" {\n\t\treturn errNoBillingSubscription\n\t}\n\treq, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttier, err := s.userManager.Tier(req.Tier)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar priceID string\n\tif req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != \"\" {\n\t\tpriceID = tier.StripeMonthlyPriceID\n\t} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != \"\" {\n\t\tpriceID = tier.StripeYearlyPriceID\n\t} else {\n\t\treturn errNotAPaidTier\n\t}\n\tlogvr(v, r).\n\t\tTag(tagStripe).\n\t\tFields(log.Context{\n\t\t\t\"new_tier_id\":                           tier.ID,\n\t\t\t\"new_tier_code\":                         tier.Code,\n\t\t\t\"new_tier_stripe_price_id\":              priceID,\n\t\t\t\"new_tier_stripe_subscription_interval\": req.Interval,\n\t\t\t// Other stripe_* fields filled by visitor context\n\t\t}).\n\t\tInfo(\"Changing Stripe subscription and billing tier to %s/%s (price %s, %s)\", tier.ID, tier.Name, priceID, req.Interval)\n\tsub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)\n\tif err != nil {\n\t\treturn err\n\t} else if sub.Items == nil || len(sub.Items.Data) != 1 {\n\t\treturn errHTTPBadRequestBillingRequestInvalid.Wrap(\"no items, or more than one item\")\n\t}\n\tparams := &stripe.SubscriptionParams{\n\t\tCancelAtPeriodEnd: stripe.Bool(false),\n\t\tProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),\n\t\tItems: []*stripe.SubscriptionItemsParams{\n\t\t\t{\n\t\t\t\tID:    stripe.String(sub.Items.Data[0].ID),\n\t\t\t\tPrice: stripe.String(priceID),\n\t\t\t},\n\t\t},\n\t}\n\t_, err = s.stripe.UpdateSubscription(sub.ID, params)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,\n// and cancelling the Stripe subscription entirely. Note that this does not actually change the tier.\n// That is done by a webhook at the period end (in X days).\nfunc (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tlogvr(v, r).Tag(tagStripe).Info(\"Deleting Stripe subscription\")\n\tu := v.User()\n\tif u.Billing.StripeSubscriptionID != \"\" {\n\t\tparams := &stripe.SubscriptionParams{\n\t\t\tCancelAtPeriodEnd: stripe.Bool(true),\n\t\t}\n\t\t_, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\n// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the\n// redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription.\nfunc (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\tlogvr(v, r).Tag(tagStripe).Info(\"Creating Stripe billing portal session\")\n\tu := v.User()\n\tif u.Billing.StripeCustomerID == \"\" {\n\t\treturn errHTTPBadRequestNotAPaidUser\n\t}\n\tparams := &stripe.BillingPortalSessionParams{\n\t\tCustomer:  stripe.String(u.Billing.StripeCustomerID),\n\t\tReturnURL: stripe.String(s.config.BaseURL),\n\t}\n\tps, err := s.stripe.NewPortalSession(params)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresponse := &apiAccountBillingPortalRedirectResponse{\n\t\tRedirectURL: ps.URL,\n\t}\n\treturn s.writeJSON(w, response)\n}\n\n// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync\n// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the\n// visitor (v) in this endpoint is the Stripe API, so we don't have u available.\nfunc (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {\n\tstripeSignature := r.Header.Get(\"Stripe-Signature\")\n\tif stripeSignature == \"\" {\n\t\treturn errHTTPBadRequestBillingRequestInvalid\n\t}\n\tbody, err := util.Peek(r.Body, jsonBodyBytesLimit)\n\tif err != nil {\n\t\treturn err\n\t} else if body.LimitReached {\n\t\treturn errHTTPEntityTooLargeJSONBody\n\t}\n\tevent, err := s.stripe.ConstructWebhookEvent(body.PeekedBytes, stripeSignature, s.config.StripeWebhookKey)\n\tif err != nil {\n\t\treturn err\n\t} else if event.Data == nil || event.Data.Raw == nil {\n\t\treturn errHTTPBadRequestBillingRequestInvalid\n\t}\n\tswitch event.Type {\n\tcase \"customer.subscription.updated\":\n\t\treturn s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event)\n\tcase \"customer.subscription.deleted\":\n\t\treturn s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event)\n\tdefault:\n\t\tlogvr(v, r).\n\t\t\tTag(tagStripe).\n\t\t\tField(\"stripe_webhook_type\", event.Type).\n\t\t\tWarn(\"Unhandled Stripe webhook event %s received\", event.Type)\n\t\treturn nil\n\t}\n}\n\nfunc (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error {\n\tev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))\n\tif err != nil {\n\t\treturn err\n\t} else if ev.ID == \"\" || ev.Customer == \"\" || ev.Status == \"\" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == \"\" || ev.Items.Data[0].Price.Recurring == nil {\n\t\tlogvr(v, r).Tag(tagStripe).Field(\"stripe_request\", fmt.Sprintf(\"%#v\", ev)).Warn(\"Unexpected request from Stripe\")\n\t\treturn errHTTPBadRequestBillingRequestInvalid\n\t}\n\tsubscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval\n\tlogvr(v, r).\n\t\tTag(tagStripe).\n\t\tFields(log.Context{\n\t\t\t\"stripe_webhook_type\":            event.Type,\n\t\t\t\"stripe_customer_id\":             ev.Customer,\n\t\t\t\"stripe_price_id\":                priceID,\n\t\t\t\"stripe_subscription_id\":         ev.ID,\n\t\t\t\"stripe_subscription_status\":     ev.Status,\n\t\t\t\"stripe_subscription_interval\":   interval,\n\t\t\t\"stripe_subscription_paid_until\": ev.CurrentPeriodEnd,\n\t\t\t\"stripe_subscription_cancel_at\":  ev.CancelAt,\n\t\t}).\n\t\tInfo(\"Updating subscription to status %s, with price %s\", ev.Status, priceID)\n\tuserFn := func() (*user.User, error) {\n\t\treturn s.userManager.UserByStripeCustomer(ev.Customer)\n\t}\n\t// We retry the user retrieval function, because during the Stripe checkout, there a race between the browser\n\t// checkout success redirect (see handleAccountBillingSubscriptionCreateSuccess), and this webhook. The checkout\n\t// success call is the one that updates the user with the Stripe customer ID.\n\tu, err := util.Retry[user.User](userFn, retryUserDelays...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv.SetUser(u)\n\ttier, err := s.userManager.TierByStripePrice(priceID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil {\n\t\treturn err\n\t}\n\ts.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))\n\treturn nil\n}\n\nfunc (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error {\n\tev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))\n\tif err != nil {\n\t\treturn err\n\t} else if ev.Customer == \"\" {\n\t\treturn errHTTPBadRequestBillingRequestInvalid\n\t}\n\tu, err := s.userManager.UserByStripeCustomer(ev.Customer)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv.SetUser(u)\n\tlogvr(v, r).\n\t\tTag(tagStripe).\n\t\tField(\"stripe_webhook_type\", event.Type).\n\t\tInfo(\"Subscription deleted, downgrading to unpaid tier\")\n\tif err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, \"\", \"\", \"\", 0, 0); err != nil {\n\t\treturn err\n\t}\n\ts.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))\n\treturn nil\n}\n\nfunc (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error {\n\treservationsLimit := visitorDefaultReservationsLimit\n\tif tier != nil {\n\t\treservationsLimit = tier.ReservationLimit\n\t}\n\tif err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil {\n\t\treturn err\n\t}\n\tif tier == nil && u.Tier != nil {\n\t\tlogvr(v, r).Tag(tagStripe).Info(\"Resetting tier for user %s\", u.Name)\n\t\tif err := s.userManager.ResetTier(u.Name); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if tier != nil && u.TierID() != tier.ID {\n\t\tlogvr(v, r).\n\t\t\tTag(tagStripe).\n\t\t\tFields(log.Context{\n\t\t\t\t\"new_tier_id\":   tier.ID,\n\t\t\t\t\"new_tier_code\": tier.Code,\n\t\t\t}).\n\t\t\tInfo(\"Changing tier to tier %s (%s) for user %s\", tier.ID, tier.Name, u.Name)\n\t\tif err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// Update billing fields\n\tbilling := &user.Billing{\n\t\tStripeCustomerID:            customerID,\n\t\tStripeSubscriptionID:        subscriptionID,\n\t\tStripeSubscriptionStatus:    payments.SubscriptionStatus(status),\n\t\tStripeSubscriptionInterval:  payments.PriceRecurringInterval(interval),\n\t\tStripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),\n\t\tStripeSubscriptionCancelAt:  time.Unix(cancelAt, 0),\n\t}\n\tif err := s.userManager.ChangeBilling(u.Name, billing); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices\n// in memory, and ultimately for the web app to display the price table.\nfunc (s *Server) fetchStripePrices() (map[string]int64, error) {\n\tlog.Debug(\"Caching prices from Stripe API\")\n\tpriceMap := make(map[string]int64)\n\tprices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)})\n\tif err != nil {\n\t\tlog.Warn(\"Fetching Stripe prices failed: %s\", err.Error())\n\t\treturn nil, err\n\t}\n\tfor _, p := range prices {\n\t\tpriceMap[p.ID] = p.UnitAmount\n\t\tlog.Trace(\"- Caching price %s = %v\", p.ID, priceMap[p.ID])\n\t}\n\treturn priceMap, nil\n}\n\n// stripeAPI is a small interface to facilitate mocking of the Stripe API\ntype stripeAPI interface {\n\tNewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)\n\tNewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error)\n\tListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error)\n\tGetCustomer(id string) (*stripe.Customer, error)\n\tGetSession(id string) (*stripe.CheckoutSession, error)\n\tGetSubscription(id string) (*stripe.Subscription, error)\n\tUpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error)\n\tUpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)\n\tCancelSubscription(id string) (*stripe.Subscription, error)\n\tConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)\n}\n\n// realStripeAPI is a thin shim around the Stripe functions to facilitate mocking\ntype realStripeAPI struct{}\n\nvar _ stripeAPI = (*realStripeAPI)(nil)\n\nfunc newStripeAPI() stripeAPI {\n\treturn &realStripeAPI{}\n}\n\nfunc (s *realStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {\n\treturn session.New(params)\n}\n\nfunc (s *realStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {\n\treturn portalsession.New(params)\n}\n\nfunc (s *realStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) {\n\tprices := make([]*stripe.Price, 0)\n\titer := price.List(params)\n\tfor iter.Next() {\n\t\tprices = append(prices, iter.Price())\n\t}\n\tif iter.Err() != nil {\n\t\treturn nil, iter.Err()\n\t}\n\treturn prices, nil\n}\n\nfunc (s *realStripeAPI) GetCustomer(id string) (*stripe.Customer, error) {\n\treturn customer.Get(id, nil)\n}\n\nfunc (s *realStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) {\n\treturn session.Get(id, nil)\n}\n\nfunc (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) {\n\treturn subscription.Get(id, nil)\n}\n\nfunc (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {\n\treturn customer.Update(id, params)\n}\n\nfunc (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {\n\treturn subscription.Update(id, params)\n}\n\nfunc (s *realStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) {\n\treturn subscription.Cancel(id, nil)\n}\n\nfunc (s *realStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) {\n\treturn webhook.ConstructEvent(payload, header, secret)\n}\n"
  },
  {
    "path": "server/server_payments_dummy.go",
    "content": "//go:build nopayments\n\npackage server\n\nimport (\n\t\"net/http\"\n)\n\ntype stripeAPI interface {\n\tCancelSubscription(id string) (string, error)\n}\n\nfunc newStripeAPI() stripeAPI {\n\treturn nil\n}\n\nfunc (s *Server) fetchStripePrices() (map[string]int64, error) {\n\treturn nil, errHTTPNotFound\n}\n\nfunc (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n"
  },
  {
    "path": "server/server_payments_test.go",
    "content": "//go:build !nopayments\n\npackage server\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stripe/stripe-go/v74\"\n\t\"golang.org/x/time/rate\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/payments\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"io\"\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestPayments_Tiers(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\tc.VisitorRequestLimitReplenish = 12 * time.Hour\n\t\tc.CacheDuration = 13 * time.Hour\n\t\tc.AttachmentFileSizeLimit = 111\n\t\tc.VisitorAttachmentTotalSizeLimit = 222\n\t\tc.AttachmentExpiryDuration = 123 * time.Second\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"ListPrices\", mock.Anything).\n\t\t\tReturn([]*stripe.Price{\n\t\t\t\t{ID: \"price_123\", UnitAmount: 500},\n\t\t\t\t{ID: \"price_124\", UnitAmount: 5000},\n\t\t\t\t{ID: \"price_456\", UnitAmount: 1000},\n\t\t\t\t{ID: \"price_457\", UnitAmount: 10000},\n\t\t\t\t{ID: \"price_999\", UnitAmount: 9999},\n\t\t\t}, nil)\n\n\t\t// Create tiers\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:   \"ti_1\",\n\t\t\tCode: \"admin\",\n\t\t\tName: \"Admin\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                       \"ti_123\",\n\t\t\tCode:                     \"pro\",\n\t\t\tName:                     \"Pro\",\n\t\t\tMessageLimit:             1000,\n\t\t\tMessageExpiryDuration:    time.Hour,\n\t\t\tEmailLimit:               123,\n\t\t\tReservationLimit:         777,\n\t\t\tAttachmentFileSizeLimit:  999,\n\t\t\tAttachmentTotalSizeLimit: 888,\n\t\t\tAttachmentExpiryDuration: time.Minute,\n\t\t\tStripeMonthlyPriceID:     \"price_123\",\n\t\t\tStripeYearlyPriceID:      \"price_124\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                       \"ti_444\",\n\t\t\tCode:                     \"business\",\n\t\t\tName:                     \"Business\",\n\t\t\tMessageLimit:             2000,\n\t\t\tMessageExpiryDuration:    10 * time.Hour,\n\t\t\tEmailLimit:               123123,\n\t\t\tReservationLimit:         777333,\n\t\t\tAttachmentFileSizeLimit:  999111,\n\t\t\tAttachmentTotalSizeLimit: 888111,\n\t\t\tAttachmentExpiryDuration: time.Hour,\n\t\t\tStripeMonthlyPriceID:     \"price_456\",\n\t\t\tStripeYearlyPriceID:      \"price_457\",\n\t\t}))\n\t\tresponse := request(t, s, \"GET\", \"/v1/tiers\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tvar tiers []apiAccountBillingTier\n\t\trequire.Nil(t, json.NewDecoder(response.Body).Decode(&tiers))\n\t\trequire.Equal(t, 3, len(tiers))\n\n\t\t// Free tier\n\t\ttier := tiers[0]\n\t\trequire.Equal(t, \"\", tier.Code)\n\t\trequire.Equal(t, \"\", tier.Name)\n\t\trequire.Equal(t, \"ip\", tier.Limits.Basis)\n\t\trequire.Equal(t, int64(0), tier.Limits.Reservations)\n\t\trequire.Equal(t, int64(2), tier.Limits.Messages) // :-(\n\t\trequire.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration)\n\t\trequire.Equal(t, int64(24), tier.Limits.Emails)\n\t\trequire.Equal(t, int64(111), tier.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(222), tier.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration)\n\n\t\t// Admin tier is not included, because it is not paid!\n\n\t\ttier = tiers[1]\n\t\trequire.Equal(t, \"pro\", tier.Code)\n\t\trequire.Equal(t, \"Pro\", tier.Name)\n\t\trequire.Equal(t, \"tier\", tier.Limits.Basis)\n\t\trequire.Equal(t, int64(500), tier.Prices.Month)\n\t\trequire.Equal(t, int64(5000), tier.Prices.Year)\n\t\trequire.Equal(t, int64(777), tier.Limits.Reservations)\n\t\trequire.Equal(t, int64(1000), tier.Limits.Messages)\n\t\trequire.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)\n\t\trequire.Equal(t, int64(123), tier.Limits.Emails)\n\t\trequire.Equal(t, int64(999), tier.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(888), tier.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration)\n\n\t\ttier = tiers[2]\n\t\trequire.Equal(t, \"business\", tier.Code)\n\t\trequire.Equal(t, \"Business\", tier.Name)\n\t\trequire.Equal(t, int64(1000), tier.Prices.Month)\n\t\trequire.Equal(t, int64(10000), tier.Prices.Year)\n\t\trequire.Equal(t, \"tier\", tier.Limits.Basis)\n\t\trequire.Equal(t, int64(777333), tier.Limits.Reservations)\n\t\trequire.Equal(t, int64(2000), tier.Limits.Messages)\n\t\trequire.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration)\n\t\trequire.Equal(t, int64(123123), tier.Limits.Emails)\n\t\trequire.Equal(t, int64(999111), tier.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration)\n\t})\n}\n\nfunc TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"NewCheckoutSession\", mock.Anything).\n\t\t\tReturn(&stripe.CheckoutSession{URL: \"https://billing.stripe.com/abc/def\"}, nil)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_123\",\n\t\t\tCode:                 \"pro\",\n\t\t\tStripeMonthlyPriceID: \"price_123\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\t// Create subscription\n\t\tresponse := request(t, s, \"POST\", \"/v1/account/billing/subscription\", `{\"tier\": \"pro\", \"interval\": \"month\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tredirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"https://billing.stripe.com/abc/def\", redirectResponse.RedirectURL)\n\t})\n}\n\nfunc TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"GetCustomer\", \"acct_123\").\n\t\t\tReturn(&stripe.Customer{Subscriptions: &stripe.SubscriptionList{}}, nil)\n\t\tstripeMock.\n\t\t\tOn(\"NewCheckoutSession\", mock.Anything).\n\t\t\tReturn(&stripe.CheckoutSession{URL: \"https://billing.stripe.com/abc/def\"}, nil)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_123\",\n\t\t\tCode:                 \"pro\",\n\t\t\tStripeMonthlyPriceID: \"price_123\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tbilling := &user.Billing{\n\t\t\tStripeCustomerID: \"acct_123\",\n\t\t}\n\t\trequire.Nil(t, s.userManager.ChangeBilling(u.Name, billing))\n\n\t\t// Create subscription\n\t\tresponse := request(t, s, \"POST\", \"/v1/account/billing/subscription\", `{\"tier\": \"pro\", \"interval\": \"month\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tredirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"https://billing.stripe.com/abc/def\", redirectResponse.RedirectURL)\n\t})\n}\n\nfunc TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.EnableSignup = true\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"CancelSubscription\", \"sub_123\").\n\t\t\tReturn(&stripe.Subscription{}, nil)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_123\",\n\t\t\tCode:                 \"pro\",\n\t\t\tStripeMonthlyPriceID: \"price_123\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tbilling := &user.Billing{\n\t\t\tStripeCustomerID:     \"acct_123\",\n\t\t\tStripeSubscriptionID: \"sub_123\",\n\t\t}\n\t\trequire.Nil(t, s.userManager.ChangeBilling(u.Name, billing))\n\n\t\t// Delete account\n\t\trr := request(t, s, \"DELETE\", \"/v1/account\", `{\"password\": \"phil\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"mypass\"),\n\t\t})\n\t\trequire.Equal(t, 401, rr.Code)\n\t})\n}\n\nfunc TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This test is too overloaded, but it's also a great end-to-end a test.\n\t\t//\n\t\t// It tests:\n\t\t// - A successful checkout flow (not a paying customer -> paying customer)\n\t\t// - Tier-changes reset the rate limits for the user\n\t\t// - The request limits for tier-less user and a tier-user\n\t\t// - The message limits for a tier-user\n\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\tc.VisitorRequestLimitBurst = 5\n\t\tc.VisitorRequestLimitReplenish = time.Hour\n\t\tc.CacheBatchSize = 500\n\t\tc.CacheBatchTimeout = time.Second\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Create a user with a Stripe subscription and 3 reservations\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                    \"ti_123\",\n\t\t\tCode:                  \"starter\",\n\t\t\tStripeMonthlyPriceID:  \"price_1234\",\n\t\t\tReservationLimit:      1,\n\t\t\tMessageLimit:          220, // 220 * 5% = 11 requests before rate limiting kicks in\n\t\t\tMessageExpiryDuration: time.Hour,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false)) // No tier\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"GetSession\", \"SOMETOKEN\").\n\t\t\tReturn(&stripe.CheckoutSession{\n\t\t\t\tClientReferenceID: u.ID, // ntfy user ID\n\t\t\t\tCustomer: &stripe.Customer{\n\t\t\t\t\tID: \"acct_5555\",\n\t\t\t\t},\n\t\t\t\tSubscription: &stripe.Subscription{\n\t\t\t\t\tID: \"sub_1234\",\n\t\t\t\t},\n\t\t\t}, nil)\n\t\tstripeMock.\n\t\t\tOn(\"GetSubscription\", \"sub_1234\").\n\t\t\tReturn(&stripe.Subscription{\n\t\t\t\tID:               \"sub_1234\",\n\t\t\t\tStatus:           stripe.SubscriptionStatusActive,\n\t\t\t\tCurrentPeriodEnd: 123456789,\n\t\t\t\tCancelAt:         0,\n\t\t\t\tItems: &stripe.SubscriptionItemList{\n\t\t\t\t\tData: []*stripe.SubscriptionItem{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPrice: &stripe.Price{\n\t\t\t\t\t\t\t\tID: \"price_1234\",\n\t\t\t\t\t\t\t\tRecurring: &stripe.PriceRecurring{\n\t\t\t\t\t\t\t\t\tInterval: stripe.PriceRecurringIntervalMonth,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, nil)\n\t\tstripeMock.\n\t\t\tOn(\"UpdateCustomer\", \"acct_5555\", &stripe.CustomerParams{\n\t\t\t\tParams: stripe.Params{\n\t\t\t\t\tMetadata: map[string]string{\n\t\t\t\t\t\t\"user_id\":   u.ID,\n\t\t\t\t\t\t\"user_name\": u.Name,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}).\n\t\t\tReturn(&stripe.Customer{}, nil)\n\n\t\t// Send messages until rate limit of free tier is hit\n\t\tfor i := 0; i < 5; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\t\trr := request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// Verify some \"before-stats\"\n\t\tu, err = s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, u.Tier)\n\t\trequire.Equal(t, \"\", u.Billing.StripeCustomerID)\n\t\trequire.Equal(t, \"\", u.Billing.StripeSubscriptionID)\n\t\trequire.Equal(t, payments.SubscriptionStatus(\"\"), u.Billing.StripeSubscriptionStatus)\n\t\trequire.Equal(t, payments.PriceRecurringInterval(\"\"), u.Billing.StripeSubscriptionInterval)\n\t\trequire.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())\n\t\trequire.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())\n\t\trequire.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!\n\t\trequire.Equal(t, int64(0), u.Stats.Emails)\n\n\t\t// Simulate Stripe success return URL call (no user context)\n\t\trr = request(t, s, \"GET\", \"/v1/account/billing/subscription/success/SOMETOKEN\", \"\", nil)\n\t\trequire.Equal(t, 303, rr.Code)\n\n\t\t// Verify that database columns were updated\n\t\tu, err = s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"starter\", u.Tier.Code) // Not \"pro\"\n\t\trequire.Equal(t, \"acct_5555\", u.Billing.StripeCustomerID)\n\t\trequire.Equal(t, \"sub_1234\", u.Billing.StripeSubscriptionID)\n\t\trequire.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)\n\t\trequire.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)\n\t\trequire.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())\n\t\trequire.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())\n\t\trequire.Equal(t, int64(0), u.Stats.Messages)\n\t\trequire.Equal(t, int64(0), u.Stats.Emails)\n\n\t\t// Now for the fun part: Verify that new rate limits are immediately applied\n\t\t// This only tests the request limiter, which kicks in before the message limiter.\n\t\tfor i := 0; i < 11; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, rr.Code, \"failed on iteration %d\", i)\n\t\t}\n\t\trr = request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// Now let's test the message limiter by faking a ridiculously generous rate limiter\n\t\tv := s.visitor(netip.MustParseAddr(\"9.9.9.9\"), u)\n\t\tv.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000)\n\n\t\tvar wg sync.WaitGroup\n\t\tfor i := 0; i < 209; i++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(i int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\trr := request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\t})\n\t\t\t\trequire.Equal(t, 200, rr.Code, \"Failed on %d\", i)\n\t\t\t}(i)\n\t\t}\n\t\twg.Wait()\n\t\trr = request(t, s, \"PUT\", \"/mytopic\", \"some message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// And now let's cross-check that the stats are correct too\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, int64(220), account.Limits.Messages)\n\t\trequire.Equal(t, int64(220), account.Stats.Messages)\n\t\trequire.Equal(t, int64(0), account.Stats.MessagesRemaining)\n\t})\n}\n\nfunc TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\n\t\t// This tests incoming webhooks from Stripe to update a subscription:\n\t\t// - All Stripe columns are updated in the user table\n\t\t// - When downgrading, excess reservations are deleted, including messages and attachments in\n\t\t//   the corresponding topics\n\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"ConstructWebhookEvent\", mock.Anything, \"stripe signature\", \"webhook key\").\n\t\t\tReturn(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)\n\n\t\t// Create a user with a Stripe subscription and 3 reservations\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                       \"ti_1\",\n\t\t\tCode:                     \"starter\",\n\t\t\tStripeMonthlyPriceID:     \"price_1234\", // !\n\t\t\tReservationLimit:         1,            // !\n\t\t\tMessageLimit:             100,\n\t\t\tMessageExpiryDuration:    time.Hour,\n\t\t\tAttachmentExpiryDuration: time.Hour,\n\t\t\tAttachmentFileSizeLimit:  1000000,\n\t\t\tAttachmentTotalSizeLimit: 1000000,\n\t\t\tAttachmentBandwidthLimit: 1000000,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                       \"ti_2\",\n\t\t\tCode:                     \"pro\",\n\t\t\tStripeMonthlyPriceID:     \"price_1111\", // !\n\t\t\tReservationLimit:         3,            // !\n\t\t\tMessageLimit:             200,\n\t\t\tMessageExpiryDuration:    time.Hour,\n\t\t\tAttachmentExpiryDuration: time.Hour,\n\t\t\tAttachmentFileSizeLimit:  1000000,\n\t\t\tAttachmentTotalSizeLimit: 1000000,\n\t\t\tAttachmentBandwidthLimit: 1000000,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\trequire.Nil(t, s.userManager.AddReservation(\"phil\", \"atopic\", user.PermissionDenyAll, 0))\n\t\trequire.Nil(t, s.userManager.AddReservation(\"phil\", \"ztopic\", user.PermissionDenyAll, 0))\n\n\t\t// Add billing details\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tbilling := &user.Billing{\n\t\t\tStripeCustomerID:            \"acct_5555\",\n\t\t\tStripeSubscriptionID:        \"sub_1234\",\n\t\t\tStripeSubscriptionStatus:    payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),\n\t\t\tStripeSubscriptionInterval:  payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),\n\t\t\tStripeSubscriptionPaidUntil: time.Unix(123, 0),\n\t\t\tStripeSubscriptionCancelAt:  time.Unix(456, 0),\n\t\t}\n\t\trequire.Nil(t, s.userManager.ChangeBilling(u.Name, billing))\n\n\t\t// Add some messages to \"atopic\" and \"ztopic\", everything in \"ztopic\" will be deleted\n\t\trr := request(t, s, \"PUT\", \"/atopic\", \"some aaa message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"PUT\", \"/atopic\", strings.Repeat(\"a\", 5000), map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\ta2 := toMessage(t, rr.Body.String())\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID))\n\n\t\trr = request(t, s, \"PUT\", \"/ztopic\", \"some zzz message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"PUT\", \"/ztopic\", strings.Repeat(\"z\", 5000), map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tz2 := toMessage(t, rr.Body.String())\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))\n\n\t\t// Call the webhook: This does all the magic\n\t\trr = request(t, s, \"POST\", \"/v1/account/billing/webhook\", \"dummy\", map[string]string{\n\t\t\t\"Stripe-Signature\": \"stripe signature\",\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Verify that database columns were updated\n\t\tu, err = s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"starter\", u.Tier.Code) // Not \"pro\"\n\t\trequire.Equal(t, \"acct_5555\", u.Billing.StripeCustomerID)\n\t\trequire.Equal(t, \"sub_1234\", u.Billing.StripeSubscriptionID)\n\t\trequire.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)         // Not \"past_due\"\n\t\trequire.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not \"month\"\n\t\trequire.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix())                                          // Updated\n\t\trequire.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix())                                           // Updated\n\n\t\t// Verify that reservations were deleted\n\t\tr, err := s.userManager.Reservations(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(r)) // \"ztopic\" reservation was deleted\n\t\trequire.Equal(t, \"atopic\", r[0].Topic)\n\n\t\t// Verify that messages and attachments were deleted\n\t\ttime.Sleep(time.Second)\n\t\ts.execManager()\n\n\t\tms, err := s.messageCache.Messages(\"atopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(ms))\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID))\n\n\t\tms, err = s.messageCache.Messages(\"ztopic\", model.SinceAllMessages, false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(ms))\n\t\trequire.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))\n\t})\n}\n\nfunc TestPayments_Webhook_Subscription_Deleted(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is\n\t\t// updated (all Stripe fields are deleted, and the tier is removed).\n\t\t//\n\t\t// It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call.\n\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"ConstructWebhookEvent\", mock.Anything, \"stripe signature\", \"webhook key\").\n\t\t\tReturn(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil)\n\n\t\t// Create a user with a Stripe subscription and 3 reservations\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_1\",\n\t\t\tCode:                 \"pro\",\n\t\t\tStripeMonthlyPriceID: \"price_1234\",\n\t\t\tReservationLimit:     1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\trequire.Nil(t, s.userManager.AddReservation(\"phil\", \"atopic\", user.PermissionDenyAll, 0))\n\n\t\t// Add billing details\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{\n\t\t\tStripeCustomerID:            \"acct_5555\",\n\t\t\tStripeSubscriptionID:        \"sub_1234\",\n\t\t\tStripeSubscriptionStatus:    payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),\n\t\t\tStripeSubscriptionInterval:  payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),\n\t\t\tStripeSubscriptionPaidUntil: time.Unix(123, 0),\n\t\t\tStripeSubscriptionCancelAt:  time.Unix(0, 0),\n\t\t}))\n\n\t\t// Call the webhook: This does all the magic\n\t\trr := request(t, s, \"POST\", \"/v1/account/billing/webhook\", \"dummy\", map[string]string{\n\t\t\t\"Stripe-Signature\": \"stripe signature\",\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Verify that database columns were updated\n\t\tu, err = s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, u.Tier)\n\t\trequire.Equal(t, \"acct_5555\", u.Billing.StripeCustomerID)\n\t\trequire.Equal(t, \"\", u.Billing.StripeSubscriptionID)\n\t\trequire.Equal(t, payments.SubscriptionStatus(\"\"), u.Billing.StripeSubscriptionStatus)\n\t\trequire.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())\n\t\trequire.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())\n\n\t\t// Verify that reservations were deleted\n\t\tr, err := s.userManager.Reservations(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(r))\n\t})\n}\n\nfunc TestPayments_Subscription_Update_Different_Tier(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"GetSubscription\", \"sub_123\").\n\t\t\tReturn(&stripe.Subscription{\n\t\t\t\tID: \"sub_123\",\n\t\t\t\tItems: &stripe.SubscriptionItemList{\n\t\t\t\t\tData: []*stripe.SubscriptionItem{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tID:    \"someid_123\",\n\t\t\t\t\t\t\tPrice: &stripe.Price{ID: \"price_123\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, nil)\n\t\tstripeMock.\n\t\t\tOn(\"UpdateSubscription\", \"sub_123\", &stripe.SubscriptionParams{\n\t\t\t\tCancelAtPeriodEnd: stripe.Bool(false),\n\t\t\t\tProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),\n\t\t\t\tItems: []*stripe.SubscriptionItemsParams{\n\t\t\t\t\t{\n\t\t\t\t\t\tID:    stripe.String(\"someid_123\"),\n\t\t\t\t\t\tPrice: stripe.String(\"price_457\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}).\n\t\t\tReturn(&stripe.Subscription{}, nil)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_123\",\n\t\t\tCode:                 \"pro\",\n\t\t\tStripeMonthlyPriceID: \"price_123\",\n\t\t\tStripeYearlyPriceID:  \"price_124\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tID:                   \"ti_456\",\n\t\t\tCode:                 \"business\",\n\t\t\tStripeMonthlyPriceID: \"price_456\",\n\t\t\tStripeYearlyPriceID:  \"price_457\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\trequire.Nil(t, s.userManager.ChangeBilling(\"phil\", &user.Billing{\n\t\t\tStripeCustomerID:     \"acct_123\",\n\t\t\tStripeSubscriptionID: \"sub_123\",\n\t\t}))\n\n\t\t// Call endpoint to change subscription\n\t\trr := request(t, s, \"PUT\", \"/v1/account/billing/subscription\", `{\"tier\":\"business\",\"interval\":\"year\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\n\nfunc TestPayments_Subscription_Delete_At_Period_End(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"UpdateSubscription\", \"sub_123\", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool {\n\t\t\t\treturn *s.CancelAtPeriodEnd // Is true\n\t\t\t})).\n\t\t\tReturn(&stripe.Subscription{}, nil)\n\n\t\t// Create user\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeBilling(\"phil\", &user.Billing{\n\t\t\tStripeCustomerID:     \"acct_123\",\n\t\t\tStripeSubscriptionID: \"sub_123\",\n\t\t}))\n\n\t\t// Delete subscription\n\t\trr := request(t, s, \"DELETE\", \"/v1/account/billing/subscription\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\n\nfunc TestPayments_CreatePortalSession(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tstripeMock := &testStripeAPI{}\n\t\tdefer stripeMock.AssertExpectations(t)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.StripeSecretKey = \"secret key\"\n\t\tc.StripeWebhookKey = \"webhook key\"\n\t\ts := newTestServer(t, c)\n\t\ts.stripe = stripeMock\n\n\t\t// Define how the mock should react\n\t\tstripeMock.\n\t\t\tOn(\"NewPortalSession\", &stripe.BillingPortalSessionParams{\n\t\t\t\tCustomer:  stripe.String(\"acct_123\"),\n\t\t\t\tReturnURL: stripe.String(s.config.BaseURL),\n\t\t\t}).\n\t\t\tReturn(&stripe.BillingPortalSession{\n\t\t\t\tURL: \"https://billing.stripe.com/blablabla\",\n\t\t\t}, nil)\n\n\t\t// Create user\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeBilling(\"phil\", &user.Billing{\n\t\t\tStripeCustomerID:     \"acct_123\",\n\t\t\tStripeSubscriptionID: \"sub_123\",\n\t\t}))\n\n\t\t// Create portal session\n\t\trr := request(t, s, \"POST\", \"/v1/account/billing/portal\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, \"https://billing.stripe.com/blablabla\", ps.RedirectURL)\n\t})\n}\n\ntype testStripeAPI struct {\n\tmock.Mock\n}\n\nvar _ stripeAPI = (*testStripeAPI)(nil)\n\nfunc (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {\n\targs := s.Called(params)\n\treturn args.Get(0).(*stripe.CheckoutSession), args.Error(1)\n}\n\nfunc (s *testStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {\n\targs := s.Called(params)\n\treturn args.Get(0).(*stripe.BillingPortalSession), args.Error(1)\n}\n\nfunc (s *testStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) {\n\targs := s.Called(params)\n\treturn args.Get(0).([]*stripe.Price), args.Error(1)\n}\n\nfunc (s *testStripeAPI) GetCustomer(id string) (*stripe.Customer, error) {\n\targs := s.Called(id)\n\treturn args.Get(0).(*stripe.Customer), args.Error(1)\n}\n\nfunc (s *testStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) {\n\targs := s.Called(id)\n\treturn args.Get(0).(*stripe.CheckoutSession), args.Error(1)\n}\n\nfunc (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) {\n\targs := s.Called(id)\n\treturn args.Get(0).(*stripe.Subscription), args.Error(1)\n}\n\nfunc (s *testStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {\n\targs := s.Called(id, params)\n\treturn args.Get(0).(*stripe.Customer), args.Error(1)\n}\n\nfunc (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {\n\targs := s.Called(id, params)\n\treturn args.Get(0).(*stripe.Subscription), args.Error(1)\n}\n\nfunc (s *testStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) {\n\targs := s.Called(id)\n\treturn args.Get(0).(*stripe.Subscription), args.Error(1)\n}\n\nfunc (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) {\n\targs := s.Called(payload, header, secret)\n\treturn args.Get(0).(stripe.Event), args.Error(1)\n}\n\nfunc jsonToStripeEvent(t *testing.T, v string) stripe.Event {\n\tvar e stripe.Event\n\tif err := json.Unmarshal([]byte(v), &e); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn e\n}\n\nconst subscriptionUpdatedEventJSON = `\n{\n\t\"type\": \"customer.subscription.updated\",\n\t\"data\": {\n\t\t\"object\": {\n\t\t\t\"id\": \"sub_1234\",\n\t\t\t\"customer\": \"acct_5555\",\n\t\t\t\"status\": \"active\",\n\t\t\t\"current_period_end\": 1674268231,\n\t\t\t\"cancel_at\": 1674299999,\n\t\t\t\"items\": {\n\t\t\t\t\"data\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"price\": {\n\t\t\t\t\t\t\t\"id\": \"price_1234\",\n\t\t\t\t\t\t\t\"recurring\": {\n\t\t\t\t\t\t\t\t\"interval\": \"year\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}\n}`\n\nconst subscriptionDeletedEventJSON = `\n{\n\t\"type\": \"customer.subscription.deleted\",\n\t\"data\": {\n\t\t\"object\": {\n\t\t\t\"id\": \"sub_1234\",\n\t\t\t\"customer\": \"acct_5555\",\n\t\t\t\"status\": \"active\",\n\t\t\t\"current_period_end\": 1674268231,\n\t\t\t\"cancel_at\": 1674299999,\n\t\t\t\"items\": {\n\t\t\t\t\"data\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"price\": {\n\t\t\t\t\t\t\t\"id\": \"price_1234\",\n\t\t\t\t\t\t\t\"recurring\": {\n\t\t\t\t\t\t\t\t\"interval\": \"month\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t}\n}`\n"
  },
  {
    "path": "server/server_race_off_test.go",
    "content": "//go:build !race\n\npackage server\n\nconst raceEnabled = false\n"
  },
  {
    "path": "server/server_race_on_test.go",
    "content": "//go:build race\n\npackage server\n\nconst raceEnabled = true\n"
  },
  {
    "path": "server/server_test.go",
    "content": "package server\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"crypto/rand\"\n\t_ \"embed\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/netip\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime/debug\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/crypto/bcrypt\"\n\tdbtest \"heckel.io/ntfy/v2/db/test\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/message\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc TestMain(m *testing.M) {\n\tlog.SetLevel(log.ErrorLevel)\n\tos.Exit(m.Run())\n}\n\nfunc TestServer_PublishAndPoll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse1 := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\tmsg1 := toMessage(t, response1.Body.String())\n\t\trequire.NotEmpty(t, msg1.ID)\n\t\trequire.Equal(t, \"my first message\", msg1.Message)\n\n\t\tresponse2 := request(t, s, \"PUT\", \"/mytopic\", \"my second\\n\\nmessage\", nil)\n\t\tmsg2 := toMessage(t, response2.Body.String())\n\t\trequire.NotEqual(t, msg1.ID, msg2.ID)\n\t\trequire.NotEmpty(t, msg2.ID)\n\t\trequire.Equal(t, \"my second\\n\\nmessage\", msg2.Message)\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, \"my first message\", messages[0].Message)\n\t\trequire.Equal(t, \"my second\\n\\nmessage\", messages[1].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/sse?poll=1&since=all\", \"\", nil)\n\t\tlines := strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 3, len(lines))\n\t\trequire.Equal(t, \"my first message\", toMessage(t, strings.TrimPrefix(lines[0], \"data: \")).Message)\n\t\trequire.Equal(t, \"\", lines[1])\n\t\trequire.Equal(t, \"my second\\n\\nmessage\", toMessage(t, strings.TrimPrefix(lines[2], \"data: \")).Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/raw?poll=1\", \"\", nil)\n\t\tlines = strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 2, len(lines))\n\t\trequire.Equal(t, \"my first message\", lines[0])\n\t\trequire.Equal(t, \"my second  message\", lines[1]) // \\n -> \" \"\n\t})\n}\n\nfunc TestServer_PublishWithFirebase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsender := newTestFirebaseSender(10)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg1.ID)\n\t\trequire.Equal(t, \"my first message\", msg1.Message)\n\n\t\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens\n\t\trequire.Equal(t, 1, len(sender.Messages()))\n\t\trequire.Equal(t, \"my first message\", sender.Messages()[0].Data[\"message\"])\n\t\trequire.Equal(t, \"my first message\", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)\n\t\trequire.Equal(t, \"my first message\", sender.Messages()[0].APNS.Payload.CustomData[\"message\"])\n\t})\n}\n\nfunc TestServer_PublishWithoutFirebase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsender := newTestFirebaseSender(10)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", map[string]string{\n\t\t\t\"firebase\": \"no\",\n\t\t})\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg1.ID)\n\t\trequire.Equal(t, \"my first message\", msg1.Message)\n\n\t\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens\n\t\trequire.Equal(t, 0, len(sender.Messages()))\n\t})\n}\n\nfunc TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This tests issue #641, which used to panic before the fix\n\n\t\tfirebaseKeyFile := filepath.Join(t.TempDir(), \"firebase.json\")\n\t\tcontents := `{\n  \"type\": \"service_account\",\n  \"project_id\": \"ntfy-test\",\n  \"private_key_id\": \"fsfhskjdfhskdhfskdjfhsdf\",\n  \"private_key\": \"lalala\",\n  \"client_email\": \"firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com\",\n  \"client_id\": \"123123213\",\n  \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n  \"token_uri\": \"https://oauth2.googleapis.com/token\",\n  \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n  \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com\"\n}\n`\n\t\trequire.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600))\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.FirebaseKeyFile = firebaseKeyFile\n\t\ts := newTestServer(t, c)\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\trequire.Equal(t, \"my first message\", toMessage(t, response.Body.String()).Message)\n\t})\n}\n\nfunc TestServer_SubscribeOpenAndKeepalive(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.KeepaliveInterval = time.Second\n\t\ts := newTestServer(t, c)\n\n\t\trr := httptest.NewRecorder()\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"/mytopic/json\", nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdoneChan := make(chan bool)\n\t\tgo func() {\n\t\t\ts.handle(rr, req)\n\t\t\tdoneChan <- true\n\t\t}()\n\t\ttime.Sleep(1300 * time.Millisecond)\n\t\tcancel()\n\t\t<-doneChan\n\n\t\tmessages := toMessages(t, rr.Body.String())\n\t\trequire.Equal(t, 2, len(messages))\n\n\t\trequire.Equal(t, model.OpenEvent, messages[0].Event)\n\t\trequire.Equal(t, \"mytopic\", messages[0].Topic)\n\t\trequire.Equal(t, \"\", messages[0].Message)\n\t\trequire.Equal(t, \"\", messages[0].Title)\n\t\trequire.Equal(t, 0, messages[0].Priority)\n\t\trequire.Nil(t, messages[0].Tags)\n\n\t\trequire.Equal(t, model.KeepaliveEvent, messages[1].Event)\n\t\trequire.Equal(t, \"mytopic\", messages[1].Topic)\n\t\trequire.Equal(t, \"\", messages[1].Message)\n\t\trequire.Equal(t, \"\", messages[1].Title)\n\t\trequire.Equal(t, 0, messages[1].Priority)\n\t\trequire.Nil(t, messages[1].Tags)\n\t})\n}\n\nfunc TestServer_PublishAndSubscribe(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tsubscribeRR := httptest.NewRecorder()\n\t\tsubscribeCancel := subscribe(t, s, \"/mytopic/json\", subscribeRR)\n\n\t\tpublishFirstRR := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\trequire.Equal(t, 200, publishFirstRR.Code)\n\t\ttime.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races\n\n\t\tpublishSecondRR := request(t, s, \"PUT\", \"/mytopic\", \"my other message\", map[string]string{\n\t\t\t\"Title\":  \" This is a title \",\n\t\t\t\"X-Tags\": \"tag1,tag 2, tag3\",\n\t\t\t\"p\":      \"1\",\n\t\t})\n\t\trequire.Equal(t, 200, publishSecondRR.Code)\n\n\t\tsubscribeCancel()\n\t\tmessages := toMessages(t, subscribeRR.Body.String())\n\t\trequire.Equal(t, 3, len(messages))\n\t\trequire.Equal(t, model.OpenEvent, messages[0].Event)\n\n\t\trequire.Equal(t, model.MessageEvent, messages[1].Event)\n\t\trequire.Equal(t, \"mytopic\", messages[1].Topic)\n\t\trequire.Equal(t, \"my first message\", messages[1].Message)\n\t\trequire.Equal(t, \"\", messages[1].Title)\n\t\trequire.Equal(t, 0, messages[1].Priority)\n\t\trequire.Nil(t, messages[1].Tags)\n\t\trequire.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires)\n\t\trequire.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires)\n\n\t\trequire.Equal(t, model.MessageEvent, messages[2].Event)\n\t\trequire.Equal(t, \"mytopic\", messages[2].Topic)\n\t\trequire.Equal(t, \"my other message\", messages[2].Message)\n\t\trequire.Equal(t, \"This is a title\", messages[2].Title)\n\t\trequire.Equal(t, 1, messages[2].Priority)\n\t\trequire.Equal(t, []string{\"tag1\", \"tag 2\", \"tag3\"}, messages[2].Tags)\n\t})\n}\n\nfunc TestServer_Publish_Disallowed_Topic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.DisallowedTopics = []string{\"about\", \"time\", \"this\", \"got\", \"added\"}\n\t\ts := newTestServer(t, c)\n\n\t\trr := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"PUT\", \"/about\", \"another message\", nil)\n\t\trequire.Equal(t, 400, rr.Code)\n\t\trequire.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_StaticSites(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\trr := request(t, s, \"GET\", \"/\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Contains(t, rr.Body.String(), \"</html>\")\n\n\t\trr = request(t, s, \"HEAD\", \"/\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"OPTIONS\", \"/\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/does-not-exist.txt\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/mytopic\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Contains(t, rr.Body.String(), `<meta name=\"robots\" content=\"noindex, nofollow\" />`)\n\n\t\trr = request(t, s, \"GET\", \"/docs\", \"\", nil)\n\t\trequire.Equal(t, 301, rr.Code)\n\n\t\t// Docs test removed, it was failing annoyingly.\n\t})\n}\n\nfunc TestServer_WebEnabled(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfig(t, databaseURL)\n\t\tconf.WebRoot = \"\" // Disable web app\n\t\ts := newTestServer(t, conf)\n\n\t\trr := request(t, s, \"GET\", \"/\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/config.js\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/sw.js\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/app.html\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\trr = request(t, s, \"GET\", \"/static/css/home.css\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\tconf2 := newTestConfig(t, databaseURL)\n\t\tconf2.WebRoot = \"/\"\n\t\ts2 := newTestServer(t, conf2)\n\n\t\trr = request(t, s2, \"GET\", \"/\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s2, \"GET\", \"/config.js\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s2, \"GET\", \"/sw.js\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\trr = request(t, s2, \"GET\", \"/app.html\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t})\n}\nfunc TestServer_PublishLargeMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentCacheDir = \"\" // Disable attachments\n\t\ts := newTestServer(t, c)\n\n\t\tbody := strings.Repeat(\"this is a large message\", 5000)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", body, nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t})\n}\n\nfunc TestServer_PublishPriority(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tfor prio := 1; prio <= 5; prio++ {\n\t\t\tresponse := request(t, s, \"GET\", fmt.Sprintf(\"/mytopic/publish?priority=%d\", prio), fmt.Sprintf(\"priority %d\", prio), nil)\n\t\t\tmsg := toMessage(t, response.Body.String())\n\t\t\trequire.Equal(t, prio, msg.Priority)\n\t\t}\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/publish?priority=min\", \"test\", nil)\n\t\trequire.Equal(t, 1, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/send?priority=low\", \"test\", nil)\n\t\trequire.Equal(t, 2, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/send?priority=default\", \"test\", nil)\n\t\trequire.Equal(t, 3, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/send?priority=high\", \"test\", nil)\n\t\trequire.Equal(t, 4, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/send?priority=max\", \"test\", nil)\n\t\trequire.Equal(t, 5, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/trigger?priority=urgent\", \"test\", nil)\n\t\trequire.Equal(t, 5, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/trigger?priority=INVALID\", \"test\", nil)\n\t\trequire.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Priority\":   \"u=4\",\n\t\t\t\"X-Priority\": \"5\",\n\t\t})\n\t\trequire.Equal(t, 5, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"POST\", \"/mytopic?priority=4\", \"test\", map[string]string{\n\t\t\t\"Priority\": \"u=9\",\n\t\t})\n\t\trequire.Equal(t, 4, toMessage(t, response.Body.String()).Priority)\n\n\t\tresponse = request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"p\":        \"2\",\n\t\t\t\"priority\": \"u=9, i\",\n\t\t})\n\t\trequire.Equal(t, 2, toMessage(t, response.Body.String()).Priority)\n\t})\n}\n\nfunc TestServer_PublishGETOnlyOneTopic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This tests a bug that allowed publishing topics with a comma in the name (no ticket)\n\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"GET\", \"/mytopic,mytopic2/publish?m=hi\", \"\", nil)\n\t\trequire.Equal(t, 404, response.Code)\n\t})\n}\n\nfunc TestServer_PublishNoCache(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"this message is not cached\", map[string]string{\n\t\t\t\"Cache\": \"no\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"this message is not cached\", msg.Message)\n\t\trequire.Equal(t, int64(0), msg.Expires)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Empty(t, messages)\n\t})\n}\n\nfunc TestServer_PublishAt(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"In\": \"1h\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 0, len(messages))\n\n\t\t// Update message time to the past\n\t\tfakeTime := time.Now().Add(-10 * time.Second).Unix()\n\t\trequire.Nil(t, s.messageCache.UpdateMessageTime(msg.ID, fakeTime))\n\n\t\t// Trigger delayed message sending\n\t\trequire.Nil(t, s.sendDelayedMessages())\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"a message\", messages[0].Message)\n\t\trequire.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender!\n\n\t\tvar err error\n\t\tmessages, err = s.messageCache.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"a message\", messages[0].Message)\n\t\trequire.Equal(t, \"9.9.9.9\", messages[0].Sender.String()) // It's stored in the DB though!\n\t})\n}\n\nfunc TestServer_PublishAt_FromUser(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"In\":            \"1h\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\n\t\t// Message doesn't show up immediately\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 0, len(messages))\n\n\t\t// Update message time to the past\n\t\tfakeTime := time.Now().Add(-10 * time.Second).Unix()\n\t\trequire.Nil(t, s.messageCache.UpdateMessageTime(msg.ID, fakeTime))\n\n\t\t// Trigger delayed message sending\n\t\trequire.Nil(t, s.sendDelayedMessages())\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, fakeTime, messages[0].Time)\n\t\trequire.Equal(t, \"a message\", messages[0].Message)\n\n\t\tvar err error\n\t\tmessages, err = s.messageCache.Messages(\"mytopic\", model.SinceAllMessages, true)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"a message\", messages[0].Message)\n\t\trequire.True(t, strings.HasPrefix(messages[0].User, \"u_\"))\n\t})\n}\n\nfunc TestServer_PublishAt_Expires(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"In\": \"2 days\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix())\n\t\trequire.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix())\n\t})\n}\n\nfunc TestServer_PublishAtWithCacheError(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"Cache\": \"no\",\n\t\t\t\"In\":    \"30 min\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))\n\t})\n}\n\nfunc TestServer_PublishAtTooShortDelay(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"In\": \"1s\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t})\n}\n\nfunc TestServer_PublishAtTooLongDelay(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"In\": \"99999999h\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t})\n}\n\nfunc TestServer_PublishAtInvalidDelay(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?delay=INVALID\", \"a message\", nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40004, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAtTooLarge(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?x-in=99999h\", \"a message\", nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40006, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAtAndPrune(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"a message\", map[string]string{\n\t\t\t\"In\": \"1h\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\ts.execManager() // Fire pruning\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&scheduled=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages)) // Not affected by pruning\n\t\trequire.Equal(t, \"a message\", messages[0].Message)\n\n\t\ttime.Sleep(time.Second) // FIXME CI failing not sure why\n\t})\n}\n\nfunc TestServer_PublishAndMultiPoll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic1\", \"message 1\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"mytopic1\", msg.Topic)\n\t\trequire.Equal(t, \"message 1\", msg.Message)\n\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic2\", \"message 2\", nil)\n\t\tmsg = toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"mytopic2\", msg.Topic)\n\t\trequire.Equal(t, \"message 2\", msg.Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic1/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"mytopic1\", messages[0].Topic)\n\t\trequire.Equal(t, \"message 1\", messages[0].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic1,mytopic2/json?poll=1\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, \"mytopic1\", messages[0].Topic)\n\t\trequire.Equal(t, \"message 1\", messages[0].Message)\n\t\trequire.Equal(t, \"mytopic2\", messages[1].Topic)\n\t\trequire.Equal(t, \"message 2\", messages[1].Message)\n\t})\n}\n\nfunc TestServer_PublishWithNopCache(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.CacheDuration = 0\n\t\ts := newTestServer(t, c)\n\n\t\tsubscribeRR := httptest.NewRecorder()\n\t\tsubscribeCancel := subscribe(t, s, \"/mytopic/json\", subscribeRR)\n\n\t\tpublishRR := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\trequire.Equal(t, 200, publishRR.Code)\n\n\t\tsubscribeCancel()\n\t\tmessages := toMessages(t, subscribeRR.Body.String())\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, model.OpenEvent, messages[0].Event)\n\t\trequire.Equal(t, model.MessageEvent, messages[1].Event)\n\t\trequire.Equal(t, \"my first message\", messages[1].Message)\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Empty(t, messages)\n\t})\n}\n\nfunc TestServer_PublishAndPollSince(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\trequest(t, s, \"PUT\", \"/mytopic\", \"test 1\", nil)\n\t\ttime.Sleep(1100 * time.Millisecond)\n\n\t\tsince := time.Now().Unix()\n\t\trequest(t, s, \"PUT\", \"/mytopic\", \"test 2\", nil)\n\n\t\tresponse := request(t, s, \"GET\", fmt.Sprintf(\"/mytopic/json?poll=1&since=%d\", since), \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"test 2\", messages[0].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&since=10s\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 2, len(messages))\n\t\trequire.Equal(t, \"test 1\", messages[0].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&since=100ms\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"test 2\", messages[0].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&since=latest\", \"\", nil)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"test 2\", messages[0].Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&since=INVALID\", \"\", nil)\n\t\trequire.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message {\n\tm := model.NewDefaultMessage(topic, msg)\n\tm.Time = timestamp\n\treturn m\n}\n\nfunc TestServer_PollSinceID_MultipleTopics(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic1\", \"test 1\", 1655740277)))\n\t\tmarkerMessage := newMessageWithTimestamp(\"mytopic2\", \"test 2\", 1655740283)\n\t\trequire.Nil(t, s.messageCache.AddMessage(markerMessage))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic1\", \"test 3\", 1655740289)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic2\", \"test 4\", 1655740293)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic1\", \"test 5\", 1655740297)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic2\", \"test 6\", 1655740303)))\n\n\t\tresponse := request(t, s, \"GET\", fmt.Sprintf(\"/mytopic1,mytopic2/json?poll=1&since=%s\", markerMessage.ID), \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 4, len(messages))\n\t\trequire.Equal(t, \"test 3\", messages[0].Message)\n\t\trequire.Equal(t, \"mytopic1\", messages[0].Topic)\n\t\trequire.Equal(t, \"test 4\", messages[1].Message)\n\t\trequire.Equal(t, \"mytopic2\", messages[1].Topic)\n\t\trequire.Equal(t, \"test 5\", messages[2].Message)\n\t\trequire.Equal(t, \"mytopic1\", messages[2].Topic)\n\t\trequire.Equal(t, \"test 6\", messages[3].Message)\n\t\trequire.Equal(t, \"mytopic2\", messages[3].Topic)\n\t})\n}\n\nfunc TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic1\", \"test 3\", 1655740289)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic2\", \"test 4\", 1655740293)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic1\", \"test 5\", 1655740297)))\n\t\trequire.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp(\"mytopic2\", \"test 6\", 1655740303)))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic1,mytopic2/json?poll=1&since=NoMatchForID\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 4, len(messages))\n\t\trequire.Equal(t, \"test 3\", messages[0].Message)\n\t\trequire.Equal(t, \"test 4\", messages[1].Message)\n\t\trequire.Equal(t, \"test 5\", messages[2].Message)\n\t\trequire.Equal(t, \"test 6\", messages[3].Message)\n\t})\n}\n\nfunc TestServer_PublishViaGET(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/trigger\", \"\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"triggered\", msg.Message)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h\", \"\", nil)\n\t\tmsg = toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"This is a test\", msg.Message)\n\t\trequire.Equal(t, \"This is a title\", msg.Title)\n\t\trequire.Equal(t, []string{\"skull\"}, msg.Tags)\n\t\trequire.Equal(t, 5, msg.Priority)\n\t\trequire.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())\n\t})\n}\n\nfunc TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"\", map[string]string{\n\t\t\t\"Message\": \"Line 1\\\\nLine 2\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"Line 1\\nLine 2\", msg.Message) // \\\\n -> \\n !\n\t})\n}\n\nfunc TestServer_PublishInvalidTopic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.smtpSender = &testMailer{}\n\t\tresponse := request(t, s, \"PUT\", \"/docs\", \"fail\", nil)\n\t\trequire.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishWithSIDInPath(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic/sid\", \"message\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"sid\", msg.SequenceID)\n\t})\n}\n\nfunc TestServer_PublishWithSIDInHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"message\", map[string]string{\n\t\t\t\"sid\": \"sid\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"sid\", msg.SequenceID)\n\t})\n}\n\nfunc TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/sid1\", \"message\", map[string]string{\n\t\t\t\"sid\": \"sid2\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"sid1\", msg.SequenceID) // Sequence ID in path has priority over header\n\t})\n}\n\nfunc TestServer_PublishWithSIDInQuery(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?sid=sid1\", \"message\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"sid1\", msg.SequenceID)\n\t})\n}\n\nfunc TestServer_PublishWithSIDViaGet(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/publish?sid=sid1\", \"message\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"sid1\", msg.SequenceID)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_WithSequenceID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tbody := `{\"topic\":\"mytopic\",\"message\":\"A message\",\"sequence_id\":\"my-sequence-123\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"my-sequence-123\", msg.SequenceID)\n\t})\n}\n\nfunc TestServer_PublishWithInvalidSIDInPath(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic/.\", \"message\", nil)\n\n\t\trequire.Equal(t, 404, response.Code)\n\t})\n}\n\nfunc TestServer_PublishWithInvalidSIDInHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"message\", map[string]string{\n\t\t\t\"X-Sequence-ID\": \"*&?\",\n\t\t})\n\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PollWithQueryFilters(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?priority=1&tags=tag1,tag2\", \"my first message\", nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic?title=a+title\", \"my second message\", map[string]string{\n\t\t\t\"Tags\": \"tag2,tag3\",\n\t\t})\n\t\tmsg = toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\n\t\tqueriesThatShouldReturnMessageOne := []string{\n\t\t\t\"/mytopic/json?poll=1&priority=1\",\n\t\t\t\"/mytopic/json?poll=1&priority=min\",\n\t\t\t\"/mytopic/json?poll=1&priority=min,low\",\n\t\t\t\"/mytopic/json?poll=1&priority=1,2\",\n\t\t\t\"/mytopic/json?poll=1&p=2,min\",\n\t\t\t\"/mytopic/json?poll=1&tags=tag1\",\n\t\t\t\"/mytopic/json?poll=1&tags=tag1,tag2\",\n\t\t\t\"/mytopic/json?poll=1&message=my+first+message\",\n\t\t}\n\t\tfor _, query := range queriesThatShouldReturnMessageOne {\n\t\t\tresponse = request(t, s, \"GET\", query, \"\", nil)\n\t\t\tmessages := toMessages(t, response.Body.String())\n\t\t\trequire.Equal(t, 1, len(messages), \"Query failed: \"+query)\n\t\t\trequire.Equal(t, \"my first message\", messages[0].Message, \"Query failed: \"+query)\n\t\t}\n\n\t\tqueriesThatShouldReturnMessageTwo := []string{\n\t\t\t\"/mytopic/json?poll=1&x-priority=3\", // !\n\t\t\t\"/mytopic/json?poll=1&priority=3\",\n\t\t\t\"/mytopic/json?poll=1&priority=default\",\n\t\t\t\"/mytopic/json?poll=1&p=3\",\n\t\t\t\"/mytopic/json?poll=1&x-tags=tag2,tag3\",\n\t\t\t\"/mytopic/json?poll=1&tags=tag2,tag3\",\n\t\t\t\"/mytopic/json?poll=1&tag=tag2,tag3\",\n\t\t\t\"/mytopic/json?poll=1&ta=tag2,tag3\",\n\t\t\t\"/mytopic/json?poll=1&x-title=a+title\",\n\t\t\t\"/mytopic/json?poll=1&title=a+title\",\n\t\t\t\"/mytopic/json?poll=1&t=a+title\",\n\t\t\t\"/mytopic/json?poll=1&x-message=my+second+message\",\n\t\t\t\"/mytopic/json?poll=1&message=my+second+message\",\n\t\t\t\"/mytopic/json?poll=1&m=my+second+message\",\n\t\t\t\"/mytopic/json?x-poll=1&m=my+second+message\",\n\t\t\t\"/mytopic/json?po=1&m=my+second+message\",\n\t\t}\n\t\tfor _, query := range queriesThatShouldReturnMessageTwo {\n\t\t\tresponse = request(t, s, \"GET\", query, \"\", nil)\n\t\t\tmessages := toMessages(t, response.Body.String())\n\t\t\trequire.Equal(t, 1, len(messages), \"Query failed: \"+query)\n\t\t\trequire.Equal(t, \"my second message\", messages[0].Message, \"Query failed: \"+query)\n\t\t}\n\n\t\tqueriesThatShouldReturnNoMessages := []string{\n\t\t\t\"/mytopic/json?poll=1&priority=4\",\n\t\t\t\"/mytopic/json?poll=1&tags=tag1,tag2,tag3\",\n\t\t\t\"/mytopic/json?poll=1&title=another+title\",\n\t\t\t\"/mytopic/json?poll=1&message=my+third+message\",\n\t\t\t\"/mytopic/json?poll=1&message=my+third+message\",\n\t\t}\n\t\tfor _, query := range queriesThatShouldReturnNoMessages {\n\t\t\tresponse = request(t, s, \"GET\", query, \"\", nil)\n\t\t\tmessages := toMessages(t, response.Body.String())\n\t\t\trequire.Equal(t, 0, len(messages), \"Query failed: \"+query)\n\t\t}\n\t})\n}\n\nfunc TestServer_SubscribeWithQueryFilters(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.KeepaliveInterval = 800 * time.Millisecond\n\t\ts := newTestServer(t, c)\n\n\t\tsubscribeResponse := httptest.NewRecorder()\n\t\tsubscribeCancel := subscribe(t, s, \"/mytopic/json?tags=zfs-issue\", subscribeResponse)\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"my first message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"ZFS scrub failed\", map[string]string{\n\t\t\t\"Tags\": \"zfs-issue,zfs-scrub\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\ttime.Sleep(850 * time.Millisecond)\n\t\tsubscribeCancel()\n\n\t\tmessages := toMessages(t, subscribeResponse.Body.String())\n\t\trequire.Equal(t, 3, len(messages))\n\t\trequire.Equal(t, model.OpenEvent, messages[0].Event)\n\t\trequire.Equal(t, model.MessageEvent, messages[1].Event)\n\t\trequire.Equal(t, \"ZFS scrub failed\", messages[1].Message)\n\t\trequire.Equal(t, model.KeepaliveEvent, messages[2].Event)\n\t})\n}\n\nfunc TestServer_Auth_Success_Admin(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\t})\n}\n\nfunc TestServer_Auth_Success_User(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"mytopic\", user.PermissionReadWrite))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t})\n}\n\nfunc TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"mytopic\", user.PermissionReadWrite))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"anothertopic\", user.PermissionReadWrite))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic,anothertopic/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic,anothertopic,NOT-THIS-ONE/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 403, response.Code)\n\t})\n}\n\nfunc TestServer_Auth_Fail_InvalidPass(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"INVALID\"),\n\t\t})\n\t\trequire.Equal(t, 401, response.Code)\n\t})\n}\n\nfunc TestServer_Auth_Fail_Unauthorized(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"sometopic\", user.PermissionReadWrite)) // Not mytopic!\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/auth\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 403, response.Code)\n\t})\n}\n\nfunc TestServer_Auth_Fail_CannotPublish(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionReadWrite // Open by default\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleAdmin, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(user.Everyone, \"private\", user.PermissionDenyAll))\n\t\trequire.Nil(t, s.userManager.AllowAccess(user.Everyone, \"announcements\", user.PermissionRead))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"PUT\", \"/announcements\", \"test\", nil)\n\t\trequire.Equal(t, 403, response.Code) // Cannot write as anonymous\n\n\t\tresponse = request(t, s, \"PUT\", \"/announcements\", \"test\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"GET\", \"/announcements/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code) // Anonymous read allowed\n\n\t\tresponse = request(t, s, \"GET\", \"/private/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 403, response.Code) // Anonymous read not allowed\n\t})\n}\n\nfunc TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorAuthFailureLimitBurst = 10\n\t\ts := newTestServer(t, c)\n\n\t\tfor i := 0; i < 10; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/announcements\", \"test\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 401, response.Code)\n\t\t}\n\n\t\tresponse := request(t, s, \"PUT\", \"/announcements\", \"test\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 429, response.Code)\n\t\trequire.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_Auth_ViaQuery(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, c)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"some pass\", user.RoleAdmin, false))\n\n\t\tu := fmt.Sprintf(\"/mytopic/json?poll=1&auth=%s\", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth(\"ben\", \"some pass\"))))\n\t\tresponse := request(t, s, \"GET\", u, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tu = fmt.Sprintf(\"/mytopic/json?poll=1&auth=%s\", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth(\"ben\", \"WRONNNGGGG\"))))\n\t\tresponse = request(t, s, \"GET\", u, \"\", nil)\n\t\trequire.Equal(t, 401, response.Code)\n\t})\n}\n\nfunc TestServer_Auth_NonBasicHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Authorization\": \"WebPush not-supported\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Authorization\": \"Bearer supported\",\n\t\t})\n\t\trequire.Equal(t, 401, response.Code)\n\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Authorization\": \"basic supported\",\n\t\t})\n\t\trequire.Equal(t, 401, response.Code)\n\t})\n}\n\nfunc TestServer_StatsResetter(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\t// This tests the stats resetter for\n\t\t// - an anonymous user\n\t\t// - a user without a tier (treated like the same as the anonymous user)\n\t\t// - a user with a tier\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorStatsResetTime = time.Now().Add(2 * time.Second)\n\t\ts := newTestServer(t, c)\n\t\tgo s.runStatsResetter()\n\n\t\t// Create user with tier (tieruser) and user without tier (phil)\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                  \"test\",\n\t\t\tMessageLimit:          5,\n\t\t\tMessageExpiryDuration: -5 * time.Second, // Second, what a hack!\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AddUser(\"tieruser\", \"tieruser\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"tieruser\", \"test\"))\n\n\t\t// Send an anonymous message\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Send messages from user without tier (phil)\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\n\t\t// Send messages from user with tier\n\t\tfor i := 0; i < 2; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"tieruser\", \"tieruser\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\n\t\t// User stats show 6 messages (for user without tier)\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(6), account.Stats.Messages)\n\n\t\t// User stats show 6 messages (for anonymous visitor)\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(6), account.Stats.Messages)\n\n\t\t// User stats show 2 messages (for user with tier)\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"tieruser\", \"tieruser\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(2), account.Stats.Messages)\n\n\t\t// Wait for stats resetter to run\n\t\twaitFor(t, func() bool {\n\t\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\t\trequire.Nil(t, err)\n\t\t\treturn account.Stats.Messages == 0\n\t\t})\n\n\t\t// User stats show 0 messages now!\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), account.Stats.Messages)\n\n\t\t// Since this is a user without a tier, the anonymous user should have the same stats\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), account.Stats.Messages)\n\n\t\t// User stats show 0 messages (for user with tier)\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"tieruser\", \"tieruser\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), account.Stats.Messages)\n\t})\n}\n\nfunc TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket)\n\t\t// is reset by the stats resetter\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\ts := newTestServer(t, c)\n\t\ts.smtpSender = &testMailer{}\n\n\t\t// Publish some messages, and check stats\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Email\": \"test@email.com\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\trr := request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(4), account.Stats.Messages)\n\t\trequire.Equal(t, int64(1), account.Stats.Emails)\n\t\tv := s.visitor(netip.MustParseAddr(\"9.9.9.9\"), nil)\n\t\trequire.Equal(t, int64(4), v.Stats().Messages)\n\t\trequire.Equal(t, int64(4), v.messagesLimiter.Value())\n\t\trequire.Equal(t, int64(1), v.Stats().Emails)\n\t\trequire.Equal(t, int64(1), v.emailsLimiter.Value())\n\n\t\t// Reset stats and check again\n\t\ts.resetStats()\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), account.Stats.Messages)\n\t\trequire.Equal(t, int64(0), account.Stats.Emails)\n\t\tv = s.visitor(netip.MustParseAddr(\"9.9.9.9\"), nil)\n\t\trequire.Equal(t, int64(0), v.Stats().Messages)\n\t\trequire.Equal(t, int64(0), v.messagesLimiter.Value())\n\t\trequire.Equal(t, int64(0), v.Stats().Emails)\n\t\trequire.Equal(t, int64(0), v.emailsLimiter.Value())\n\t})\n}\n\nfunc TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\n\t\t// This tests that the daily message quota is prefilled originally from the database,\n\t\t// if the visitor is unknown\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthStatsQueueWriterInterval = 100 * time.Millisecond\n\t\ts := newTestServer(t, c)\n\n\t\t// Create user, and update it with some message and email stats\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode: \"test\",\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"test\"))\n\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\ts.userManager.EnqueueUserStats(u.ID, &user.Stats{\n\t\t\tMessages: 123456,\n\t\t\tEmails:   999,\n\t\t})\n\t\ttime.Sleep(400 * time.Millisecond)\n\n\t\t// Get account and verify stats are read from the DB, and that the visitor also has these stats\n\t\trr := request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\taccount, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(123456), account.Stats.Messages)\n\t\trequire.Equal(t, int64(999), account.Stats.Emails)\n\t\tv := s.visitor(netip.MustParseAddr(\"9.9.9.9\"), u)\n\t\trequire.Equal(t, int64(123456), v.Stats().Messages)\n\t\trequire.Equal(t, int64(123456), v.messagesLimiter.Value())\n\t\trequire.Equal(t, int64(999), v.Stats().Emails)\n\t\trequire.Equal(t, int64(999), v.emailsLimiter.Value())\n\t})\n}\n\ntype testMailer struct {\n\tcount int\n\tmu    sync.Mutex\n}\n\nfunc (t *testMailer) Send(v *visitor, m *model.Message, to string) error {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tt.count++\n\treturn nil\n}\n\nfunc (t *testMailer) Counts() (total int64, success int64, failure int64) {\n\treturn 0, 0, 0\n}\n\nfunc (t *testMailer) Count() int {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\treturn t.count\n}\n\nfunc TestServer_PublishTooManyRequests_Defaults(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tfor i := 0; i < 60; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"message\", nil)\n\t\trequire.Equal(t, 429, response.Code)\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\toverrideRemoteAddr1 := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"[2001:db8:9999:8888:1::1]:1234\"\n\t\t}\n\t\toverrideRemoteAddr2 := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"[2001:db8:9999:8888:2::1]:1234\" // Same /64\n\t\t}\n\t\tfor i := 0; i < 30; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil, overrideRemoteAddr1)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tfor i := 0; i < 30; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil, overrideRemoteAddr2)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"message\", nil, overrideRemoteAddr1)\n\t\trequire.Equal(t, 429, response.Code)\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 6\n\t\tc.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes\n\t\ts := newTestServer(t, c)\n\t\toverrideRemoteAddr1 := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"[2001:db8:9999::1]:1234\"\n\t\t}\n\t\toverrideRemoteAddr2 := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"[2001:db8:9999::2]:1234\" // Same /48\n\t\t}\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil, overrideRemoteAddr1)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil, overrideRemoteAddr2)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"message\", nil, overrideRemoteAddr1)\n\t\trequire.Equal(t, 429, response.Code)\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix(\"9.9.9.9/32\")} // see request()\n\t\ts := newTestServer(t, c)\n\t\tfor i := 0; i < 5; i++ { // > 3\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix(\"2001:db8:9999::/48\")}\n\t\ts := newTestServer(t, c)\n\t\toverrideRemoteAddr := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"[2001:db8:9999::1]:1234\"\n\t\t}\n\t\tfor i := 0; i < 5; i++ { // > 3\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil, overrideRemoteAddr)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 10\n\t\tc.VisitorMessageDailyLimit = 4\n\t\tc.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix(\"9.9.9.9/32\")} // see request()\n\t\ts := newTestServer(t, c)\n\t\tfor i := 0; i < 8; i++ { // 4\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"message\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t})\n}\n\nfunc TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 60\n\t\tc.VisitorRequestLimitReplenish = time.Second\n\t\ts := newTestServer(t, c)\n\t\tfor i := 0; i < 60; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"message\", nil)\n\t\trequire.Equal(t, 429, response.Code)\n\n\t\ttime.Sleep(1020 * time.Millisecond)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t})\n}\n\nfunc TestServer_PublishTooManyEmails_Defaults(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.smtpSender = &testMailer{}\n\t\tfor i := 0; i < 16; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), map[string]string{\n\t\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"one too many\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 429, response.Code)\n\t})\n}\n\nfunc TestServer_PublishTooManyEmails_Replenish(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorEmailLimitReplenish = 500 * time.Millisecond\n\t\ts := newTestServer(t, c)\n\t\ts.smtpSender = &testMailer{}\n\t\tfor i := 0; i < 16; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"message %d\", i), map[string]string{\n\t\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"one too many\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 429, response.Code)\n\n\t\ttime.Sleep(510 * time.Millisecond)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"this should be okay again too many\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", \"and bad again\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 429, response.Code)\n\t})\n}\n\nfunc TestServer_PublishDelayedEmail_Fail(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.smtpSender = &testMailer{}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"fail\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t\t\"Delay\":  \"20 min\",\n\t\t})\n\t\trequire.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishDelayedCall_Fail(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"fail\", map[string]string{\n\t\t\t\"Call\":  \"yes\",\n\t\t\t\"Delay\": \"20 min\",\n\t\t})\n\t\trequire.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishEmailNoMailer_Fail(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"fail\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t})\n}\n\nfunc TestServer_PublishEmailAddressInvalid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.smtpSender = &testMailer{}\n\t\taddresses := []string{\n\t\t\t\"test@example.com, other@example.com\",\n\t\t\t\"invalidaddress\",\n\t\t\t\"@nope\",\n\t\t\t\"nope@\",\n\t\t}\n\t\tfor _, email := range addresses {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"fail\", map[string]string{\n\t\t\t\t\"E-Mail\": email,\n\t\t\t})\n\t\t\trequire.Equal(t, 400, response.Code, \"expected 400 for email: %s\", email)\n\t\t}\n\t\t// Valid address should succeed\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"success\", map[string]string{\n\t\t\t\"E-Mail\": \"test@example.com\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t})\n}\n\nfunc TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tdefer s.messageCache.Close()\n\n\t\tsubFn := func(v *visitor, msg *model.Message) error {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Publish and check last access\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"Cache\": \"no\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\twaitFor(t, func() bool {\n\t\t\ts.mu.Lock()\n\t\t\ttp, exists := s.topics[\"mytopic\"]\n\t\t\ts.mu.Unlock()\n\t\t\tif !exists {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\t// .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine\n\t\t\ttp.mu.RLock()\n\t\t\tdefer tp.mu.RUnlock()\n\t\t\treturn tp.lastAccess.Unix() >= time.Now().Unix()-2 &&\n\t\t\t\ttp.lastAccess.Unix() <= time.Now().Unix()+2\n\t\t})\n\n\t\t// Hack!\n\t\ttime.Sleep(time.Second)\n\n\t\t// Topic won't get pruned\n\t\ts.execManager()\n\t\trequire.NotNil(t, s.topics[\"mytopic\"])\n\n\t\t// Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber)\n\t\tsubID := s.topics[\"mytopic\"].Subscribe(subFn, \"\", func() {})\n\t\ts.topics[\"mytopic\"].mu.Lock()\n\t\ts.topics[\"mytopic\"].lastAccess = time.Now().Add(-17 * time.Hour)\n\t\ts.topics[\"mytopic\"].mu.Unlock()\n\t\ts.execManager()\n\t\trequire.NotNil(t, s.topics[\"mytopic\"])\n\n\t\t// It'll finally get pruned now that there are no subscribers and last access is 17 hours ago\n\t\ts.topics[\"mytopic\"].Unsubscribe(subID)\n\t\ts.execManager()\n\t\trequire.Nil(t, s.topics[\"mytopic\"])\n\t})\n}\n\nfunc TestServer_TopicKeepaliveOnPoll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Create topic by polling once\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Mess with last access time\n\t\ts.topics[\"mytopic\"].lastAccess = time.Now().Add(-17 * time.Hour)\n\n\t\t// Poll again and check keepalive time\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.True(t, s.topics[\"mytopic\"].lastAccess.Unix() >= time.Now().Unix()-2)\n\t\trequire.True(t, s.topics[\"mytopic\"].lastAccess.Unix() <= time.Now().Unix()+2)\n\t})\n}\n\nfunc TestServer_UnifiedPushDiscovery(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"GET\", \"/mytopic?up=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"unifiedpush\":{\"version\":1}}`+\"\\n\", response.Body.String())\n\t})\n}\n\nfunc TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tb := make([]byte, 12) // Max length\n\t\t_, err := rand.Read(b)\n\t\trequire.Nil(t, err)\n\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Register a UnifiedPush subscriber\n\t\tresponse := request(t, s, \"GET\", \"/up123456789012/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Publish message to topic\n\t\tresponse = request(t, s, \"PUT\", \"/up123456789012?up=1\", string(b), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"base64\", m.Encoding)\n\t\tb2, err := base64.StdEncoding.DecodeString(m.Message)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, b, b2)\n\n\t\t// Retrieve and check published message\n\t\tresponse = request(t, s, \"GET\", \"/up123456789012/json?poll=1\", string(b), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm = toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"base64\", m.Encoding)\n\t\tb2, err = base64.StdEncoding.DecodeString(m.Message)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, b, b2)\n\t})\n}\n\nfunc TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tb := make([]byte, 5000) // Longer than max length\n\t\t_, err := rand.Read(b)\n\t\trequire.Nil(t, err)\n\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Register a UnifiedPush subscriber\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Publish message to topic\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic?up=1\", string(b), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"base64\", m.Encoding)\n\t\tb2, err := base64.StdEncoding.DecodeString(m.Message)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 4096, len(b2))\n\t\trequire.Equal(t, b[:4096], b2)\n\t})\n}\n\nfunc TestServer_PublishUnifiedPushText(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Register a UnifiedPush subscriber\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Publish UnifiedPush text message\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic?up=1\", \"this is a unifiedpush text message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"\", m.Encoding)\n\t\trequire.Equal(t, \"this is a unifiedpush text message\", m.Message)\n\t})\n}\n\nfunc TestServer_MatrixGateway_Discovery_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"GET\", \"/_matrix/push/v1/notify\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"unifiedpush\":{\"gateway\":\"matrix\"}}`+\"\\n\", response.Body.String())\n\t})\n}\n\nfunc TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BaseURL = \"\"\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"GET\", \"/_matrix/push/v1/notify\", \"\", nil)\n\t\trequire.Equal(t, 500, response.Code)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 50003, err.Code)\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tnotification := `{\"notification\":{\"devices\":[{\"pushkey\":\"http://127.0.0.1:12345/mytopic?up=1\"}]}}`\n\t\tresponse = request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"rejected\":[]}`+\"\\n\", response.Body.String())\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, notification, m.Message)\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\t\tnotification := `{\"notification\":{\"devices\":[{\"pushkey\":\"http://127.0.0.1:12345/mytopic?up=1\"}]}}`\n\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 507, response.Code)\n\t\trequire.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\t\tnotification := `{\"notification\":{\"devices\":[{\"pushkey\":\"http://127.0.0.1:12345/mytopic?up=1\"}]}}`\n\n\t\t// No success if no rate visitor set (this also creates the topic in memory)\n\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 507, response.Code)\n\t\trequire.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)\n\t\trequire.Nil(t, s.topics[\"mytopic\"].rateVisitor)\n\n\t\t// Fake: This topic has been around for 13 hours without a rate visitor\n\t\ts.topics[\"mytopic\"].lastAccess = time.Now().Add(-13 * time.Hour)\n\n\t\t// Same request should now return HTTP 200 with a rejected pushkey\n\t\tresponse = request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"rejected\":[\"http://127.0.0.1:12345/mytopic?up=1\"]}`, strings.TrimSpace(response.Body.String()))\n\n\t\t// Slightly unrelated: Test that topic is pruned after 16 hours\n\t\ts.topics[\"mytopic\"].lastAccess = time.Now().Add(-17 * time.Hour)\n\t\ts.execManager()\n\t\trequire.Nil(t, s.topics[\"mytopic\"])\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tnotification := `{\"notification\":{\"devices\":[{\"pushkey\":\"http://wrong-base-url.com/mytopic?up=1\"}]}}`\n\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"rejected\":[\"http://wrong-base-url.com/mytopic?up=1\"]}`+\"\\n\", response.Body.String())\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, \"\", response.Body.String()) // Empty!\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tnotification := `{\"message\":\"this is not really a Matrix message\"}`\n\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)\n\n\t\tnotification = `this isn't even JSON'`\n\t\tresponse = request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BaseURL = \"\"\n\t\ts := newTestServer(t, c)\n\t\tnotification := `{\"notification\":{\"devices\":[{\"pushkey\":\"http://127.0.0.1:12345/mytopic?up=1\"}]}}`\n\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\trequire.Equal(t, 500, response.Code)\n\t\trequire.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishActions_AndPoll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"my message\", map[string]string{\n\t\t\t\"Actions\": \"view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 2, len(m.Actions))\n\t\trequire.Equal(t, \"view\", m.Actions[0].Action)\n\t\trequire.Equal(t, \"Open portal\", m.Actions[0].Label)\n\t\trequire.Equal(t, \"https://home.nest.com/\", m.Actions[0].URL)\n\t\trequire.Equal(t, \"http\", m.Actions[1].Action)\n\t\trequire.Equal(t, \"Turn down\", m.Actions[1].Label)\n\t\trequire.Equal(t, \"https://api.nest.com/device/XZ1D2\", m.Actions[1].URL)\n\t\trequire.Equal(t, \"target_temp_f=65\", m.Actions[1].Body)\n\t})\n}\n\nfunc TestServer_PublishMarkdown(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"**make this bold**\", map[string]string{\n\t\t\t\"Content-Type\": \"text/markdown\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"**make this bold**\", m.Message)\n\t\trequire.Equal(t, \"text/markdown\", m.ContentType)\n\t})\n}\n\nfunc TestServer_PublishMarkdown_QueryParam(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?md=1\", \"**make this bold**\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"**make this bold**\", m.Message)\n\t\trequire.Equal(t, \"text/markdown\", m.ContentType)\n\t})\n}\n\nfunc TestServer_PublishMarkdown_NotMarkdown(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"**make this bold**\", map[string]string{\n\t\t\t\"Content-Type\": \"not-markdown\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"\", m.ContentType)\n\t})\n}\n\nfunc TestServer_PublishAsJSON(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\":\"mytopic\",\"message\":\"A message\",\"title\":\"a title\\nwith lines\",\"tags\":[\"tag1\",\"tag 2\"],` +\n\t\t\t`\"not-a-thing\":\"ok\", \"attach\":\"http://google.com\",\"filename\":\"google.pdf\", \"click\":\"http://ntfy.sh\",\"priority\":4,` +\n\t\t\t`\"icon\":\"https://ntfy.sh/static/img/ntfy.png\", \"delay\":\"30min\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"mytopic\", m.Topic)\n\t\trequire.Equal(t, \"A message\", m.Message)\n\t\trequire.Equal(t, \"a title\\nwith lines\", m.Title)\n\t\trequire.Equal(t, []string{\"tag1\", \"tag 2\"}, m.Tags)\n\t\trequire.Equal(t, \"http://google.com\", m.Attachment.URL)\n\t\trequire.Equal(t, \"google.pdf\", m.Attachment.Name)\n\t\trequire.Equal(t, \"http://ntfy.sh\", m.Click)\n\t\trequire.Equal(t, \"https://ntfy.sh/static/img/ntfy.png\", m.Icon)\n\t\trequire.Equal(t, \"\", m.ContentType)\n\n\t\trequire.Equal(t, 4, m.Priority)\n\t\trequire.True(t, m.Time > time.Now().Unix()+29*60)\n\t\trequire.True(t, m.Time < time.Now().Unix()+31*60)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_Markdown(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\":\"mytopic\",\"message\":\"**This is bold**\",\"markdown\":true}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"mytopic\", m.Topic)\n\t\trequire.Equal(t, \"**This is bold**\", m.Message)\n\t\trequire.Equal(t, \"text/markdown\", m.ContentType)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// Publishing as JSON follows a different path. This ensures that rate\n\t\t// limiting works for this endpoint as well\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorMessageDailyLimit = 3\n\t\ts := newTestServer(t, c)\n\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/\", `{\"topic\":\"mytopic\",\"message\":\"A message\"}`, nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/\", `{\"topic\":\"mytopic\",\"message\":\"A message\"}`, nil)\n\t\trequire.Equal(t, 429, response.Code)\n\t\trequire.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_WithEmail(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tmailer := &testMailer{}\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.smtpSender = mailer\n\t\tbody := `{\"topic\":\"mytopic\",\"message\":\"A message\",\"email\":\"phil@example.com\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\ttime.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"mytopic\", m.Topic)\n\t\trequire.Equal(t, \"A message\", m.Message)\n\t\trequire.Equal(t, 1, mailer.Count())\n\t})\n}\n\nfunc TestServer_PublishAsJSON_WithActions(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\n\t\t\"topic\":\"mytopic\",\n\t\t\"message\":\"A message\",\n\t\t\"actions\": [\n\t\t\t  {\n\t\t\t\t\"action\": \"view\",\n\t\t\t\t\"label\": \"Open portal\",\n\t\t\t\t\"url\": \"https://home.nest.com/\"\n\t\t\t  },\n\t\t\t  {\n\t\t\t\t\"action\": \"http\",\n\t\t\t\t\"label\": \"Turn down\",\n\t\t\t\t\"url\": \"https://api.nest.com/device/XZ1D2\",\n\t\t\t\t\"body\": \"target_temp_f=65\"\n\t\t\t  }\n\t\t]\n\t}`\n\t\tresponse := request(t, s, \"POST\", \"/\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"mytopic\", m.Topic)\n\t\trequire.Equal(t, \"A message\", m.Message)\n\t\trequire.Equal(t, 2, len(m.Actions))\n\t\trequire.Equal(t, \"view\", m.Actions[0].Action)\n\t\trequire.Equal(t, \"Open portal\", m.Actions[0].Label)\n\t\trequire.Equal(t, \"https://home.nest.com/\", m.Actions[0].URL)\n\t\trequire.Equal(t, \"http\", m.Actions[1].Action)\n\t\trequire.Equal(t, \"Turn down\", m.Actions[1].Label)\n\t\trequire.Equal(t, \"https://api.nest.com/device/XZ1D2\", m.Actions[1].URL)\n\t\trequire.Equal(t, \"target_temp_f=65\", m.Actions[1].Body)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_NoCache(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\":\"mytopic\",\"message\": \"this message is not cached\",\"cache\":\"no\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg.ID)\n\t\trequire.Equal(t, \"this message is not cached\", msg.Message)\n\t\trequire.Equal(t, int64(0), msg.Expires)\n\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Empty(t, messages)\n\t})\n}\n\nfunc TestServer_PublishAsJSON_WithoutFirebase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsender := newTestFirebaseSender(10)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\t\tbody := `{\"topic\":\"mytopic\",\"message\": \"my first message\",\"firebase\":\"no\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg1.ID)\n\t\trequire.Equal(t, \"my first message\", msg1.Message)\n\n\t\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens\n\t\trequire.Equal(t, 0, len(sender.Messages()))\n\t})\n}\n\nfunc TestServer_PublishAsJSON_Invalid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\":\"mytopic\",INVALID`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t})\n}\n\nfunc TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\ts := newTestServer(t, c)\n\n\t\t// Create tier with certain limits\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                  \"test\",\n\t\t\tMessageLimit:          5,\n\t\t\tMessageExpiryDuration: -5 * time.Second, // Second, what a hack!\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"test\"))\n\n\t\t// Publish to reach message limit\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", fmt.Sprintf(\"this is message %d\", i+1), map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\tmsg := toMessage(t, response.Body.String())\n\t\t\trequire.True(t, msg.Expires < time.Now().Unix()+5)\n\t\t}\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"this is too much\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 429, response.Code)\n\n\t\t// Run pruning and see if they are gone\n\t\ts.execManager()\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Empty(t, response.Body)\n\t})\n}\n\nfunc TestServer_PublishAttachment(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := \"text file!\" + util.RandomString(4990) // > 4096\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"attachment.txt\", msg.Attachment.Name)\n\t\trequire.Equal(t, \"text/plain; charset=utf-8\", msg.Attachment.Type)\n\t\trequire.Equal(t, int64(5000), msg.Attachment.Size)\n\t\trequire.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\trequire.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))\n\n\t\t// GET\n\t\tpath := strings.TrimPrefix(msg.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, \"5000\", response.Header().Get(\"Content-Length\"))\n\t\trequire.Equal(t, content, response.Body.String())\n\n\t\t// HEAD\n\t\tresponse = request(t, s, \"HEAD\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, \"5000\", response.Header().Get(\"Content-Length\"))\n\t\trequire.Equal(t, \"\", response.Body.String())\n\n\t\t// Slightly unrelated cross-test: make sure we add an owner for internal attachments\n\t\tsize, err := s.messageCache.AttachmentBytesUsedBySender(\"9.9.9.9\") // See request()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(5000), size)\n\t})\n}\n\nfunc TestServer_PublishAttachmentShortWithFilename(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\ts := newTestServer(t, c)\n\t\tcontent := \"this is an ATTACHMENT\"\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?f=myfile.txt\", content, map[string]string{\n\t\t\t\"X-Forwarded-For\": \"1.2.3.4\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"myfile.txt\", msg.Attachment.Name)\n\t\trequire.Equal(t, \"text/plain; charset=utf-8\", msg.Attachment.Type)\n\t\trequire.Equal(t, int64(21), msg.Attachment.Size)\n\t\trequire.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\trequire.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))\n\n\t\tpath := strings.TrimPrefix(msg.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, \"21\", response.Header().Get(\"Content-Length\"))\n\t\trequire.Equal(t, content, response.Body.String())\n\n\t\t// Slightly unrelated cross-test: make sure we add an owner for internal attachments\n\t\tsize, err := s.messageCache.AttachmentBytesUsedBySender(\"1.2.3.4\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(21), size)\n\t})\n}\n\nfunc TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"\", map[string]string{\n\t\t\t\"Attach\": \"https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"You received a file: Pink_flower.jpg\", msg.Message)\n\t\trequire.Equal(t, \"Pink_flower.jpg\", msg.Attachment.Name)\n\t\trequire.Equal(t, \"https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg\", msg.Attachment.URL)\n\t\trequire.Equal(t, \"\", msg.Attachment.Type)\n\t\trequire.Equal(t, int64(0), msg.Attachment.Size)\n\t\trequire.Equal(t, int64(0), msg.Attachment.Expires)\n\t\trequire.Equal(t, netip.Addr{}, msg.Sender)\n\n\t\t// Slightly unrelated cross-test: make sure we don't add an owner for external attachments\n\t\tsize, err := s.messageCache.AttachmentBytesUsedBySender(\"127.0.0.1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), size)\n\t})\n}\n\nfunc TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"This is a custom message\", map[string]string{\n\t\t\t\"X-Attach\": \"https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg\",\n\t\t\t\"File\":     \"some file.jpg\",\n\t\t})\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"This is a custom message\", msg.Message)\n\t\trequire.Equal(t, \"some file.jpg\", msg.Attachment.Name)\n\t\trequire.Equal(t, \"https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg\", msg.Attachment.URL)\n\t\trequire.Equal(t, \"\", msg.Attachment.Type)\n\t\trequire.Equal(t, int64(0), msg.Attachment.Size)\n\t\trequire.Equal(t, int64(0), msg.Attachment.Expires)\n\t\trequire.Equal(t, netip.Addr{}, msg.Sender)\n\t})\n}\n\nfunc TestServer_PublishAttachmentBadURL(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?a=not+a+URL\", \"\", nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 400, err.HTTPCode)\n\t\trequire.Equal(t, 40013, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(5000) // > 4096\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, map[string]string{\n\t\t\t\"Content-Length\": \"20000000\",\n\t\t})\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 413, err.HTTPCode)\n\t\trequire.Equal(t, 41301, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(5001) // > 5000, see below\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentFileSizeLimit = 5000\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 413, err.HTTPCode)\n\t\trequire.Equal(t, 41301, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentExpiryDuration = 10 * time.Minute\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", util.RandomString(5000), map[string]string{\n\t\t\t\"Delay\": \"11 min\", // > AttachmentExpiryDuration\n\t\t})\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 400, err.HTTPCode)\n\t\trequire.Equal(t, 40015, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorAttachmentTotalSizeLimit = 10000\n\t\ts := newTestServer(t, c)\n\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"text file!\"+util.RandomString(4990), nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, \"You received a file: attachment.txt\", msg.Message)\n\t\trequire.Equal(t, int64(5000), msg.Attachment.Size)\n\n\t\tcontent := util.RandomString(5001) // 5000+5001 > , see below\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 413, err.HTTPCode)\n\t\trequire.Equal(t, 41301, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentAndExpire(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tcontent := util.RandomString(5000) // > 4096\n\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentExpiryDuration = time.Millisecond // Hack\n\t\ts := newTestServer(t, c)\n\n\t\t// Publish and make sure we can retrieve it\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\tfile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)\n\t\trequire.FileExists(t, file)\n\n\t\tpath := strings.TrimPrefix(msg.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, content, response.Body.String())\n\n\t\t// Prune and makes sure it's gone\n\t\twaitFor(t, func() bool {\n\t\t\ts.execManager() // May run many times\n\t\t\treturn !util.FileExists(file)\n\t\t})\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 404, response.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tcontent := util.RandomString(5000) // > 4096\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AttachmentExpiryDuration = time.Millisecond // Hack\n\t\ts := newTestServer(t, c)\n\n\t\t// Create tier with certain limits\n\t\tsevenDays := time.Duration(604800) * time.Second\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                     \"test\",\n\t\t\tMessageLimit:             10,\n\t\t\tMessageExpiryDuration:    sevenDays,\n\t\t\tAttachmentFileSizeLimit:  50_000,\n\t\t\tAttachmentTotalSizeLimit: 200_000,\n\t\t\tAttachmentExpiryDuration: sevenDays, // 7 days\n\t\t\tAttachmentBandwidthLimit: 100000,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"test\"))\n\n\t\t// Publish and make sure we can retrieve it\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\trequire.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())\n\t\trequire.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())\n\t\tfile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)\n\t\trequire.FileExists(t, file)\n\n\t\tpath := strings.TrimPrefix(msg.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, content, response.Body.String())\n\n\t\t// Prune and makes sure it's still there\n\t\ttime.Sleep(time.Second) // Sigh ...\n\t\ts.execManager()\n\t\trequire.FileExists(t, file)\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(5000) // > 4096\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth!\n\t\ts := newTestServer(t, c)\n\n\t\t// Create tier with certain limits\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                     \"test\",\n\t\t\tMessageLimit:             10,\n\t\t\tMessageExpiryDuration:    time.Hour,\n\t\t\tAttachmentFileSizeLimit:  50_000,\n\t\t\tAttachmentTotalSizeLimit: 200_000,\n\t\t\tAttachmentExpiryDuration: time.Hour,\n\t\t\tAttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"test\"))\n\n\t\t// Publish and make sure we can retrieve it\n\t\trr := request(t, s, \"PUT\", \"/mytopic\", content, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\tmsg := toMessage(t, rr.Body.String())\n\n\t\t// Retrieve it (first time succeeds)\n\t\trr = request(t, s, \"GET\", \"/file/\"+msg.ID, content, nil) // File downloads do not send auth headers!!\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, content, rr.Body.String())\n\n\t\t// Retrieve it AGAIN (fails, due to bandwidth limit)\n\t\trr = request(t, s, \"GET\", \"/file/\"+msg.ID, content, nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsmallFile := util.RandomString(20_000)\n\t\tlargeFile := util.RandomString(50_000)\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AttachmentFileSizeLimit = 20_000\n\t\tc.VisitorAttachmentTotalSizeLimit = 40_000\n\t\ts := newTestServer(t, c)\n\n\t\t// Create tier with certain limits\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:                     \"test\",\n\t\t\tMessageLimit:             100,\n\t\t\tAttachmentFileSizeLimit:  50_000,\n\t\t\tAttachmentTotalSizeLimit: 200_000,\n\t\t\tAttachmentExpiryDuration: 30 * time.Second,\n\t\t\tAttachmentBandwidthLimit: 1000000,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"test\"))\n\n\t\t// Publish small file as anonymous\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", smallFile, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))\n\n\t\t// Publish large file as anonymous\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", largeFile, nil)\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)\n\n\t\t// Publish too large file as phil\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", largeFile+\" a few more bytes\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)\n\n\t\t// Publish large file as phil (4x)\n\t\tfor i := 0; i < 4; i++ {\n\t\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", largeFile, map[string]string{\n\t\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\tmsg = toMessage(t, response.Body.String())\n\t\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\t\trequire.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))\n\t\t}\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", largeFile, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(5000) // > 4096\n\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads\n\t\ts := newTestServer(t, c)\n\n\t\t// Publish attachment\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\n\t\t// Value it 4 times successfully\n\t\tpath := strings.TrimPrefix(msg.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\tfor i := 1; i <= 4; i++ { // 4 successful downloads\n\t\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\trequire.Equal(t, content, response.Body.String())\n\t\t}\n\n\t\t// And then fail with a 429\n\t\tresponse = request(t, s, \"GET\", path, \"\", nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 429, response.Code)\n\t\trequire.Equal(t, 42905, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(5000) // > 4096\n\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads\n\t\ts := newTestServer(t, c)\n\n\t\t// 5 successful uploads\n\t\tfor i := 1; i <= 5; i++ {\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\t\tmsg := toMessage(t, response.Body.String())\n\t\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\t\t}\n\n\t\t// And a failed one\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\terr := toHTTPError(t, response.Body.String())\n\t\trequire.Equal(t, 413, response.Code)\n\t\trequire.Equal(t, 41301, err.Code)\n\t})\n}\n\nfunc TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This tests the awkward util.Retry in handleFile: Due to the async persisting of messages,\n\t\t// the message is not immediately available when attempting to download it.\n\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.CacheBatchTimeout = 500 * time.Millisecond\n\t\tc.CacheBatchSize = 10\n\t\ts := newTestServer(t, c)\n\t\tcontent := \"this is an ATTACHMENT\"\n\t\trr := request(t, s, \"PUT\", \"/mytopic?f=myfile.txt\", content, nil)\n\t\tm := toMessage(t, rr.Body.String())\n\t\trequire.Equal(t, \"myfile.txt\", m.Attachment.Name)\n\n\t\tpath := strings.TrimPrefix(m.Attachment.URL, \"http://127.0.0.1:12345\")\n\t\trr = request(t, s, \"GET\", path, \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code) // Not 404!\n\t\trequire.Equal(t, content, rr.Body.String())\n\t})\n}\n\nfunc TestServer_PublishAttachmentAccountStats(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tcontent := util.RandomString(4999) // > 4096\n\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.AttachmentFileSizeLimit = 5000\n\t\tc.VisitorAttachmentTotalSizeLimit = 6000\n\t\ts := newTestServer(t, c)\n\n\t\t// Upload one attachment\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", content, nil)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Contains(t, msg.Attachment.URL, \"http://127.0.0.1:12345/file/\")\n\n\t\t// User stats\n\t\tresponse = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\taccount, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(5000), account.Limits.AttachmentFileSize)\n\t\trequire.Equal(t, int64(6000), account.Limits.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(4999), account.Stats.AttachmentTotalSize)\n\t\trequire.Equal(t, int64(1001), account.Stats.AttachmentTotalSizeRemaining)\n\t\trequire.Equal(t, int64(1), account.Stats.Messages)\n\t})\n}\n\nfunc TestServer_Visitor_XForwardedFor_None(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"8.9.10.11:1234\"\n\t\tr.Header.Set(\"X-Forwarded-For\", \"  \") // Spaces, not empty!\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"8.9.10.11\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_XForwardedFor_Single(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"8.9.10.11:1234\"\n\t\tr.Header.Set(\"X-Forwarded-For\", \"1.1.1.1\")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"1.1.1.1\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"8.9.10.11:1234\"\n\t\tr.Header.Set(\"X-Forwarded-For\", \"1.2.3.4 , 2.4.4.2,234.5.2.1 \")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"234.5.2.1\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\tc.ProxyForwardedHeader = \"X-Client-IP\"\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"8.9.10.11:1234\"\n\t\tr.Header.Set(\"X-Client-IP\", \"1.2.3.4\")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"1.2.3.4\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\tc.ProxyForwardedHeader = \"X-Client-IP\"\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"[2001:db8:9999::1]:1234\"\n\t\tr.Header.Set(\"X-Client-IP\", \"2001:db8:7777::1\")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"2001:db8:7777::1\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\tc.ProxyForwardedHeader = \"Forwarded\"\n\t\tc.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix(\"1.2.3.0/24\")}\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"8.9.10.11:1234\"\n\t\tr.Header.Set(\"Forwarded\", \" for=5.6.7.8, by=example.com;for=1.2.3.4\")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"5.6.7.8\", v.ip.String())\n\t})\n}\n\nfunc TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.BehindProxy = true\n\t\tc.ProxyForwardedHeader = \"Forwarded\"\n\t\tc.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix(\"2001:db8:1111::/64\")}\n\t\ts := newTestServer(t, c)\n\t\tr, _ := http.NewRequest(\"GET\", \"/bla\", nil)\n\t\tr.RemoteAddr = \"[2001:db8:2222::1]:1234\"\n\t\tr.Header.Set(\"Forwarded\", \" for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]\")\n\t\tv, err := s.maybeAuthenticate(r)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"2001:db8:3333::1\", v.ip.String())\n\t})\n}\n\nfunc TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tcount := 50000\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.TotalTopicLimit = 50001\n\t\tc.CacheStartupQueries = \"pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Add lots of messages\n\t\tlog.Info(\"Adding %d messages\", count)\n\t\tstart := time.Now()\n\t\tmessages := make([]*model.Message, 0)\n\t\tfor i := 0; i < count; i++ {\n\t\t\ttopicID := fmt.Sprintf(\"topic%d\", i)\n\t\t\t_, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array\n\t\t\trequire.Nil(t, err)\n\t\t\tmessages = append(messages, model.NewDefaultMessage(topicID, \"some message\"))\n\t\t}\n\t\trequire.Nil(t, s.messageCache.AddMessages(messages))\n\t\tlog.Info(\"Done: Adding %d messages; took %s\", count, time.Since(start).Round(time.Millisecond))\n\n\t\t// Update stats\n\t\tstatsChan := make(chan bool)\n\t\tgo func() {\n\t\t\tlog.Info(\"Updating stats\")\n\t\t\tstart := time.Now()\n\t\t\ts.execManager()\n\t\t\tlog.Info(\"Done: Updating stats; took %s\", time.Since(start).Round(time.Millisecond))\n\t\t\tstatsChan <- true\n\t\t}()\n\t\ttime.Sleep(50 * time.Millisecond) // Make sure it starts first\n\n\t\t// Publish message (during stats update)\n\t\tlog.Info(\"Publishing message\")\n\t\tstart = time.Now()\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"some body\", nil)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"some body\", m.Message)\n\t\trequire.True(t, time.Since(start) < 100*time.Millisecond)\n\t\tlog.Info(\"Done: Publishing message; took %s\", time.Since(start).Round(time.Millisecond))\n\n\t\t// Wait for all Goroutines\n\t\twaitDuration := 10 * time.Second\n\t\tif raceEnabled {\n\t\t\twaitDuration = 35 * time.Second\n\t\t}\n\t\tselect {\n\t\tcase <-statsChan:\n\t\tcase <-time.After(waitDuration):\n\t\t\tt.Fatal(\"Timed out waiting for Go routines\")\n\t\t}\n\t\tlog.Info(\"Done: Waiting for all locks\")\n\t})\n}\n\nfunc TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfigWithAuthFile(t, databaseURL)\n\t\ts := newTestServer(t, conf)\n\t\tdefer s.closeDatabases()\n\n\t\t// Create user without tier\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\n\t\t// Publish a message (anonymous user)\n\t\trr := request(t, s, \"POST\", \"/mytopic\", \"hi\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// Publish a message (non-tier user)\n\t\trr = request(t, s, \"POST\", \"/mytopic\", \"hi\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\n\t\t// User stats (anonymous user)\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", nil)\n\t\taccount, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, int64(2), account.Stats.Messages)\n\n\t\t// User stats (non-tier user)\n\t\trr = request(t, s, \"GET\", \"/v1/account\", \"\", map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\taccount, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))\n\t\trequire.Equal(t, int64(2), account.Stats.Messages)\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\t// \"Register\" visitor 1.2.3.4 to topic \"upAAAAAAAAAAAA\" as a rate limit visitor\n\t\tsubscriber1Fn := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"1.2.3.4:1234\"\n\t\t}\n\t\trr := request(t, s, \"GET\", \"/upAAAAAAAAAAAA/json?poll=1\", \"\", nil, subscriber1Fn)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"\", rr.Body.String())\n\t\trequire.Equal(t, \"1.2.3.4\", s.topics[\"upAAAAAAAAAAAA\"].rateVisitor.ip.String())\n\n\t\t// \"Register\" visitor 8.7.7.1 to topic \"up012345678912\" as a rate limit visitor (implicitly via topic name)\n\t\tsubscriber2Fn := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"8.7.7.1:1234\"\n\t\t}\n\t\trr = request(t, s, \"GET\", \"/up012345678912/json?poll=1\", \"\", nil, subscriber2Fn)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"\", rr.Body.String())\n\t\trequire.Equal(t, \"8.7.7.1\", s.topics[\"up012345678912\"].rateVisitor.ip.String())\n\n\t\t// Publish 2 messages to \"subscriber1topic\" as visitor 9.9.9.9. It'd be 3 normally, but the\n\t\t// GET request before is also counted towards the request limiter.\n\t\tfor i := 0; i < 2; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/upAAAAAAAAAAAA\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\t\trr = request(t, s, \"PUT\", \"/upAAAAAAAAAAAA\", \"some message\", nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// Publish another 2 messages to \"up012345678912\" as visitor 9.9.9.9\n\t\tfor i := 0; i < 2; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/up012345678912\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor!\n\t\t}\n\t\trr = request(t, s, \"PUT\", \"/up012345678912\", \"some message\", nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\n\t\t// Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though\n\t\t// VisitorRequestLimitBurst is 3. That means it's working.\n\n\t\t// Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter\n\t\t// by publishing another 3 requests from it.\n\t\tfor i := 0; i < 3; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/some-other-topic\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\t\trr = request(t, s, \"PUT\", \"/some-other-topic\", \"some message\", nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\tsubscriberFn := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"1.2.3.4:1234\"\n\t\t}\n\t\trr := request(t, s, \"GET\", \"/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1\", \"\", nil, subscriberFn)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"\", rr.Body.String())\n\t\trequire.Nil(t, s.topics[\"alerts\"].rateVisitor)\n\t\trequire.Equal(t, \"1.2.3.4\", s.topics[\"upAAAAAAAAAAAA\"].rateVisitor.ip.String())\n\t\trequire.Equal(t, \"1.2.3.4\", s.topics[\"upBBBBBBBBBBBB\"].rateVisitor.ip.String())\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorSubscriberRateLimiting = false\n\t\ts := newTestServer(t, c)\n\n\t\t// Subscriber rate limiting is disabled!\n\n\t\t// Registering visitor 1.2.3.4 to topic has no effect\n\t\trr := request(t, s, \"GET\", \"/upAAAAAAAAAAAA/json?poll=1\", \"\", nil, func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"1.2.3.4:1234\"\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"\", rr.Body.String())\n\t\trequire.Nil(t, s.topics[\"upAAAAAAAAAAAA\"].rateVisitor)\n\n\t\t// Registering visitor 8.7.7.1 to topic has no effect\n\t\trr = request(t, s, \"GET\", \"/up012345678912/json?poll=1\", \"\", nil, func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"8.7.7.1:1234\"\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"\", rr.Body.String())\n\t\trequire.Nil(t, s.topics[\"up012345678912\"].rateVisitor)\n\n\t\t// Publish 3 messages to \"upAAAAAAAAAAAA\" as visitor 9.9.9.9\n\t\tfor i := 0; i < 3; i++ {\n\t\t\trr := request(t, s, \"PUT\", \"/subscriber1topic\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\t\trr = request(t, s, \"PUT\", \"/subscriber1topic\", \"some message\", nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t\trr = request(t, s, \"PUT\", \"/up012345678912\", \"some message\", nil)\n\t\trequire.Equal(t, 429, rr.Code)\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\t// \"Register\" 5 different UnifiedPush visitors\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tsubscriberFn := func(r *http.Request) {\n\t\t\t\tr.RemoteAddr = fmt.Sprintf(\"1.2.3.%d:1234\", i+1)\n\t\t\t}\n\t\t\trr := request(t, s, \"GET\", fmt.Sprintf(\"/up12345678901%d/json?poll=1\", i), \"\", nil, subscriberFn)\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\n\t\t// Publish 2 messages per topic\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tfor j := 0; j < 2; j++ {\n\t\t\t\trr := request(t, s, \"PUT\", fmt.Sprintf(\"/up12345678901%d?up=1\", i), \"some message\", nil)\n\t\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\t// \"Register\" 5 different UnifiedPush visitors\n\t\tfor i := 0; i < 5; i++ {\n\t\t\trr := request(t, s, \"GET\", fmt.Sprintf(\"/up12345678901%d/json?poll=1\", i), \"\", nil, func(r *http.Request) {\n\t\t\t\tr.RemoteAddr = fmt.Sprintf(\"1.2.3.%d:1234\", i+1)\n\t\t\t})\n\t\t\trequire.Equal(t, 200, rr.Code)\n\t\t}\n\n\t\t// Publish 2 messages per topic\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tnotification := fmt.Sprintf(`{\"notification\":{\"devices\":[{\"pushkey\":\"http://127.0.0.1:12345/up12345678901%d?up=1\"}]}}`, i)\n\t\t\tfor j := 0; j < 2; j++ {\n\t\t\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\t\trequire.Equal(t, `{\"rejected\":[]}`+\"\\n\", response.Body.String())\n\t\t\t}\n\t\t\tresponse := request(t, s, \"POST\", \"/_matrix/push/v1/notify\", notification, nil)\n\t\t\trequire.Equal(t, 429, response.Code, notification)\n\t\t\trequire.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code)\n\t\t}\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.VisitorRequestLimitBurst = 3\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\t// \"Register\" rate visitor\n\t\tsubscriberFn := func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"1.2.3.4:1234\"\n\t\t}\n\t\trr := request(t, s, \"GET\", \"/upAAAAAAAAAAAA/json?poll=1\", \"\", nil, subscriberFn)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"1.2.3.4\", s.topics[\"upAAAAAAAAAAAA\"].rateVisitor.ip.String())\n\t\trequire.Equal(t, s.visitors[\"ip:1.2.3.4\"], s.topics[\"upAAAAAAAAAAAA\"].rateVisitor)\n\n\t\t// Publish message, observe rate visitor tokens being decreased\n\t\tresponse := request(t, s, \"POST\", \"/upAAAAAAAAAAAA\", \"some message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, int64(0), s.visitors[\"ip:9.9.9.9\"].messagesLimiter.Value())\n\t\trequire.Equal(t, int64(1), s.topics[\"upAAAAAAAAAAAA\"].rateVisitor.messagesLimiter.Value())\n\t\trequire.Equal(t, s.visitors[\"ip:1.2.3.4\"], s.topics[\"upAAAAAAAAAAAA\"].rateVisitor)\n\n\t\t// Expire visitor\n\t\ts.visitors[\"ip:1.2.3.4\"].seen = time.Now().Add(-1 * 25 * time.Hour)\n\t\ts.pruneVisitors()\n\n\t\t// Publish message again, observe that rateVisitor is not used anymore and is reset\n\t\tresponse = request(t, s, \"POST\", \"/upAAAAAAAAAAAA\", \"some message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, int64(1), s.visitors[\"ip:9.9.9.9\"].messagesLimiter.Value())\n\t\trequire.Nil(t, s.topics[\"upAAAAAAAAAAAA\"].rateVisitor)\n\t\trequire.Nil(t, s.visitors[\"ip:1.2.3.4\"])\n\t})\n}\n\nfunc TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.AuthDefault = user.PermissionReadWrite\n\t\tc.VisitorSubscriberRateLimiting = true\n\t\ts := newTestServer(t, c)\n\n\t\t// Create some ACLs\n\t\trequire.Nil(t, s.userManager.AllowAccess(user.Everyone, \"announcements\", user.PermissionRead))\n\n\t\t// Set rate visitor as ip:1.2.3.4 on topic\n\t\t// - \"up123456789012\": Allowed, because no ACLs and nobody owns the topic\n\t\t// - \"announcements\": NOT allowed, because it has read-only permissions for everyone\n\t\trr := request(t, s, \"GET\", \"/up123456789012,announcements/json?poll=1\", \"\", nil, func(r *http.Request) {\n\t\t\tr.RemoteAddr = \"1.2.3.4:1234\"\n\t\t})\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"1.2.3.4\", s.topics[\"up123456789012\"].rateVisitor.ip.String())\n\t\trequire.Nil(t, s.topics[\"announcements\"].rateVisitor)\n\t})\n}\n\nfunc TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.ManagerInterval = 2 * time.Second\n\t\ts := newTestServer(t, c)\n\n\t\t// Publish some messages, and get stats\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\trequire.Equal(t, int64(5), s.messages)\n\t\trequire.Equal(t, []int64{0}, s.messagesHistory)\n\n\t\tresponse := request(t, s, \"GET\", \"/v1/stats\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"messages\":5,\"messages_rate\":0}`+\"\\n\", response.Body.String())\n\n\t\t// Run manager and see message history update\n\t\ts.execManager()\n\t\trequire.Equal(t, []int64{0, 5}, s.messagesHistory)\n\n\t\tresponse = request(t, s, \"GET\", \"/v1/stats\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"messages\":5,\"messages_rate\":2.5}`+\"\\n\", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second\n\n\t\t// Publish some more messages\n\t\tfor i := 0; i < 10; i++ {\n\t\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"some message\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t}\n\t\trequire.Equal(t, int64(15), s.messages)\n\t\trequire.Equal(t, []int64{0, 5}, s.messagesHistory)\n\n\t\tresponse = request(t, s, \"GET\", \"/v1/stats\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"messages\":15,\"messages_rate\":2.5}`+\"\\n\", response.Body.String()) // Rate did not update yet\n\n\t\t// Run manager and see message history update\n\t\ts.execManager()\n\t\trequire.Equal(t, []int64{0, 5, 15}, s.messagesHistory)\n\n\t\tresponse = request(t, s, \"GET\", \"/v1/stats\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"messages\":15,\"messages_rate\":3.75}`+\"\\n\", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second\n\t})\n}\n\nfunc TestServer_MessageHistoryMaxSize(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// Do not run in parallel, we've seen race conditions with SQLite closing\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tfor i := 0; i < 20; i++ {\n\t\t\ts.updateAndWriteStats(int64(i))\n\t\t}\n\t\trequire.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory)\n\t})\n}\n\nfunc TestServer_MessageCountPersistence(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\ts := newTestServer(t, c)\n\t\ts.messages = 1234\n\t\ts.execManager()\n\t\twaitFor(t, func() bool {\n\t\t\tmessages, err := s.messageCache.Stats()\n\t\t\trequire.Nil(t, err)\n\t\t\treturn messages == 1234\n\t\t})\n\n\t\ts = newTestServer(t, c)\n\t\trequire.Equal(t, int64(1234), s.messages)\n\t})\n}\n\nfunc TestServer_PublishWithUTF8MimeHeader(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"some attachment\", map[string]string{\n\t\t\t\"X-Filename\": \"some =?UTF-8?q?=C3=A4?=ttachment.txt\",\n\t\t\t\"X-Message\":  \"=?UTF-8?B?8J+HqfCfh6o=?=\",\n\t\t\t\"X-Title\":    \"=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=\",\n\t\t\t\"X-Tags\":     \"=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=\",\n\t\t\t\"X-Click\":    \"=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=\",\n\t\t\t\"X-Actions\":  \"http, \\\"=?utf-8?q?Mettre =C3=A0 jour?=\\\", \\\"https://my.tld/webhook/netbird-update\\\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"🇩🇪\", m.Message)\n\t\trequire.Equal(t, \"ntfy 很棒, no really I mean it! This is qüöted-printäble.\", m.Title)\n\t\trequire.Equal(t, \"some ättachment.txt\", m.Attachment.Name)\n\t\trequire.Equal(t, \"🇩🇪\", m.Tags[0])\n\t\trequire.Equal(t, \"ntfy 很棒\", m.Tags[1])\n\t\trequire.Equal(t, \"https://💩.la\", m.Click)\n\t\trequire.Equal(t, \"Mettre à jour\", m.Actions[0].Label)\n\t\trequire.Equal(t, \"http\", m.Actions[1].Action)\n\t\trequire.Equal(t, \"这是一个标签\", m.Actions[1].Label)\n\t\trequire.Equal(t, \"https://💩.la\", m.Actions[1].URL)\n\t})\n}\n\nfunc TestServer_UpstreamBaseURL_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tvar pollID atomic.Pointer[string]\n\t\tupstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735\", r.URL.Path)\n\t\t\trequire.Equal(t, \"\", string(body))\n\t\t\trequire.NotEmpty(t, r.Header.Get(\"X-Poll-ID\"))\n\t\t\tpollID.Store(util.String(r.Header.Get(\"X-Poll-ID\")))\n\t\t}))\n\t\tdefer upstreamServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.BaseURL = \"http://myserver.internal\"\n\t\tc.UpstreamBaseURL = upstreamServer.URL\n\t\ts := newTestServer(t, c)\n\n\t\t// Send message, and wait for upstream server to receive it\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `hi there`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, m.ID)\n\t\trequire.Equal(t, \"hi there\", m.Message)\n\t\twaitFor(t, func() bool {\n\t\t\tpID := pollID.Load()\n\t\t\treturn pID != nil && *pID == m.ID\n\t\t})\n\t})\n}\n\nfunc TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tvar pollID atomic.Pointer[string]\n\t\tupstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df\", r.URL.Path)\n\t\t\trequire.Equal(t, \"Bearer tk_1234567890\", r.Header.Get(\"Authorization\"))\n\t\t\trequire.Equal(t, \"\", string(body))\n\t\t\trequire.NotEmpty(t, r.Header.Get(\"X-Poll-ID\"))\n\t\t\tpollID.Store(util.String(r.Header.Get(\"X-Poll-ID\")))\n\t\t}))\n\t\tdefer upstreamServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.BaseURL = \"http://myserver.internal\"\n\t\tc.UpstreamBaseURL = upstreamServer.URL\n\t\tc.UpstreamAccessToken = \"tk_1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Send message, and wait for upstream server to receive it\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic1\", `hi there`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, m.ID)\n\t\trequire.Equal(t, \"hi there\", m.Message)\n\t\twaitFor(t, func() bool {\n\t\t\tpID := pollID.Load()\n\t\t\treturn pID != nil && *pID == m.ID\n\t\t})\n\t})\n}\n\nfunc TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tupstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tt.Fatal(\"UnifiedPush messages should not be forwarded\")\n\t\t}))\n\t\tdefer upstreamServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.BaseURL = \"http://myserver.internal\"\n\t\tc.UpstreamBaseURL = upstreamServer.URL\n\t\ts := newTestServer(t, c)\n\n\t\t// Send UP message, this should not forward to upstream server\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?up=1\", `hi there`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, m.ID)\n\t\trequire.Equal(t, \"hi there\", m.Message)\n\n\t\t// Forwarding is done asynchronously, so wait a bit.\n\t\t// This ensures that the t.Fatal above is actually not triggered.\n\t\ttime.Sleep(500 * time.Millisecond)\n\t})\n}\n\nfunc TestServer_MessageTemplate(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\", \"nested\":{\"title\":\"here\"}}`, map[string]string{\n\t\t\t\"X-Message\":  \"{{.foo}}\",\n\t\t\t\"X-Title\":    \"{{.nested.title}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"bar\", m.Message)\n\t\trequire.Equal(t, \"here\", m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\", \"nested\":{\"title\":\"here\"}}`, map[string]string{\n\t\t\t\"Message\":  \"{{.foo}} is {{.foo}}\",\n\t\t\t\"Title\":    \"{{.nested.title}} is {{.nested.title}}\",\n\t\t\t\"Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"bar is bar\", m.Message)\n\t\trequire.Equal(t, \"here is here\", m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_JSONBody(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\": \"mytopic\", \"message\": \"{\\\"foo\\\":\\\"bar\\\",\\\"nested\\\":{\\\"title\\\":\\\"here\\\"}}\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, map[string]string{\n\t\t\t\"m\":   \"{{.foo}}\",\n\t\t\t\"t\":   \"{{.nested.title}}\",\n\t\t\t\"tpl\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"bar\", m.Message)\n\t\trequire.Equal(t, \"here\", m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"topic\": \"mytopic\", \"message\": \"{\\\"foo\\\":\\\"bar\\\",\\\"nested\\\":{\\\"title\\\":\\\"here\\\"INVALID\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/\", body, map[string]string{\n\t\t\t\"X-Message\":  \"{{.foo}}\",\n\t\t\t\"X-Title\":    \"{{.nested.title}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40042, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\", \"nested\":{\"title\":\"here\"}}`, map[string]string{\n\t\t\t\"X-Message\":  \"{{.food}}\",\n\t\t\t\"X-Title\":    \"{{.neste.title}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"<no value>\", m.Message)\n\t\trequire.Equal(t, \"<no value>\", m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\", \"nested\":{\"title\":\"here\"}}`, map[string]string{\n\t\t\t\"X-Message\":  \"{{.foo}} is {{.nested.title}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"bar is here\", m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Range(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tjsonBody := `{\"foo\": \"bar\", \"errors\": [{\"level\": \"severe\", \"url\": \"https://severe1.com\"},{\"level\": \"warning\", \"url\": \"https://warning.com\"},{\"level\": \"severe\", \"url\": \"https://severe2.com\"}]}`\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", jsonBody, map[string]string{\n\t\t\t\"X-Message\":  `Severe URLs:\\n{{range .errors}}{{if eq .level \"severe\"}}- {{.url}}\\n{{end}}{{end}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Severe URLs:\\n- https://severe1.com\\n- https://severe2.com\", m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.MessageSizeLimit = 25 // 25 < len(HTTP body) < 32k, and len(m.Message) < 25\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\", \"nested\":{\"title\":\"here\"}}`, map[string]string{\n\t\t\t\"X-Message\":  \"{{.foo}}\",\n\t\t\t\"X-Title\":    \"{{.nested.title}}\",\n\t\t\t\"X-Template\": \"yes\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"bar\", m.Message)\n\t\trequire.Equal(t, \"here\", m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.MessageSizeLimit = 21 // 21 < len(HTTP body) < 32k, but !len(m.Message) < 21\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"This is a long message\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"{{.foo}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Grafana(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"receiver\":\"ntfy\\\\.example\\\\.com/alerts\",\"status\":\"resolved\",\"alerts\":[{\"status\":\"resolved\",\"labels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\",\"instance\":\"10.108.0.2:9100\",\"job\":\"node-exporter\"},\"annotations\":{\"summary\":\"15m load average too high\"},\"startsAt\":\"2024-03-15T02:28:00Z\",\"endsAt\":\"2024-03-15T02:42:00Z\",\"generatorURL\":\"localhost:3000/alerting/grafana/NW9oDw-4z/view\",\"fingerprint\":\"becbfb94bd81ef48\",\"silenceURL\":\"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\",\"dashboardURL\":\"\",\"panelURL\":\"\",\"values\":{\"B\":18.98211314475876,\"C\":0},\"valueString\":\"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]\"}],\"groupLabels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\"},\"commonLabels\":{\"alertname\":\"Load avg 15m too high\",\"grafana_folder\":\"Node alerts\",\"instance\":\"10.108.0.2:9100\",\"job\":\"node-exporter\"},\"commonAnnotations\":{\"summary\":\"15m load average too high\"},\"externalURL\":\"localhost:3000/\",\"version\":\"1\",\"groupKey\":\"{}:{alertname=\\\"Load avg 15m too high\\\", grafana_folder=\\\"Node alerts\\\"}\",\"truncatedAlerts\":0,\"orgId\":1,\"title\":\"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)\",\"state\":\"ok\",\"message\":\"**Resolved**\\n\\nValue: B=18.98211314475876, C=0\\nLabels:\\n - alertname = Load avg 15m too high\\n - grafana_folder = Node alerts\\n - instance = 10.108.0.2:9100\\n - job = node-exporter\\nAnnotations:\\n - summary = 15m load average too high\\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\\n\"}`\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?tpl=yes&title=Grafana+alert:+{{.title}}&message={{.message}}\", body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Grafana alert: [RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)\", m.Title)\n\t\trequire.Equal(t, `**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_GitHub(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"action\":\"opened\",\"number\":1,\"pull_request\":{\"url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1\",\"id\":1783420972,\"node_id\":\"PR_kwDOHAbdo85qTNgs\",\"html_url\":\"https://github.com/binwiederhier/dabble/pull/1\",\"diff_url\":\"https://github.com/binwiederhier/dabble/pull/1.diff\",\"patch_url\":\"https://github.com/binwiederhier/dabble/pull/1.patch\",\"issue_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1\",\"number\":1,\"state\":\"open\",\"locked\":false,\"title\":\"A sample PR from Phil\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"body\":null,\"created_at\":\"2024-03-21T02:52:09Z\",\"updated_at\":\"2024-03-21T02:52:09Z\",\"closed_at\":null,\"merged_at\":null,\"merge_commit_sha\":null,\"assignee\":null,\"assignees\":[],\"requested_reviewers\":[],\"requested_teams\":[],\"labels\":[],\"milestone\":null,\"draft\":false,\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits\",\"review_comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments\",\"review_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b\",\"head\":{\"label\":\"binwiederhier:aa\",\"ref\":\"aa\",\"sha\":\"5703842cc5715ed1e358d23ebb693db09747ae9b\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"repo\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\",\"allow_squash_merge\":true,\"allow_merge_commit\":true,\"allow_rebase_merge\":true,\"allow_auto_merge\":false,\"delete_branch_on_merge\":false,\"allow_update_branch\":false,\"use_squash_pr_title_as_default\":false,\"squash_merge_commit_message\":\"COMMIT_MESSAGES\",\"squash_merge_commit_title\":\"COMMIT_OR_PR_TITLE\",\"merge_commit_message\":\"PR_TITLE\",\"merge_commit_title\":\"MERGE_MESSAGE\"}},\"base\":{\"label\":\"binwiederhier:main\",\"ref\":\"main\",\"sha\":\"72d931a20bb83d123ab45accaf761150c8b01211\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"repo\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\",\"allow_squash_merge\":true,\"allow_merge_commit\":true,\"allow_rebase_merge\":true,\"allow_auto_merge\":false,\"delete_branch_on_merge\":false,\"allow_update_branch\":false,\"use_squash_pr_title_as_default\":false,\"squash_merge_commit_message\":\"COMMIT_MESSAGES\",\"squash_merge_commit_title\":\"COMMIT_OR_PR_TITLE\",\"merge_commit_message\":\"PR_TITLE\",\"merge_commit_title\":\"MERGE_MESSAGE\"}},\"_links\":{\"self\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1\"},\"html\":{\"href\":\"https://github.com/binwiederhier/dabble/pull/1\"},\"issue\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1\"},\"comments\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments\"},\"review_comments\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments\"},\"review_comment\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}\"},\"commits\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits\"},\"statuses\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b\"}},\"author_association\":\"OWNER\",\"auto_merge\":null,\"active_lock_reason\":null,\"merged\":false,\"mergeable\":null,\"rebaseable\":null,\"mergeable_state\":\"unknown\",\"merged_by\":null,\"comments\":0,\"review_comments\":0,\"maintainer_can_modify\":false,\"commits\":1,\"additions\":1,\"deletions\":1,\"changed_files\":1},\"repository\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\"},\"sender\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false}}`\n\t\tresponse := request(t, s, \"PUT\", `/mytopic?tpl=yes&message=[{{.pull_request.head.repo.full_name}}]+Pull+request+{{if+eq+.action+\"opened\"}}OPENED{{else}}CLOSED{{end}}:+{{.pull_request.title}}`, body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"\", m.Title)\n\t\trequire.Equal(t, `[binwiederhier/dabble] Pull request OPENED: A sample PR from Phil`, m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_GitHub2(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbody := `{\"action\":\"opened\",\"number\":1,\"pull_request\":{\"url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1\",\"id\":1783420972,\"node_id\":\"PR_kwDOHAbdo85qTNgs\",\"html_url\":\"https://github.com/binwiederhier/dabble/pull/1\",\"diff_url\":\"https://github.com/binwiederhier/dabble/pull/1.diff\",\"patch_url\":\"https://github.com/binwiederhier/dabble/pull/1.patch\",\"issue_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1\",\"number\":1,\"state\":\"open\",\"locked\":false,\"title\":\"A sample PR from Phil\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"body\":null,\"created_at\":\"2024-03-21T02:52:09Z\",\"updated_at\":\"2024-03-21T02:52:09Z\",\"closed_at\":null,\"merged_at\":null,\"merge_commit_sha\":null,\"assignee\":null,\"assignees\":[],\"requested_reviewers\":[],\"requested_teams\":[],\"labels\":[],\"milestone\":null,\"draft\":false,\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits\",\"review_comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments\",\"review_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b\",\"head\":{\"label\":\"binwiederhier:aa\",\"ref\":\"aa\",\"sha\":\"5703842cc5715ed1e358d23ebb693db09747ae9b\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"repo\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\",\"allow_squash_merge\":true,\"allow_merge_commit\":true,\"allow_rebase_merge\":true,\"allow_auto_merge\":false,\"delete_branch_on_merge\":false,\"allow_update_branch\":false,\"use_squash_pr_title_as_default\":false,\"squash_merge_commit_message\":\"COMMIT_MESSAGES\",\"squash_merge_commit_title\":\"COMMIT_OR_PR_TITLE\",\"merge_commit_message\":\"PR_TITLE\",\"merge_commit_title\":\"MERGE_MESSAGE\"}},\"base\":{\"label\":\"binwiederhier:main\",\"ref\":\"main\",\"sha\":\"72d931a20bb83d123ab45accaf761150c8b01211\",\"user\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"repo\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\",\"allow_squash_merge\":true,\"allow_merge_commit\":true,\"allow_rebase_merge\":true,\"allow_auto_merge\":false,\"delete_branch_on_merge\":false,\"allow_update_branch\":false,\"use_squash_pr_title_as_default\":false,\"squash_merge_commit_message\":\"COMMIT_MESSAGES\",\"squash_merge_commit_title\":\"COMMIT_OR_PR_TITLE\",\"merge_commit_message\":\"PR_TITLE\",\"merge_commit_title\":\"MERGE_MESSAGE\"}},\"_links\":{\"self\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1\"},\"html\":{\"href\":\"https://github.com/binwiederhier/dabble/pull/1\"},\"issue\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1\"},\"comments\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments\"},\"review_comments\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments\"},\"review_comment\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}\"},\"commits\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits\"},\"statuses\":{\"href\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b\"}},\"author_association\":\"OWNER\",\"auto_merge\":null,\"active_lock_reason\":null,\"merged\":false,\"mergeable\":null,\"rebaseable\":null,\"mergeable_state\":\"unknown\",\"merged_by\":null,\"comments\":0,\"review_comments\":0,\"maintainer_can_modify\":false,\"commits\":1,\"additions\":1,\"deletions\":1,\"changed_files\":1},\"repository\":{\"id\":470212003,\"node_id\":\"R_kgDOHAbdow\",\"name\":\"dabble\",\"full_name\":\"binwiederhier/dabble\",\"private\":false,\"owner\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false},\"html_url\":\"https://github.com/binwiederhier/dabble\",\"description\":\"A repo for dabbling\",\"fork\":false,\"url\":\"https://api.github.com/repos/binwiederhier/dabble\",\"forks_url\":\"https://api.github.com/repos/binwiederhier/dabble/forks\",\"keys_url\":\"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/binwiederhier/dabble/teams\",\"hooks_url\":\"https://api.github.com/repos/binwiederhier/dabble/hooks\",\"issue_events_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/binwiederhier/dabble/events\",\"assignees_url\":\"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/tags\",\"blobs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/binwiederhier/dabble/languages\",\"stargazers_url\":\"https://api.github.com/repos/binwiederhier/dabble/stargazers\",\"contributors_url\":\"https://api.github.com/repos/binwiederhier/dabble/contributors\",\"subscribers_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscribers\",\"subscription_url\":\"https://api.github.com/repos/binwiederhier/dabble/subscription\",\"commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/binwiederhier/dabble/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/binwiederhier/dabble/merges\",\"archive_url\":\"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/binwiederhier/dabble/downloads\",\"issues_url\":\"https://api.github.com/repos/binwiederhier/dabble/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/binwiederhier/dabble/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/binwiederhier/dabble/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/binwiederhier/dabble/deployments\",\"created_at\":\"2022-03-15T15:06:17Z\",\"updated_at\":\"2022-03-15T15:06:17Z\",\"pushed_at\":\"2024-03-21T02:52:10Z\",\"git_url\":\"git://github.com/binwiederhier/dabble.git\",\"ssh_url\":\"git@github.com:binwiederhier/dabble.git\",\"clone_url\":\"https://github.com/binwiederhier/dabble.git\",\"svn_url\":\"https://github.com/binwiederhier/dabble\",\"homepage\":null,\"size\":1,\"stargazers_count\":0,\"watchers_count\":0,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":true,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":0,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":1,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"topics\":[],\"visibility\":\"public\",\"forks\":0,\"open_issues\":1,\"watchers\":0,\"default_branch\":\"main\"},\"sender\":{\"login\":\"binwiederhier\",\"id\":664597,\"node_id\":\"MDQ6VXNlcjY2NDU5Nw==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/664597?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/binwiederhier\",\"html_url\":\"https://github.com/binwiederhier\",\"followers_url\":\"https://api.github.com/users/binwiederhier/followers\",\"following_url\":\"https://api.github.com/users/binwiederhier/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/binwiederhier/subscriptions\",\"organizations_url\":\"https://api.github.com/users/binwiederhier/orgs\",\"repos_url\":\"https://api.github.com/users/binwiederhier/repos\",\"events_url\":\"https://api.github.com/users/binwiederhier/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/binwiederhier/received_events\",\"type\":\"User\",\"site_admin\":false}}`\n\t\tresponse := request(t, s, \"PUT\", `/mytopic?tpl=yes&title={{if+eq+.action+\"opened\"}}New+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{else}}[{{.action}}]+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{end}}&message={{.pull_request.title}}+in+{{.repository.full_name}}.+View+more+at+{{.pull_request.html_url}}`, body, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, `New PR: #1 by binwiederhier`, m.Title)\n\t\trequire.Equal(t, `A sample PR from Phil in binwiederhier/dabble. View more at https://github.com/binwiederhier/dabble/pull/1`, m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_DisallowedCalls(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tdisallowedTemplates := []string{\n\t\t\t`{{template \"\"}}`,\n\t\t\t`{{- template \"\"}}`,\n\t\t\t`{{-\ntemplate \"\"}}`,\n\t\t\t`{{      call abc}}`,\n\t\t\t`{{      define \"aa\"}}`,\n\t\t\t`We cannot {{define \"aa\"}}`,\n\t\t\t`We cannot {{ call \"aa\"}}`,\n\t\t\t`We cannot {{- template \"aa\"}}`,\n\t\t}\n\t\tfor _, disallowedTemplate := range disallowedTemplates {\n\t\t\tmessageTemplate := disallowedTemplate\n\t\t\tt.Run(disallowedTemplate, func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tresponse := request(t, s, \"PUT\", `/mytopic`, `{}`, map[string]string{\n\t\t\t\t\t\"Template\": \"yes\",\n\t\t\t\t\t\"Message\":  messageTemplate,\n\t\t\t\t})\n\t\t\t\trequire.Equal(t, 400, response.Code)\n\t\t\t\trequire.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestServer_MessageTemplate_SprigFunctions(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tbodies := []string{\n\t\t\t`{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}`,\n\t\t\t`{\"topic\":\"ntfy-test\"}`,\n\t\t\t`{\"topic\":\"another-topic\"}`,\n\t\t}\n\t\ttemplates := []string{\n\t\t\t`{{.foo | upper}} is {{.nested.title | repeat 3}}`,\n\t\t\t`{{if hasPrefix \"ntfy-\" .topic}}Topic: {{trimPrefix \"ntfy-\" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,\n\t\t\t`{{if hasPrefix \"ntfy-\" .topic}}Topic: {{trimPrefix \"ntfy-\" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,\n\t\t}\n\t\ttargets := []string{\n\t\t\t`BAR is hereherehere`,\n\t\t\t`Topic: test`,\n\t\t\t`Topic: another-topic`,\n\t\t}\n\t\tfor i, body := range bodies {\n\t\t\ttemplate := templates[i]\n\t\t\ttarget := targets[i]\n\t\t\tt.Run(template, func(t *testing.T) {\n\t\t\t\tresponse := request(t, s, \"PUT\", `/mytopic`, body, map[string]string{\n\t\t\t\t\t\"Template\": \"yes\",\n\t\t\t\t\t\"Message\":  template,\n\t\t\t\t})\n\t\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\t\tm := toMessage(t, response.Body.String())\n\t\t\t\trequire.Equal(t, target, m.Message)\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", `{}`, map[string]string{\n\t\t\t\"X-Message\":  `{{ env \"PATH\" }}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MessageTemplate_InlineNewlines(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{}`, map[string]string{\n\t\t\t\"X-Message\":  `{{\"New\\nlines\"}}`,\n\t\t\t\"X-Title\":    `{{\"New\\nlines\"}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, `New\nlines`, m.Message)\n\t\trequire.Equal(t, `New\nlines`, m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"foo\":\"bar\",\"food\":\"bag\"}`, map[string]string{\n\t\t\t\"X-Message\":  `{{.foo}}{{\"\\n\"}}{{.food}}`,\n\t\t\t\"X-Title\":    `{{.food}}{{\"\\n\"}}{{.foo}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, `bar\nbag`, m.Message)\n\t\trequire.Equal(t, `bag\nbar`, m.Title)\n\t})\n}\n\nfunc TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.TemplateDir = t.TempDir()\n\t\trequire.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, \"newline.yml\"), []byte(`\ntitle: |\n  {{.food}}{{\"\\n\"}}{{.foo}}\nmessage: |\n  {{.foo}}{{\"\\n\"}}{{.food}}\n`), 0644))\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"POST\", \"/mytopic?template=newline\", `{\"foo\":\"bar\",\"food\":\"bag\"}`, nil)\n\t\tfmt.Println(response.Body.String())\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, `bar\nbag`, m.Message)\n\t\trequire.Equal(t, `bag\nbar`, m.Title)\n\t})\n}\n\nvar (\n\t//go:embed testdata/webhook_github_comment_created.json\n\tgithubCommentCreatedJSON string\n\n\t//go:embed testdata/webhook_github_issue_opened.json\n\tgithubIssueOpenedJSON string\n)\n\nfunc TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic?template=github\", githubCommentCreatedJSON, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"💬 New comment on issue #1389 instant alerts without Pull to refresh\", m.Title)\n\t\trequire.Equal(t, `Commenter: https://github.com/wunter8\nRepository: https://github.com/binwiederhier/ntfy\nComment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289\n\nComment:\nThese are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `+\"`\"+`base-url`+\"`\"+` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `+\"`\"+`upstream-base-url`+\"`\"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)`, m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic?template=github\", githubIssueOpenedJSON, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"🐛 Issue opened: #1391 http 500 error (ntfy error 50001)\", m.Title)\n\t\trequire.Equal(t, `Opened by: https://github.com/TheUser-dev\nRepository: https://github.com/binwiederhier/ntfy\nIssue link: https://github.com/binwiederhier/ntfy/issues/1391\nLabels: 🪲 bug \n\nDescription:\n:lady_beetle: **Describe the bug**\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\n\n:computer: **Components impacted**\nntfy server 2.13.0 in docker, debian 12 arm64\n\n:bulb: **Screenshots and/or logs**\n`+\"```\"+`\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n`+\"```\"+`\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?`, m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.TemplateDir = t.TempDir()\n\t\trequire.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, \"github.yml\"), []byte(`\ntitle: |\n  Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }}\nmessage: |\n  Custom message {{ .issue.number }}\n`), 0644))\n\t\ts := newTestServer(t, c)\n\t\tresponse := request(t, s, \"POST\", \"/mytopic?template=github\", githubIssueOpenedJSON, nil)\n\t\tfmt.Println(response.Body.String())\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Custom title: action=opened trunctitle=http 500 e\", m.Title)\n\t\trequire.Equal(t, \"Custom message 1391\", m.Message)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", `{}`, map[string]string{\n\t\t\t\"X-Message\":  `{{ repeat 9999 \"mystring\" }}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)\n\t\trequire.Contains(t, toHTTPError(t, response.Body.String()).Message, \"message or title is too large after replacing template\")\n\t})\n}\n\nfunc TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", `{}`, map[string]string{\n\t\t\t\"X-Message\":  `{{ repeat 10001 \"mystring\" }}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)\n\t\trequire.Contains(t, toHTTPError(t, response.Body.String()).Message, \"repeat count 10001 exceeds limit of 10000\")\n\t})\n}\n\nfunc TestServer_MessageTemplate_Until100_000(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", `{}`, map[string]string{\n\t\t\t\"X-Message\":  `{{ range $i, $e := until 100_000 }}{{end}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)\n\t\trequire.Contains(t, toHTTPError(t, response.Body.String()).Message, \"too many iterations\")\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"priority\":\"5\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"Test message\",\n\t\t\t\"X-Priority\": \"{{.priority}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Test message\", m.Message)\n\t\trequire.Equal(t, 5, m.Priority)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority_Conditional(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Test with error status -> priority 5\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"status\":\"Error\",\"message\":\"Something went wrong\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"Status: {{.status}} - {{.message}}\",\n\t\t\t\"X-Priority\": `{{if eq .status \"Error\"}}5{{else}}3{{end}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Status: Error - Something went wrong\", m.Message)\n\t\trequire.Equal(t, 5, m.Priority)\n\n\t\t// Test with success status -> priority 3\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic\", `{\"status\":\"Success\",\"message\":\"All good\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"Status: {{.status}} - {{.message}}\",\n\t\t\t\"X-Priority\": `{{if eq .status \"Error\"}}5{{else}}3{{end}}`,\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm = toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Status: Success - All good\", m.Message)\n\t\trequire.Equal(t, 3, m.Priority)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"severity\":\"high\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"Alert\",\n\t\t\t\"X-Priority\": \"{{.severity}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 4, m.Priority) // \"high\" = 4\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority_Invalid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", `{\"priority\":\"invalid\"}`, map[string]string{\n\t\t\t\"X-Message\":  \"Test message\",\n\t\t\t\"X-Priority\": \"{{.priority}}\",\n\t\t\t\"X-Template\": \"1\",\n\t\t})\n\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic?template=1&priority={{.priority}}\", `{\"priority\":\"max\"}`, nil)\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 5, m.Priority) // \"max\" = 5\n\t})\n}\n\nfunc TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\tc := newTestConfig(t, databaseURL)\n\t\tc.TemplateDir = t.TempDir()\n\t\trequire.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, \"priority-test.yml\"), []byte(`\ntitle: \"{{.title}}\"\nmessage: \"{{.message}}\"\npriority: '{{if eq .level \"critical\"}}5{{else if eq .level \"warning\"}}4{{else}}3{{end}}'\n`), 0644))\n\t\ts := newTestServer(t, c)\n\n\t\t// Test with critical level\n\t\tresponse := request(t, s, \"POST\", \"/mytopic?template=priority-test\", `{\"title\":\"Alert\",\"message\":\"System down\",\"level\":\"critical\"}`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"Alert\", m.Title)\n\t\trequire.Equal(t, \"System down\", m.Message)\n\t\trequire.Equal(t, 5, m.Priority)\n\n\t\t// Test with warning level\n\t\tresponse = request(t, s, \"POST\", \"/mytopic?template=priority-test\", `{\"title\":\"Alert\",\"message\":\"High load\",\"level\":\"warning\"}`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm = toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 4, m.Priority)\n\n\t\t// Test with info level\n\t\tresponse = request(t, s, \"POST\", \"/mytopic?template=priority-test\", `{\"title\":\"Alert\",\"message\":\"All good\",\"level\":\"info\"}`, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tm = toMessage(t, response.Body.String())\n\t\trequire.Equal(t, 3, m.Priority)\n\t})\n}\n\nfunc TestServer_DeleteMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a message with a sequence ID\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/seq123\", \"original message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"seq123\", msg.SequenceID)\n\t\trequire.Equal(t, \"message\", msg.Event)\n\n\t\t// Delete the message using DELETE method\n\t\tresponse = request(t, s, \"DELETE\", \"/mytopic/seq123\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tdeleteMsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"seq123\", deleteMsg.SequenceID)\n\t\trequire.Equal(t, \"message_delete\", deleteMsg.Event)\n\n\t\t// Poll and verify both messages are returned\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tlines := strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 2, len(lines))\n\n\t\tmsg1 := toMessage(t, lines[0])\n\t\tmsg2 := toMessage(t, lines[1])\n\t\trequire.Equal(t, \"message\", msg1.Event)\n\t\trequire.Equal(t, \"message_delete\", msg2.Event)\n\t\trequire.Equal(t, \"seq123\", msg1.SequenceID)\n\t\trequire.Equal(t, \"seq123\", msg2.SequenceID)\n\t})\n}\n\nfunc TestServer_ClearMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a message with a sequence ID\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/seq456\", \"original message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"seq456\", msg.SequenceID)\n\t\trequire.Equal(t, \"message\", msg.Event)\n\n\t\t// Clear the message using PUT /topic/seq/clear\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/seq456/clear\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tclearMsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"seq456\", clearMsg.SequenceID)\n\t\trequire.Equal(t, \"message_clear\", clearMsg.Event)\n\n\t\t// Poll and verify both messages are returned\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tlines := strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 2, len(lines))\n\n\t\tmsg1 := toMessage(t, lines[0])\n\t\tmsg2 := toMessage(t, lines[1])\n\t\trequire.Equal(t, \"message\", msg1.Event)\n\t\trequire.Equal(t, \"message_clear\", msg2.Event)\n\t\trequire.Equal(t, \"seq456\", msg1.SequenceID)\n\t\trequire.Equal(t, \"seq456\", msg2.SequenceID)\n\t})\n}\n\nfunc TestServer_ClearMessage_ReadEndpoint(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// Test that /topic/seq/read also works\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a message\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/seq789\", \"original message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Clear using /read endpoint\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/seq789/read\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tclearMsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"seq789\", clearMsg.SequenceID)\n\t\trequire.Equal(t, \"message_clear\", clearMsg.Event)\n\t})\n}\n\nfunc TestServer_UpdateMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish original message\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/update-seq\", \"original message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"update-seq\", msg1.SequenceID)\n\t\trequire.Equal(t, \"original message\", msg1.Message)\n\n\t\t// Update the message (same sequence ID, new content)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/update-seq\", \"updated message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg2 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"update-seq\", msg2.SequenceID)\n\t\trequire.Equal(t, \"updated message\", msg2.Message)\n\t\trequire.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs\n\n\t\t// Poll and verify both versions are returned\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tlines := strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 2, len(lines))\n\n\t\tpolledMsg1 := toMessage(t, lines[0])\n\t\tpolledMsg2 := toMessage(t, lines[1])\n\t\trequire.Equal(t, \"original message\", polledMsg1.Message)\n\t\trequire.Equal(t, \"updated message\", polledMsg2.Message)\n\t\trequire.Equal(t, \"update-seq\", polledMsg1.SequenceID)\n\t\trequire.Equal(t, \"update-seq\", polledMsg2.SequenceID)\n\t})\n}\n\nfunc TestServer_UpdateMessage_UsingMessageID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish original message without a sequence ID\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"original message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.NotEmpty(t, msg1.ID)\n\t\trequire.Empty(t, msg1.SequenceID) // No sequence ID provided\n\t\trequire.Equal(t, \"original message\", msg1.Message)\n\n\t\t// Update the message using the message ID as the sequence ID\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/\"+msg1.ID, \"updated message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg2 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID\n\t\trequire.Equal(t, \"updated message\", msg2.Message)\n\t\trequire.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs\n\n\t\t// Poll and verify both versions are returned\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tlines := strings.Split(strings.TrimSpace(response.Body.String()), \"\\n\")\n\t\trequire.Equal(t, 2, len(lines))\n\n\t\tpolledMsg1 := toMessage(t, lines[0])\n\t\tpolledMsg2 := toMessage(t, lines[1])\n\t\trequire.Equal(t, \"original message\", polledMsg1.Message)\n\t\trequire.Equal(t, \"updated message\", polledMsg2.Message)\n\t\trequire.Empty(t, polledMsg1.SequenceID)          // Original has no sequence ID\n\t\trequire.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID\n\t})\n}\n\nfunc TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Test invalid sequence ID for delete (returns 404 because route doesn't match)\n\t\tresponse := request(t, s, \"DELETE\", \"/mytopic/invalid*seq\", \"\", nil)\n\t\trequire.Equal(t, 404, response.Code)\n\n\t\t// Test invalid sequence ID for clear (returns 404 because route doesn't match)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/invalid*seq/clear\", \"\", nil)\n\t\trequire.Equal(t, 404, response.Code)\n\t})\n}\n\nfunc TestServer_DeleteMessage_WithFirebase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsender := newTestFirebaseSender(10)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\t\t// Publish a message\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/firebase-seq\", \"test message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens\n\t\trequire.Equal(t, 1, len(sender.Messages()))\n\t\trequire.Equal(t, \"message\", sender.Messages()[0].Data[\"event\"])\n\n\t\t// Delete the message\n\t\tresponse = request(t, s, \"DELETE\", \"/mytopic/firebase-seq\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens\n\t\trequire.Equal(t, 2, len(sender.Messages()))\n\t\trequire.Equal(t, \"message_delete\", sender.Messages()[1].Data[\"event\"])\n\t\trequire.Equal(t, \"firebase-seq\", sender.Messages()[1].Data[\"sequence_id\"])\n\t})\n}\n\nfunc TestServer_ClearMessage_WithFirebase(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tsender := newTestFirebaseSender(10)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\t\t// Publish a message\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/firebase-clear-seq\", \"test message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\trequire.Equal(t, 1, len(sender.Messages()))\n\n\t\t// Clear the message\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/firebase-clear-seq/clear\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\trequire.Equal(t, 2, len(sender.Messages()))\n\t\trequire.Equal(t, \"message_clear\", sender.Messages()[1].Data[\"event\"])\n\t\trequire.Equal(t, \"firebase-clear-seq\", sender.Messages()[1].Data[\"sequence_id\"])\n\t})\n}\n\nfunc TestServer_UpdateScheduledMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a scheduled message (future delivery)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/sched-seq?delay=1h\", \"original scheduled message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"sched-seq\", msg1.SequenceID)\n\t\trequire.Equal(t, \"original scheduled message\", msg1.Message)\n\n\t\t// Verify scheduled message exists\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"original scheduled message\", messages[0].Message)\n\n\t\t// Update the scheduled message (same sequence ID, new content)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/sched-seq?delay=2h\", \"updated scheduled message\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg2 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"sched-seq\", msg2.SequenceID)\n\t\trequire.Equal(t, \"updated scheduled message\", msg2.Message)\n\t\trequire.NotEqual(t, msg1.ID, msg2.ID)\n\n\t\t// Verify only the updated message exists (old scheduled was deleted)\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"updated scheduled message\", messages[0].Message)\n\t\trequire.Equal(t, msg2.ID, messages[0].ID)\n\t})\n}\n\nfunc TestServer_DeleteScheduledMessage(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a scheduled message (future delivery)\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/delete-sched-seq?delay=1h\", \"scheduled message to delete\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"delete-sched-seq\", msg.SequenceID)\n\n\t\t// Verify scheduled message exists\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"scheduled message to delete\", messages[0].Message)\n\n\t\t// Delete the scheduled message\n\t\tresponse = request(t, s, \"DELETE\", \"/mytopic/delete-sched-seq\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tdeleteMsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"delete-sched-seq\", deleteMsg.SequenceID)\n\t\trequire.Equal(t, \"message_delete\", deleteMsg.Event)\n\n\t\t// Verify scheduled message was deleted, only delete event remains\n\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"message_delete\", messages[0].Event)\n\t\trequire.Equal(t, \"delete-sched-seq\", messages[0].SequenceID)\n\t})\n}\n\nfunc TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish scheduled messages with same sequence ID in different topics\n\t\tresponse := request(t, s, \"PUT\", \"/topic1/shared-seq?delay=1h\", \"topic1 scheduled\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\tresponse = request(t, s, \"PUT\", \"/topic2/shared-seq?delay=1h\", \"topic2 scheduled\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Update scheduled message in topic1 only\n\t\tresponse = request(t, s, \"PUT\", \"/topic1/shared-seq?delay=2h\", \"topic1 updated\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Verify topic1 has only the updated message\n\t\tresponse = request(t, s, \"GET\", \"/topic1/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages := toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"topic1 updated\", messages[0].Message)\n\n\t\t// Verify topic2 still has its original scheduled message (not affected)\n\t\tresponse = request(t, s, \"GET\", \"/topic2/json?poll=1&scheduled=1\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmessages = toMessages(t, response.Body.String())\n\t\trequire.Equal(t, 1, len(messages))\n\t\trequire.Equal(t, \"topic2 scheduled\", messages[0].Message)\n\t})\n}\n\nfunc TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a scheduled message with an attachment\n\t\tcontent := util.RandomString(5000) // > 4096 to trigger attachment\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/attach-seq?delay=1h\", content, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg1 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"attach-seq\", msg1.SequenceID)\n\t\trequire.NotNil(t, msg1.Attachment)\n\n\t\t// Verify attachment file exists\n\t\tattachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)\n\t\trequire.FileExists(t, attachmentFile1)\n\n\t\t// Update the scheduled message with a new attachment\n\t\tnewContent := util.RandomString(5000)\n\t\tresponse = request(t, s, \"PUT\", \"/mytopic/attach-seq?delay=2h\", newContent, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg2 := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"attach-seq\", msg2.SequenceID)\n\t\trequire.NotEqual(t, msg1.ID, msg2.ID)\n\n\t\t// Verify old attachment file was deleted\n\t\trequire.NoFileExists(t, attachmentFile1)\n\n\t\t// Verify new attachment file exists\n\t\tattachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)\n\t\trequire.FileExists(t, attachmentFile2)\n\t})\n}\n\nfunc TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Publish a scheduled message with an attachment\n\t\tcontent := util.RandomString(5000) // > 4096 to trigger attachment\n\t\tresponse := request(t, s, \"PUT\", \"/mytopic/delete-attach-seq?delay=1h\", content, nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tmsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"delete-attach-seq\", msg.SequenceID)\n\t\trequire.NotNil(t, msg.Attachment)\n\n\t\t// Verify attachment file exists\n\t\tattachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)\n\t\trequire.FileExists(t, attachmentFile)\n\n\t\t// Delete the scheduled message\n\t\tresponse = request(t, s, \"DELETE\", \"/mytopic/delete-attach-seq\", \"\", nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\tdeleteMsg := toMessage(t, response.Body.String())\n\t\trequire.Equal(t, \"message_delete\", deleteMsg.Event)\n\n\t\t// Verify attachment file was deleted\n\t\trequire.NoFileExists(t, attachmentFile)\n\t})\n}\n\nfunc newMemTestCache(t *testing.T) *message.Cache {\n\tc, err := message.NewMemStore()\n\trequire.Nil(t, err)\n\treturn c\n}\n\nfunc forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) {\n\tt.Run(\"sqlite\", func(t *testing.T) {\n\t\tf(t, \"\")\n\t})\n\tt.Run(\"postgres\", func(t *testing.T) {\n\t\tf(t, dbtest.CreateTestPostgresSchema(t))\n\t})\n}\n\nfunc newTestConfig(t *testing.T, databaseURL string) *Config {\n\tconf := NewConfig()\n\tconf.BaseURL = \"http://127.0.0.1:12345\"\n\tif databaseURL != \"\" {\n\t\tconf.DatabaseURL = databaseURL\n\t} else {\n\t\tconf.CacheFile = filepath.Join(t.TempDir(), \"cache.db\")\n\t\tconf.CacheStartupQueries = \"pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;\"\n\t}\n\tconf.AttachmentCacheDir = t.TempDir()\n\tconf.TemplateDir = t.TempDir()\n\treturn conf\n}\n\nfunc configureAuth(t *testing.T, conf *Config) *Config {\n\tif conf.DatabaseURL == \"\" {\n\t\tconf.AuthFile = filepath.Join(t.TempDir(), \"user.db\")\n\t\tconf.AuthStartupQueries = \"pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;\"\n\t}\n\tconf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot\n\treturn conf\n}\n\nfunc newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config {\n\tconf := newTestConfig(t, databaseURL)\n\tconf = configureAuth(t, conf)\n\treturn conf\n}\n\nfunc newTestServer(t *testing.T, config *Config) *Server {\n\tserver, err := New(config)\n\trequire.Nil(t, err)\n\tt.Cleanup(server.closeDatabases)\n\treturn server\n}\n\nfunc request(t *testing.T, s *Server, method, url, body string, headers map[string]string, fn ...func(r *http.Request)) *httptest.ResponseRecorder {\n\trr := httptest.NewRecorder()\n\tr, err := http.NewRequest(method, url, strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tr.RemoteAddr = \"9.9.9.9:1234\" // Used for tests\n\tfor k, v := range headers {\n\t\tr.Header.Set(k, v)\n\t}\n\tfor _, f := range fn {\n\t\tf(r)\n\t}\n\ts.handle(rr, r)\n\treturn rr\n}\n\nfunc subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorder) context.CancelFunc {\n\tctx, cancel := context.WithCancel(context.Background())\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdone := make(chan bool)\n\tgo func() {\n\t\ts.handle(rr, req)\n\t\tdone <- true\n\t}()\n\tcancelAndWaitForDone := func() {\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\tcancel()\n\t\t<-done\n\t}\n\ttime.Sleep(200 * time.Millisecond)\n\treturn cancelAndWaitForDone\n}\n\nfunc toMessages(t *testing.T, s string) []*model.Message {\n\tmessages := make([]*model.Message, 0)\n\tscanner := bufio.NewScanner(strings.NewReader(s))\n\tfor scanner.Scan() {\n\t\tmessages = append(messages, toMessage(t, scanner.Text()))\n\t}\n\treturn messages\n}\n\nfunc toMessage(t *testing.T, s string) *model.Message {\n\tvar m model.Message\n\trequire.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m))\n\treturn &m\n}\n\nfunc toHTTPError(t *testing.T, s string) *errHTTP {\n\tvar e errHTTP\n\trequire.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))\n\treturn &e\n}\n\nfunc readAll(t *testing.T, rc io.ReadCloser) string {\n\tb, err := io.ReadAll(rc)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(b)\n}\n\nfunc waitFor(t *testing.T, f func() bool) {\n\twaitForWithMaxWait(t, 5*time.Second, f)\n}\n\nfunc waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {\n\tstart := time.Now()\n\tfor time.Since(start) < maxWait {\n\t\tif f() {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\tt.Fatalf(\"Function f did not succeed after %v: %v\", maxWait, string(debug.Stack()))\n}\n\n// mockResponseWriter is a mock ResponseWriter for testing\ntype mockResponseWriter struct {\n\theader         http.Header\n\tstatusCode     int\n\tbody           []byte\n\twriteHeaderHit bool\n}\n\nfunc newMockResponseWriter() *mockResponseWriter {\n\treturn &mockResponseWriter{\n\t\theader: make(http.Header),\n\t}\n}\n\nfunc (m *mockResponseWriter) Header() http.Header {\n\treturn m.header\n}\n\nfunc (m *mockResponseWriter) Write(b []byte) (int, error) {\n\tm.body = append(m.body, b...)\n\treturn len(b), nil\n}\n\nfunc (m *mockResponseWriter) WriteHeader(statusCode int) {\n\tm.statusCode = statusCode\n\tm.writeHeaderHit = true\n}\n\n// closableResponseWriter simulates a real HTTP response writer that becomes invalid\n// after the handler returns. In production, Go's HTTP server calls finishRequest() after\n// the handler returns, which nils out the underlying bufio.Writer. Any subsequent Flush()\n// from a straggler Publish goroutine causes a nil pointer panic. This mock tracks whether\n// any Write or Flush occurred after the handler returned (i.e. after Close was called).\ntype closableResponseWriter struct {\n\theader          http.Header\n\tmu              sync.Mutex\n\tclosed          bool\n\twroteAfterClose atomic.Bool\n}\n\nfunc newClosableResponseWriter() *closableResponseWriter {\n\treturn &closableResponseWriter{\n\t\theader: make(http.Header),\n\t}\n}\n\nfunc (w *closableResponseWriter) Header() http.Header {\n\treturn w.header\n}\n\nfunc (w *closableResponseWriter) Write(b []byte) (int, error) {\n\tw.mu.Lock()\n\tdefer w.mu.Unlock()\n\tif w.closed {\n\t\tw.wroteAfterClose.Store(true)\n\t\treturn 0, errors.New(\"write after handler returned\")\n\t}\n\treturn len(b), nil\n}\n\nfunc (w *closableResponseWriter) WriteHeader(statusCode int) {}\n\nfunc (w *closableResponseWriter) Flush() {\n\tw.mu.Lock()\n\tdefer w.mu.Unlock()\n\tif w.closed {\n\t\tw.wroteAfterClose.Store(true)\n\t}\n}\n\n// Close simulates Go's HTTP server cleaning up the response writer after the handler returns.\nfunc (w *closableResponseWriter) Close() {\n\tw.mu.Lock()\n\tdefer w.mu.Unlock()\n\tw.closed = true\n}\n\nfunc TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338:\n\t\t//\n\t\t//   panic: runtime error: invalid memory address or nil pointer dereference\n\t\t//   bufio.(*Writer).Flush(...)\n\t\t//   net/http.(*response).Flush(...)\n\t\t//   server.(*Server).handleSubscribeHTTP.func2(...)\n\t\t//   server.(*topic).Publish.func1.1(...)\n\t\t//\n\t\t// The race: topic.Publish() copies the subscriber list and calls each subscriber in its own\n\t\t// goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up\n\t\t// the response writer. But a Publish goroutine that copied the subscriber list BEFORE\n\t\t// Unsubscribe may still call sub() AFTER the handler returns.\n\t\t//\n\t\t// This test deterministically reproduces the scenario by:\n\t\t// 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic)\n\t\t// 2. Copying the subscriber function from the topic (simulating what topic.Publish does)\n\t\t// 3. Cancelling the subscription and waiting for the handler to fully return\n\t\t// 4. Calling the copied subscriber function AFTER the handler has returned\n\t\t// 5. Checking that no write/flush occurred on the (now-invalid) response writer\n\t\t//\n\t\t// Without the wlock+closed fix, calling the subscriber after the handler returns writes to\n\t\t// the closed response writer (which in production causes a nil pointer panic on Flush).\n\t\t// With the fix, the subscriber sees closed=true and returns without writing.\n\t\tt.Parallel()\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\trw := newClosableResponseWriter()\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"/mytopic/json\", nil)\n\t\trequire.Nil(t, err)\n\t\treq.RemoteAddr = \"9.9.9.9:1234\"\n\n\t\t// Start the subscribe handler (blocks until context is cancelled)\n\t\thandlerDone := make(chan struct{})\n\t\tgo func() {\n\t\t\ts.handle(rw, req)\n\t\t\tclose(handlerDone)\n\t\t}()\n\t\ttime.Sleep(100 * time.Millisecond) // Wait for subscription to be registered\n\n\t\t// Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does\n\t\t// via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber.\n\t\ts.mu.RLock()\n\t\ttp := s.topics[\"mytopic\"]\n\t\ts.mu.RUnlock()\n\t\trequire.NotNil(t, tp)\n\t\tsubscribersCopy := tp.subscribersCopy()\n\t\trequire.Equal(t, 1, len(subscribersCopy))\n\n\t\tvar copiedSub subscriber\n\t\tfor _, sub := range subscribersCopy {\n\t\t\tcopiedSub = sub.subscriber\n\t\t}\n\n\t\t// Cancel the subscription and wait for the handler to fully return.\n\t\t// At this point, the deferred cleanup in handleSubscribeHTTP runs:\n\t\t// - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock()\n\t\t// - Without fix: nothing prevents future sub() calls from writing\n\t\tcancel()\n\t\t<-handlerDone\n\n\t\t// Simulate Go's HTTP server cleaning up the response writer after the handler returns.\n\t\t// In production, this is finishRequest() which nils out the bufio.Writer.\n\t\trw.Close()\n\n\t\t// Now call the copied subscriber function, simulating a straggler Publish goroutine\n\t\t// that copied the subscriber list before Unsubscribe ran. In production, this is exactly\n\t\t// how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the\n\t\t// handler has already returned and Go has cleaned up the response writer.\n\t\tv := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr(\"9.9.9.9\"), nil)\n\t\tmsg := model.NewDefaultMessage(\"mytopic\", \"straggler message\")\n\t\t_ = copiedSub(v, msg)\n\n\t\trequire.False(t, rw.wroteAfterClose.Load(),\n\t\t\t\"sub() wrote to the response writer after the handler returned; \"+\n\t\t\t\t\"in production this causes a nil pointer panic in bufio.(*Writer).Flush()\")\n\t})\n}\n\nfunc TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\t// Test that handleError does not call WriteHeader for WebSocket errors wrapped\n\t\t// with errWebSocketPostUpgrade (indicating the connection was hijacked)\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\t// Create a WebSocket upgrade request\n\t\tr, _ := http.NewRequest(\"GET\", \"/mytopic/ws\", nil)\n\t\tr.Header.Set(\"Upgrade\", \"websocket\")\n\t\tr.Header.Set(\"Connection\", \"Upgrade\")\n\t\tv := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr(\"1.2.3.4\"), nil)\n\n\t\t// Test post-upgrade errors wrapped with errWebSocketPostUpgrade (should NOT call WriteHeader)\n\t\tpostUpgradeErr := &errWebSocketPostUpgrade{errors.New(\"websocket: close 1000 (normal)\")}\n\t\tmock := newMockResponseWriter()\n\t\ts.handleError(mock, r, v, postUpgradeErr)\n\t\trequire.False(t, mock.writeHeaderHit, \"WriteHeader should not be called for post-upgrade errors\")\n\n\t\t// Test pre-upgrade errors (should call WriteHeader)\n\t\tpreUpgradeErrors := []error{\n\t\t\terrHTTPBadRequestWebSocketsUpgradeHeaderMissing,\n\t\t\terrHTTPTooManyRequestsLimitSubscriptions,\n\t\t\terrHTTPInternalError,\n\t\t}\n\t\tfor _, err := range preUpgradeErrors {\n\t\t\tmock := newMockResponseWriter()\n\t\t\ts.handleError(mock, r, v, err)\n\t\t\trequire.True(t, mock.writeHeaderHit, \"WriteHeader should be called for error: %s\", err.Error())\n\t\t}\n\t})\n}\n\nfunc TestServer_Publish_InvalidUTF8InBody(t *testing.T) {\n\t// All byte sequences from production logs, sent as message body\n\ttests := []struct {\n\t\tname    string\n\t\tbody    string\n\t\tmessage string\n\t}{\n\t\t{\"0xc9_0x43\", \"\\xc9Cas du serveur\", \"\\uFFFDCas du serveur\"},                      // Latin-1 \"ÉC\"\n\t\t{\"0xae\", \"Product\\xae Pro\", \"Product\\uFFFD Pro\"},                                 // Latin-1 \"®\"\n\t\t{\"0xe8_0x6d_0x65\", \"probl\\xe8me critique\", \"probl\\uFFFDme critique\"},             // Latin-1 \"ème\"\n\t\t{\"0xb2\", \"CO\\xb2 level high\", \"CO\\uFFFD level high\"},                             // Latin-1 \"²\"\n\t\t{\"0xe9_0x6d_0x61\", \"th\\xe9matique\", \"th\\uFFFDmatique\"},                           // Latin-1 \"éma\"\n\t\t{\"0xed_0x64_0x65\", \"vid\\xed\\x64eo surveillance\", \"vid\\uFFFDdeo surveillance\"},    // Latin-1 \"íde\"\n\t\t{\"0xf3_0x6e_0x3a_0x20\", \"notificaci\\xf3n: alerta\", \"notificaci\\uFFFDn: alerta\"},  // Latin-1 \"ón: \"\n\t\t{\"0xb7\", \"item\\xb7value\", \"item\\uFFFDvalue\"},                                     // Latin-1 \"·\"\n\t\t{\"0xa8\", \"na\\xa8ve\", \"na\\uFFFDve\"},                                               // Latin-1 \"¨\"\n\t\t{\"0x00\", \"hello\\x00world\", \"helloworld\"},                                         // NUL byte\n\t\t{\"0xdf_0x64\", \"gro\\xdf\\x64ruck\", \"gro\\uFFFDdruck\"},                               // Latin-1 \"ßd\"\n\t\t{\"0xe4_0x67_0x74\", \"tr\\xe4gt Last\", \"tr\\uFFFDgt Last\"},                           // Latin-1 \"ägt\"\n\t\t{\"0xe9_0x65_0x20\", \"journ\\xe9\\x65 termin\\xe9\\x65\", \"journ\\uFFFDe termin\\uFFFDe\"}, // Latin-1 \"ée\"\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ts := newTestServer(t, newTestConfig(t, \"\"))\n\n\t\t\t// Publish via x-message header (the most common path for invalid UTF-8 from HTTP headers)\n\t\t\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"\", map[string]string{\n\t\t\t\t\"X-Message\": tc.body,\n\t\t\t})\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\tmsg := toMessage(t, response.Body.String())\n\t\t\trequire.Equal(t, tc.message, msg.Message)\n\n\t\t\t// Verify it was stored in the cache correctly\n\t\t\tresponse = request(t, s, \"GET\", \"/mytopic/json?poll=1\", \"\", nil)\n\t\t\trequire.Equal(t, 200, response.Code)\n\t\t\tmsg = toMessage(t, response.Body.String())\n\t\t\trequire.Equal(t, tc.message, msg.Message)\n\t\t})\n\t}\n}\n\nfunc TestServer_Publish_InvalidUTF8InTitle(t *testing.T) {\n\ts := newTestServer(t, newTestConfig(t, \"\"))\n\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"valid body\", map[string]string{\n\t\t\"Title\": \"\\xc9clipse du syst\\xe8me\",\n\t})\n\trequire.Equal(t, 200, response.Code)\n\tmsg := toMessage(t, response.Body.String())\n\trequire.Equal(t, \"\\uFFFDclipse du syst\\uFFFDme\", msg.Title)\n\trequire.Equal(t, \"valid body\", msg.Message)\n}\n\nfunc TestServer_Publish_InvalidUTF8InTags(t *testing.T) {\n\ts := newTestServer(t, newTestConfig(t, \"\"))\n\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"valid body\", map[string]string{\n\t\t\"Tags\": \"probl\\xe8me,syst\\xe9me\",\n\t})\n\trequire.Equal(t, 200, response.Code)\n\tmsg := toMessage(t, response.Body.String())\n\trequire.Equal(t, \"probl\\uFFFDme\", msg.Tags[0])\n\trequire.Equal(t, \"syst\\uFFFDme\", msg.Tags[1])\n}\n\nfunc TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {\n\t// Verify that sanitization happens before Firebase dispatch, so Firebase\n\t// receives clean UTF-8 strings rather than invalid byte sequences\n\tsender := newTestFirebaseSender(10)\n\ts := newTestServer(t, newTestConfig(t, \"\"))\n\ts.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})\n\n\tresponse := request(t, s, \"PUT\", \"/mytopic\", \"\", map[string]string{\n\t\t\"X-Message\": \"notificaci\\xf3n: alerta\",\n\t\t\"Title\":     \"\\xc9clipse\",\n\t\t\"Tags\":      \"probl\\xe8me\",\n\t})\n\trequire.Equal(t, 200, response.Code)\n\n\ttime.Sleep(100 * time.Millisecond) // Firebase publishing happens asynchronously\n\trequire.Equal(t, 1, len(sender.Messages()))\n\trequire.Equal(t, \"notificaci\\uFFFDn: alerta\", sender.Messages()[0].Data[\"message\"])\n\trequire.Equal(t, \"\\uFFFDclipse\", sender.Messages()[0].Data[\"title\"])\n\trequire.Equal(t, \"probl\\uFFFDme\", sender.Messages()[0].Data[\"tags\"])\n}\n"
  },
  {
    "path": "server/server_twilio.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.\n// It can be overridden in the server configuration's twilio-call-format field.\n//\n// The format uses Go template syntax with the following fields:\n// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}\n// String fields are automatically XML-escaped.\nvar defaultTwilioCallFormatTemplate = template.Must(template.New(\"twiml\").Parse(`\n<Response>\n\t<Pause length=\"1\"/>\n\t<Say loop=\"3\">\n\t\tYou have a message from notify on topic {{.Topic}}. Message:\n\t\t<break time=\"1s\"/>\n\t\t{{.Message}}\n\t\t<break time=\"1s\"/>\n\t\tEnd of message.\n\t\t<break time=\"1s\"/>\n\t\tThis message was sent by user {{.Sender}}. It will be repeated three times.\n\t\tTo unsubscribe from calls like this, remove your phone number in the notify web app.\n\t\t<break time=\"3s\"/>\n\t</Say>\n\t<Say>Goodbye.</Say>\n</Response>`))\n\n// twilioCallData holds the data passed to the Twilio call format template\ntype twilioCallData struct {\n\tTopic    string\n\tTitle    string\n\tMessage  string\n\tPriority int\n\tTags     []string\n\tSender   string\n}\n\n// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified\n// phone number. It also converts a boolean string (\"yes\", \"1\", \"true\") to the first verified phone number.\n// If the user is anonymous, it will return an error.\nfunc (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {\n\tif u == nil {\n\t\treturn \"\", errHTTPBadRequestAnonymousCallsNotAllowed\n\t}\n\tphoneNumbers, err := s.userManager.PhoneNumbers(u.ID)\n\tif err != nil {\n\t\treturn \"\", errHTTPInternalError\n\t} else if len(phoneNumbers) == 0 {\n\t\treturn \"\", errHTTPBadRequestPhoneNumberNotVerified\n\t}\n\tif toBool(phoneNumber) {\n\t\treturn phoneNumbers[0], nil\n\t} else if util.Contains(phoneNumbers, phoneNumber) {\n\t\treturn phoneNumber, nil\n\t}\n\tfor _, p := range phoneNumbers {\n\t\tif p == phoneNumber {\n\t\t\treturn phoneNumber, nil\n\t\t}\n\t}\n\treturn \"\", errHTTPBadRequestPhoneNumberNotVerified\n}\n\n// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.\n// Failures will be logged, but not returned to the caller.\nfunc (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {\n\tu, sender := v.User(), m.Sender.String()\n\tif u != nil {\n\t\tsender = u.Name\n\t}\n\ttmpl := defaultTwilioCallFormatTemplate\n\tif s.config.TwilioCallFormat != nil {\n\t\ttmpl = s.config.TwilioCallFormat\n\t}\n\ttags := make([]string, len(m.Tags))\n\tfor i, tag := range m.Tags {\n\t\ttags[i] = xmlEscapeText(tag)\n\t}\n\ttemplateData := &twilioCallData{\n\t\tTopic:    xmlEscapeText(m.Topic),\n\t\tTitle:    xmlEscapeText(m.Title),\n\t\tMessage:  xmlEscapeText(m.Message),\n\t\tPriority: m.Priority,\n\t\tTags:     tags,\n\t\tSender:   xmlEscapeText(sender),\n\t}\n\tvar bodyBuf bytes.Buffer\n\tif err := tmpl.Execute(&bodyBuf, templateData); err != nil {\n\t\tlogvrm(v, r, m).Tag(tagTwilio).Err(err).Warn(\"Error executing Twilio call format template\")\n\t\tminc(metricCallsMadeFailure)\n\t\treturn\n\t}\n\tbody := bodyBuf.String()\n\tdata := url.Values{}\n\tdata.Set(\"From\", s.config.TwilioPhoneNumber)\n\tdata.Set(\"To\", to)\n\tdata.Set(\"Twiml\", body)\n\tev := logvrm(v, r, m).Tag(tagTwilio).Field(\"twilio_to\", to).FieldIf(\"twilio_body\", body, log.TraceLevel).Debug(\"Sending Twilio request\")\n\tresponse, err := s.callPhoneInternal(data)\n\tif err != nil {\n\t\tev.Field(\"twilio_response\", response).Err(err).Warn(\"Error sending Twilio request\")\n\t\tminc(metricCallsMadeFailure)\n\t\treturn\n\t}\n\tev.FieldIf(\"twilio_response\", response, log.TraceLevel).Debug(\"Received successful Twilio response\")\n\tminc(metricCallsMadeSuccess)\n}\n\nfunc (s *Server) callPhoneInternal(data url.Values) (string, error) {\n\trequestURL := fmt.Sprintf(\"%s/2010-04-01/Accounts/%s/Calls.json\", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)\n\treq, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"User-Agent\", \"ntfy/\"+s.config.BuildVersion)\n\treq.Header.Add(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Authorization\", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tresponse, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(response), nil\n}\n\nfunc (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {\n\tev := logvr(v, r).Tag(tagTwilio).Field(\"twilio_to\", phoneNumber).Field(\"twilio_channel\", channel).Debug(\"Sending phone verification\")\n\tdata := url.Values{}\n\tdata.Set(\"To\", phoneNumber)\n\tdata.Set(\"Channel\", channel)\n\trequestURL := fmt.Sprintf(\"%s/v2/Services/%s/Verifications\", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)\n\treq, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"User-Agent\", \"ntfy/\"+s.config.BuildVersion)\n\treq.Header.Add(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Authorization\", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresponse, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tev.Err(err).Warn(\"Error sending Twilio phone verification request\")\n\t\treturn err\n\t}\n\tev.FieldIf(\"twilio_response\", string(response), log.TraceLevel).Debug(\"Received Twilio phone verification response\")\n\treturn nil\n}\n\nfunc (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {\n\tev := logvr(v, r).Tag(tagTwilio).Field(\"twilio_to\", phoneNumber).Debug(\"Checking phone verification\")\n\tdata := url.Values{}\n\tdata.Set(\"To\", phoneNumber)\n\tdata.Set(\"Code\", code)\n\trequestURL := fmt.Sprintf(\"%s/v2/Services/%s/VerificationCheck\", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)\n\treq, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"User-Agent\", \"ntfy/\"+s.config.BuildVersion)\n\treq.Header.Add(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Authorization\", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t} else if resp.StatusCode != http.StatusOK {\n\t\tif ev.IsTrace() {\n\t\t\tresponse, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tev.Field(\"twilio_response\", string(response))\n\t\t}\n\t\tev.Warn(\"Twilio phone verification failed with status code %d\", resp.StatusCode)\n\t\tif resp.StatusCode == http.StatusNotFound {\n\t\t\treturn errHTTPGonePhoneVerificationExpired\n\t\t}\n\t\treturn errHTTPInternalError\n\t}\n\tresponse, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif ev.IsTrace() {\n\t\tev.Field(\"twilio_response\", string(response)).Trace(\"Received successful Twilio phone verification response\")\n\t} else if ev.IsDebug() {\n\t\tev.Debug(\"Received successful Twilio phone verification response\")\n\t}\n\treturn nil\n}\n\nfunc xmlEscapeText(text string) string {\n\tvar buf bytes.Buffer\n\t_ = xml.EscapeText(&buf, []byte(text))\n\treturn buf.String()\n}\n"
  },
  {
    "path": "server/server_twilio_test.go",
    "content": "package server\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nfunc TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tvar called, verified atomic.Bool\n\t\tvar code atomic.Pointer[string]\n\t\ttwilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==\", r.Header.Get(\"Authorization\"))\n\t\t\tif r.URL.Path == \"/v2/Services/VA1234567890/Verifications\" {\n\t\t\t\tif code.Load() != nil {\n\t\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t\t}\n\t\t\t\trequire.Equal(t, \"Channel=sms&To=%2B12223334444\", string(body))\n\t\t\t\tcode.Store(util.String(\"123456\"))\n\t\t\t} else if r.URL.Path == \"/v2/Services/VA1234567890/VerificationCheck\" {\n\t\t\t\tif verified.Load() {\n\t\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t\t}\n\t\t\t\trequire.Equal(t, \"Code=123456&To=%2B12223334444\", string(body))\n\t\t\t\tverified.Store(true)\n\t\t\t} else {\n\t\t\t\tt.Fatal(\"Unexpected path:\", r.URL.Path)\n\t\t\t}\n\t\t}))\n\t\tdefer twilioVerifyServer.Close()\n\t\ttwilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif called.Load() {\n\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/2010-04-01/Accounts/AC1234567890/Calls.json\", r.URL.Path)\n\t\t\trequire.Equal(t, \"Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==\", r.Header.Get(\"Authorization\"))\n\t\t\trequire.Equal(t, \"From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E\", string(body))\n\t\t\tcalled.Store(true)\n\t\t}))\n\t\tdefer twilioCallsServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioVerifyBaseURL = twilioVerifyServer.URL\n\t\tc.TwilioCallsBaseURL = twilioCallsServer.URL\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\tc.TwilioVerifyService = \"VA1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Add tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:         \"pro\",\n\t\t\tMessageLimit: 10,\n\t\t\tCallLimit:    1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t// Send verification code for phone number\n\t\tresponse := request(t, s, \"PUT\", \"/v1/account/phone/verify\", `{\"number\":\"+12223334444\",\"channel\":\"sms\"}`, map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\twaitFor(t, func() bool {\n\t\t\treturn *code.Load() == \"123456\"\n\t\t})\n\n\t\t// Add phone number with code\n\t\tresponse = request(t, s, \"PUT\", \"/v1/account/phone\", `{\"number\":\"+12223334444\",\"code\":\"123456\"}`, map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\twaitFor(t, func() bool {\n\t\t\treturn verified.Load()\n\t\t})\n\t\tphoneNumbers, err := s.userManager.PhoneNumbers(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(phoneNumbers))\n\t\trequire.Equal(t, \"+12223334444\", phoneNumbers[0])\n\n\t\t// Do the thing\n\t\tresponse = request(t, s, \"POST\", \"/mytopic\", \"hi there\", map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"x-call\":        \"yes\",\n\t\t})\n\t\trequire.Equal(t, \"hi there\", toMessage(t, response.Body.String()).Message)\n\t\twaitFor(t, func() bool {\n\t\t\treturn called.Load()\n\t\t})\n\n\t\t// Remove the phone number\n\t\tresponse = request(t, s, \"DELETE\", \"/v1/account/phone\", `{\"number\":\"+12223334444\"}`, map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\n\t\t// Verify the phone number is gone from the DB\n\t\tphoneNumbers, err = s.userManager.PhoneNumbers(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(phoneNumbers))\n\t})\n}\n\nfunc TestServer_Twilio_Call_Success(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tvar called atomic.Bool\n\t\ttwilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif called.Load() {\n\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/2010-04-01/Accounts/AC1234567890/Calls.json\", r.URL.Path)\n\t\t\trequire.Equal(t, \"Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==\", r.Header.Get(\"Authorization\"))\n\t\t\trequire.Equal(t, \"From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E\", string(body))\n\t\t\tcalled.Store(true)\n\t\t}))\n\t\tdefer twilioServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = twilioServer.URL\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Add tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:         \"pro\",\n\t\t\tMessageLimit: 10,\n\t\t\tCallLimit:    1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, s.userManager.AddPhoneNumber(u.ID, \"+11122233344\"))\n\n\t\t// Do the thing\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"hi there\", map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"x-call\":        \"+11122233344\",\n\t\t})\n\t\trequire.Equal(t, \"hi there\", toMessage(t, response.Body.String()).Message)\n\t\twaitFor(t, func() bool {\n\t\t\treturn called.Load()\n\t\t})\n\t})\n}\n\nfunc TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tvar called atomic.Bool\n\t\ttwilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif called.Load() {\n\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/2010-04-01/Accounts/AC1234567890/Calls.json\", r.URL.Path)\n\t\t\trequire.Equal(t, \"Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==\", r.Header.Get(\"Authorization\"))\n\t\t\trequire.Equal(t, \"From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E\", string(body))\n\t\t\tcalled.Store(true)\n\t\t}))\n\t\tdefer twilioServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = twilioServer.URL\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Add tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:         \"pro\",\n\t\t\tMessageLimit: 10,\n\t\t\tCallLimit:    1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, s.userManager.AddPhoneNumber(u.ID, \"+11122233344\"))\n\n\t\t// Do the thing\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"hi there\", map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"x-call\":        \"yes\", // <<<------\n\t\t})\n\t\trequire.Equal(t, \"hi there\", toMessage(t, response.Body.String()).Message)\n\t\twaitFor(t, func() bool {\n\t\t\treturn called.Load()\n\t\t})\n\t})\n}\n\nfunc TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tvar called atomic.Bool\n\t\ttwilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif called.Load() {\n\t\t\t\tt.Fatal(\"Should be only called once\")\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/2010-04-01/Accounts/AC1234567890/Calls.json\", r.URL.Path)\n\t\t\trequire.Equal(t, \"Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==\", r.Header.Get(\"Authorization\"))\n\t\t\trequire.Equal(t, \"From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E\", string(body))\n\t\t\tcalled.Store(true)\n\t\t}))\n\t\tdefer twilioServer.Close()\n\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = twilioServer.URL\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\tc.TwilioCallFormat = template.Must(template.New(\"twiml\").Parse(`\n<Response>\n\t<Pause length=\"1\"/>\n\t<Say language=\"de-DE\" loop=\"3\">\n\t\tDu hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:\n\t\t<break time=\"1s\"/>\n\t\t{{.Message}}\n\t\t<break time=\"1s\"/>\n\t\tEnde der Nachricht.\n\t\t<break time=\"1s\"/>\n\t\tDiese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.\n\t\tUm dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.\n\t\t<break time=\"3s\"/>\n\t</Say>\n\t<Say language=\"de-DE\">Auf Wiederhören.</Say>\n</Response>`))\n\t\ts := newTestServer(t, c)\n\n\t\t// Add tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:         \"pro\",\n\t\t\tMessageLimit: 10,\n\t\t\tCallLimit:    1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\t\tu, err := s.userManager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, s.userManager.AddPhoneNumber(u.ID, \"+11122233344\"))\n\n\t\t// Do the thing\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"hi there\", map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"x-call\":        \"+11122233344\",\n\t\t})\n\t\trequire.Equal(t, \"hi there\", toMessage(t, response.Body.String()).Message)\n\t\twaitFor(t, func() bool {\n\t\t\treturn called.Load()\n\t\t})\n\t})\n}\n\nfunc TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = \"http://dummy.invalid\"\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\t// Add tier and user\n\t\trequire.Nil(t, s.userManager.AddTier(&user.Tier{\n\t\t\tCode:         \"pro\",\n\t\t\tMessageLimit: 10,\n\t\t\tCallLimit:    1,\n\t\t}))\n\t\trequire.Nil(t, s.userManager.AddUser(\"phil\", \"phil\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.ChangeTier(\"phil\", \"pro\"))\n\n\t\t// Do the thing\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"authorization\": util.BasicAuth(\"phil\", \"phil\"),\n\t\t\t\"x-call\":        \"+11122233344\",\n\t\t})\n\t\trequire.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_Twilio_Call_InvalidNumber(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = \"https://127.0.0.1\"\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"x-call\": \"+invalid\",\n\t\t})\n\t\trequire.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_Twilio_Call_Anonymous(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tc := newTestConfigWithAuthFile(t, databaseURL)\n\t\tc.TwilioCallsBaseURL = \"https://127.0.0.1\"\n\t\tc.TwilioAccount = \"AC1234567890\"\n\t\tc.TwilioAuthToken = \"AAEAA1234567890\"\n\t\tc.TwilioPhoneNumber = \"+1234567890\"\n\t\ts := newTestServer(t, c)\n\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"x-call\": \"+123123\",\n\t\t})\n\t\trequire.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n\nfunc TestServer_Twilio_Call_Unconfigured(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\t\tresponse := request(t, s, \"POST\", \"/mytopic\", \"test\", map[string]string{\n\t\t\t\"x-call\": \"+1234\",\n\t\t})\n\t\trequire.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)\n\t})\n}\n"
  },
  {
    "path": "server/server_webpush.go",
    "content": "//go:build !nowebpush\n\npackage server\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/SherClockHolmes/webpush-go\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\twpush \"heckel.io/ntfy/v2/webpush\"\n)\n\nconst (\n\t// WebPushAvailable is a constant used to indicate that WebPush support is available.\n\t// It can be disabled with the 'nowebpush' build tag.\n\tWebPushAvailable = true\n\n\twebPushTopicSubscribeLimit = 50\n)\n\nvar (\n\twebPushAllowedEndpointsPatterns = []string{\n\t\t\"https://*.google.com/\",\n\t\t\"https://*.googleapis.com/\",\n\t\t\"https://*.mozilla.com/\",\n\t\t\"https://*.mozaws.net/\",\n\t\t\"https://*.windows.com/\",\n\t\t\"https://*.microsoft.com/\",\n\t\t\"https://*.apple.com/\",\n\t}\n\twebPushAllowedEndpointsRegex *regexp.Regexp\n)\n\nfunc init() {\n\tfor i, pattern := range webPushAllowedEndpointsPatterns {\n\t\twebPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, \".\", \"\\\\.\"), \"*\", \".+\")\n\t}\n\tallPatterns := fmt.Sprintf(\"^(%s)\", strings.Join(webPushAllowedEndpointsPatterns, \"|\"))\n\twebPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)\n}\n\nfunc (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treq, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil || req.Endpoint == \"\" || req.P256dh == \"\" || req.Auth == \"\" {\n\t\treturn errHTTPBadRequestWebPushSubscriptionInvalid\n\t} else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {\n\t\treturn errHTTPBadRequestWebPushEndpointUnknown\n\t} else if len(req.Topics) > webPushTopicSubscribeLimit {\n\t\treturn errHTTPBadRequestWebPushTopicCountTooHigh\n\t}\n\ttopics, err := s.topicsFromIDs(req.Topics...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif s.userManager != nil {\n\t\tu := v.User()\n\t\tfor _, t := range topics {\n\t\t\tif err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {\n\t\t\t\tlogvr(v, r).With(t).Err(err).Debug(\"Access to topic %s not authorized\", t.ID)\n\t\t\t\treturn errHTTPForbidden.With(t)\n\t\t\t}\n\t\t}\n\t}\n\tif err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {\n\treq, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)\n\tif err != nil || req.Endpoint == \"\" {\n\t\treturn errHTTPBadRequestWebPushSubscriptionInvalid\n\t}\n\tif err := s.webPush.RemoveSubscriptionsByEndpoint(req.Endpoint); err != nil {\n\t\treturn err\n\t}\n\treturn s.writeJSON(w, newSuccessResponse())\n}\n\nfunc (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {\n\tsubscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)\n\tif err != nil {\n\t\tlogvm(v, m).Err(err).With(v, m).Warn(\"Unable to publish web push messages\")\n\t\treturn\n\t}\n\tlog.Tag(tagWebPush).With(v, m).Debug(\"Publishing web push message to %d subscribers\", len(subscriptions))\n\tpayload, err := json.Marshal(newWebPushPayload(fmt.Sprintf(\"%s/%s\", s.config.BaseURL, m.Topic), m.ForJSON()))\n\tif err != nil {\n\t\tlog.Tag(tagWebPush).Err(err).With(v, m).Warn(\"Unable to marshal expiring payload\")\n\t\treturn\n\t}\n\tfor _, subscription := range subscriptions {\n\t\tif err := s.sendWebPushNotification(subscription, payload, v, m); err != nil {\n\t\t\tlog.Tag(tagWebPush).Err(err).With(v, m, subscription).Warn(\"Unable to publish web push message\")\n\t\t}\n\t}\n}\n\nfunc (s *Server) pruneAndNotifyWebPushSubscriptions() {\n\tif s.config.WebPushPublicKey == \"\" {\n\t\treturn\n\t}\n\tgo func() {\n\t\tif err := s.pruneAndNotifyWebPushSubscriptionsInternal(); err != nil {\n\t\t\tlog.Tag(tagWebPush).Err(err).Warn(\"Unable to prune or notify web push subscriptions\")\n\t\t}\n\t}()\n}\n\nfunc (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {\n\t// Expire old subscriptions\n\tif err := s.webPush.RemoveExpiredSubscriptions(s.config.WebPushExpiryDuration); err != nil {\n\t\treturn err\n\t}\n\t// Notify subscriptions that will expire soon\n\tsubscriptions, err := s.webPush.SubscriptionsExpiring(s.config.WebPushExpiryWarningDuration)\n\tif err != nil {\n\t\treturn err\n\t} else if len(subscriptions) == 0 {\n\t\treturn nil\n\t}\n\tpayload, err := json.Marshal(newWebPushSubscriptionExpiringPayload())\n\tif err != nil {\n\t\treturn err\n\t}\n\twarningSent := make([]*wpush.Subscription, 0)\n\tfor _, subscription := range subscriptions {\n\t\tif err := s.sendWebPushNotification(subscription, payload); err != nil {\n\t\t\tlog.Tag(tagWebPush).Err(err).With(subscription).Warn(\"Unable to publish expiry imminent warning\")\n\t\t\tcontinue\n\t\t}\n\t\twarningSent = append(warningSent, subscription)\n\t}\n\tif err := s.webPush.MarkExpiryWarningSent(warningSent); err != nil {\n\t\treturn err\n\t}\n\tlog.Tag(tagWebPush).Debug(\"Expired old subscriptions and published %d expiry imminent warnings\", len(subscriptions))\n\treturn nil\n}\n\nfunc (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error {\n\tlog.Tag(tagWebPush).With(sub).With(contexters...).Debug(\"Sending web push message\")\n\tpayload := &webpush.Subscription{\n\t\tEndpoint: sub.Endpoint,\n\t\tKeys: webpush.Keys{\n\t\t\tAuth:   sub.Auth,\n\t\t\tP256dh: sub.P256dh,\n\t\t},\n\t}\n\tresp, err := webpush.SendNotification(message, payload, &webpush.Options{\n\t\tSubscriber:      s.config.WebPushEmailAddress,\n\t\tVAPIDPublicKey:  s.config.WebPushPublicKey,\n\t\tVAPIDPrivateKey: s.config.WebPushPrivateKey,\n\t\tUrgency:         webpush.UrgencyHigh, // iOS requires this to ensure delivery\n\t\tTTL:             int(s.config.CacheDuration.Seconds()),\n\t})\n\tif err != nil {\n\t\tlog.Tag(tagWebPush).With(sub).With(contexters...).Err(err).Debug(\"Unable to publish web push message, removing endpoint\")\n\t\tif err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn err\n\t}\n\tif (resp.StatusCode < 200 || resp.StatusCode > 299) && resp.StatusCode != 429 {\n\t\tlog.Tag(tagWebPush).With(sub).With(contexters...).Field(\"response_code\", resp.StatusCode).Debug(\"Unable to publish web push message, unexpected response\")\n\t\tif err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn errHTTPInternalErrorWebPushUnableToPublish.With(sub).With(contexters...)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/server_webpush_dummy.go",
    "content": "//go:build nowebpush\n\npackage server\n\nimport (\n\t\"net/http\"\n\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nconst (\n\t// WebPushAvailable is a constant used to indicate that WebPush support is available.\n\t// It can be disabled with the 'nowebpush' build tag.\n\tWebPushAvailable = false\n)\n\nfunc (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {\n\treturn errHTTPNotFound\n}\n\nfunc (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {\n\t// Nothing to see here\n}\n\nfunc (s *Server) pruneAndNotifyWebPushSubscriptions() {\n\t// Nothing to see here\n}\n"
  },
  {
    "path": "server/server_webpush_test.go",
    "content": "//go:build !nowebpush\n\npackage server\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/SherClockHolmes/webpush-go\"\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\ttestWebPushEndpoint = \"https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF\"\n)\n\nfunc TestServer_WebPush_Enabled(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconf := newTestConfig(t, databaseURL)\n\t\tconf.WebRoot = \"\" // Disable web app\n\t\ts := newTestServer(t, conf)\n\n\t\trr := request(t, s, \"GET\", \"/manifest.webmanifest\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\tconf2 := newTestConfig(t, databaseURL)\n\t\ts2 := newTestServer(t, conf2)\n\n\t\trr = request(t, s2, \"GET\", \"/manifest.webmanifest\", \"\", nil)\n\t\trequire.Equal(t, 404, rr.Code)\n\n\t\tconf3 := newTestConfigWithWebPush(t, databaseURL)\n\t\ts3 := newTestServer(t, conf3)\n\n\t\trr = request(t, s3, \"GET\", \"/manifest.webmanifest\", \"\", nil)\n\t\trequire.Equal(t, 200, rr.Code)\n\t\trequire.Equal(t, \"application/manifest+json\", rr.Header().Get(\"Content-Type\"))\n\n\t})\n}\nfunc TestServer_WebPush_Disabled(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfig(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 404, response.Code)\n\t})\n}\n\nfunc TestServer_WebPush_TopicAdd(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\n\t\tsubs, err := s.webPush.SubscriptionsForTopic(\"test-topic\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, subs[0].Endpoint, testWebPushEndpoint)\n\t\trequire.Equal(t, subs[0].P256dh, \"p256dh-key\")\n\t\trequire.Equal(t, subs[0].Auth, \"auth-key\")\n\t\trequire.Equal(t, subs[0].UserID, \"\")\n\t})\n}\n\nfunc TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, \"https://ddos-target.example.com/webpush\"), nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, `{\"code\":40039,\"http\":400,\"error\":\"invalid request: web push endpoint unknown\"}`+\"\\n\", response.Body.String())\n\t})\n}\n\nfunc TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\ttopicList := make([]string, 51)\n\t\tfor i := range topicList {\n\t\t\ttopicList[i] = util.RandomString(5)\n\t\t}\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, topicList, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 400, response.Code)\n\t\trequire.Equal(t, `{\"code\":40040,\"http\":400,\"error\":\"invalid request: too many web push topic subscriptions\"}`+\"\\n\", response.Body.String())\n\t})\n}\n\nfunc TestServer_WebPush_TopicUnsubscribe(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\taddSubscription(t, s, testWebPushEndpoint, \"test-topic\")\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 0)\n\t})\n}\n\nfunc TestServer_WebPush_Delete(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\taddSubscription(t, s, testWebPushEndpoint, \"test-topic\")\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\n\t\tresponse := request(t, s, \"DELETE\", \"/v1/webpush\", fmt.Sprintf(`{\"endpoint\":\"%s\"}`, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 0)\n\t})\n}\n\nfunc TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconfig := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))\n\t\tconfig.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, config)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"test-topic\", user.PermissionReadWrite))\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, testWebPushEndpoint), map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\n\t\tsubs, err := s.webPush.SubscriptionsForTopic(\"test-topic\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.True(t, strings.HasPrefix(subs[0].UserID, \"u_\"))\n\t})\n}\n\nfunc TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconfig := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))\n\t\tconfig.AuthDefault = user.PermissionDenyAll\n\t\ts := newTestServer(t, config)\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, testWebPushEndpoint), nil)\n\t\trequire.Equal(t, 403, response.Code)\n\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 0)\n\t})\n}\n\nfunc TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\tconfig := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))\n\t\ts := newTestServer(t, config)\n\n\t\trequire.Nil(t, s.userManager.AddUser(\"ben\", \"ben\", user.RoleUser, false))\n\t\trequire.Nil(t, s.userManager.AllowAccess(\"ben\", \"test-topic\", user.PermissionReadWrite))\n\n\t\tresponse := request(t, s, \"POST\", \"/v1/webpush\", payloadForTopics(t, []string{\"test-topic\"}, testWebPushEndpoint), map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\n\t\trequire.Equal(t, 200, response.Code)\n\t\trequire.Equal(t, `{\"success\":true}`+\"\\n\", response.Body.String())\n\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\n\t\trequest(t, s, \"DELETE\", \"/v1/account\", `{\"password\":\"ben\"}`, map[string]string{\n\t\t\t\"Authorization\": util.BasicAuth(\"ben\", \"ben\"),\n\t\t})\n\t\t// should've been deleted with the account\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 0)\n\t})\n}\n\nfunc TestServer_WebPush_Publish(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\tvar received atomic.Bool\n\t\tpushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.Equal(t, \"/push-receive\", r.URL.Path)\n\t\t\trequire.Equal(t, \"high\", r.Header.Get(\"Urgency\"))\n\t\t\trequire.Equal(t, \"\", r.Header.Get(\"Topic\"))\n\t\t\treceived.Store(true)\n\t\t}))\n\t\tdefer pushService.Close()\n\n\t\taddSubscription(t, s, pushService.URL+\"/push-receive\", \"test-topic\")\n\t\trequest(t, s, \"POST\", \"/test-topic\", \"web push test\", nil)\n\n\t\twaitFor(t, func() bool {\n\t\t\treturn received.Load()\n\t\t})\n\t})\n}\n\nfunc TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\tvar received atomic.Bool\n\t\tpushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\tw.WriteHeader(http.StatusGone)\n\t\t\treceived.Store(true)\n\t\t}))\n\t\tdefer pushService.Close()\n\n\t\taddSubscription(t, s, pushService.URL+\"/push-receive\", \"test-topic\", \"test-topic-abc\")\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\t\trequireSubscriptionCount(t, s, \"test-topic-abc\", 1)\n\n\t\trequest(t, s, \"POST\", \"/test-topic\", \"web push test\", nil)\n\n\t\twaitFor(t, func() bool {\n\t\t\treturn received.Load()\n\t\t})\n\n\t\t// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint\n\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 0)\n\t\trequireSubscriptionCount(t, s, \"test-topic-abc\", 0)\n\t})\n}\n\nfunc TestServer_WebPush_Expiry(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, databaseURL string) {\n\t\ts := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))\n\n\t\tvar received atomic.Bool\n\n\t\tpushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, err := io.ReadAll(r.Body)\n\t\t\trequire.Nil(t, err)\n\t\t\tw.WriteHeader(200)\n\t\t\tw.Write([]byte(``))\n\t\t\treceived.Store(true)\n\t\t}))\n\t\tdefer pushService.Close()\n\n\t\tendpoint := pushService.URL + \"/push-receive\"\n\t\taddSubscription(t, s, endpoint, \"test-topic\")\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\n\t\trequire.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix()))\n\n\t\ts.pruneAndNotifyWebPushSubscriptions()\n\t\trequireSubscriptionCount(t, s, \"test-topic\", 1)\n\n\t\twaitFor(t, func() bool {\n\t\t\treturn received.Load()\n\t\t})\n\n\t\trequire.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix()))\n\n\t\ts.pruneAndNotifyWebPushSubscriptions()\n\t\twaitFor(t, func() bool {\n\t\t\tsubs, err := s.webPush.SubscriptionsForTopic(\"test-topic\")\n\t\t\trequire.Nil(t, err)\n\t\t\treturn len(subs) == 0\n\t\t})\n\t})\n}\n\nfunc payloadForTopics(t *testing.T, topics []string, endpoint string) string {\n\ttopicsJSON, err := json.Marshal(topics)\n\trequire.Nil(t, err)\n\n\treturn fmt.Sprintf(`{\n\t\t\"topics\": %s,\n\t\t\"endpoint\": \"%s\",\n\t\t\"p256dh\": \"p256dh-key\",\n\t\t\"auth\": \"auth-key\"\n\t}`, topicsJSON, endpoint)\n}\n\nfunc addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) {\n\trequire.Nil(t, s.webPush.UpsertSubscription(endpoint, \"kSC3T8aN1JCQxxPdrFLrZg\", \"BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE\", \"u_123\", netip.MustParseAddr(\"1.2.3.4\"), topics)) // Test auth and p256dh\n}\n\nfunc requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) {\n\tsubs, err := s.webPush.SubscriptionsForTopic(topic)\n\trequire.Nil(t, err)\n\trequire.Len(t, subs, expectedLength)\n}\n\nfunc newTestConfigWithWebPush(t *testing.T, databaseURL string) *Config {\n\tconf := newTestConfig(t, databaseURL)\n\tprivateKey, publicKey, err := webpush.GenerateVAPIDKeys()\n\trequire.Nil(t, err)\n\tif conf.DatabaseURL == \"\" {\n\t\tconf.WebPushFile = filepath.Join(t.TempDir(), \"webpush.db\")\n\t}\n\tconf.WebPushEmailAddress = \"testing@example.com\"\n\tconf.WebPushPrivateKey = privateKey\n\tconf.WebPushPublicKey = publicKey\n\treturn conf\n}\n"
  },
  {
    "path": "server/smtp_sender.go",
    "content": "package server\n\nimport (\n\t_ \"embed\" // required by go:embed\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"mime\"\n\t\"net\"\n\t\"net/smtp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\ntype mailer interface {\n\tSend(v *visitor, m *model.Message, to string) error\n\tCounts() (total int64, success int64, failure int64)\n}\n\ntype smtpSender struct {\n\tconfig  *Config\n\tsuccess int64\n\tfailure int64\n\tmu      sync.Mutex\n}\n\nfunc (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {\n\treturn s.withCount(v, m, func() error {\n\t\thost, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tmessage, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tvar auth smtp.Auth\n\t\tif s.config.SMTPSenderUser != \"\" {\n\t\t\tauth = smtp.PlainAuth(\"\", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)\n\t\t}\n\t\tev := logvm(v, m).\n\t\t\tTag(tagEmail).\n\t\t\tFields(log.Context{\n\t\t\t\t\"email_via\":  s.config.SMTPSenderAddr,\n\t\t\t\t\"email_user\": s.config.SMTPSenderUser,\n\t\t\t\t\"email_to\":   to,\n\t\t\t})\n\t\tif ev.IsTrace() {\n\t\t\tev.Field(\"email_body\", message).Trace(\"Sending email\")\n\t\t} else if ev.IsDebug() {\n\t\t\tev.Debug(\"Sending email\")\n\t\t}\n\t\treturn smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))\n\t})\n}\n\nfunc (s *smtpSender) Counts() (total int64, success int64, failure int64) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn s.success + s.failure, s.success, s.failure\n}\n\nfunc (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {\n\terr := fn()\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tif err != nil {\n\t\tlogvm(v, m).Err(err).Debug(\"Sending mail failed\")\n\t\ts.failure++\n\t} else {\n\t\ts.success++\n\t}\n\treturn err\n}\n\nfunc formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {\n\ttopicURL := baseURL + \"/\" + m.Topic\n\tsubject := m.Title\n\tif subject == \"\" {\n\t\tsubject = m.Message\n\t}\n\tsubject = strings.ReplaceAll(strings.ReplaceAll(subject, \"\\r\", \"\"), \"\\n\", \" \")\n\tmessage := m.Message\n\ttrailer := \"\"\n\tif len(m.Tags) > 0 {\n\t\temojis, tags, err := toEmojis(m.Tags)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif len(emojis) > 0 {\n\t\t\tsubject = strings.Join(emojis, \" \") + \" \" + subject\n\t\t}\n\t\tif len(tags) > 0 {\n\t\t\ttrailer = \"Tags: \" + strings.Join(tags, \", \")\n\t\t}\n\t}\n\tif m.Priority != 0 && m.Priority != 3 {\n\t\tpriority, err := util.PriorityString(m.Priority)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif trailer != \"\" {\n\t\t\ttrailer += \"\\n\"\n\t\t}\n\t\ttrailer += fmt.Sprintf(\"Priority: %s\", priority)\n\t}\n\tif trailer != \"\" {\n\t\tmessage += \"\\n\\n\" + trailer\n\t}\n\tdate := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z)\n\tsubject = mime.BEncoding.Encode(\"utf-8\", subject)\n\tbody := `From: \"{shortTopicURL}\" <{from}>\nTo: {to}\nDate: {date}\nSubject: {subject}\nContent-Type: text/plain; charset=\"utf-8\"\n\n{message}\n\n--\nThis message was sent by {ip} at {time} via {topicURL}`\n\tbody = strings.ReplaceAll(body, \"{from}\", from)\n\tbody = strings.ReplaceAll(body, \"{to}\", to)\n\tbody = strings.ReplaceAll(body, \"{date}\", date)\n\tbody = strings.ReplaceAll(body, \"{subject}\", subject)\n\tbody = strings.ReplaceAll(body, \"{message}\", message)\n\tbody = strings.ReplaceAll(body, \"{topicURL}\", topicURL)\n\tbody = strings.ReplaceAll(body, \"{shortTopicURL}\", util.ShortTopicURL(topicURL))\n\tbody = strings.ReplaceAll(body, \"{time}\", time.Unix(m.Time, 0).UTC().Format(time.RFC1123))\n\tbody = strings.ReplaceAll(body, \"{ip}\", senderIP)\n\treturn body, nil\n}\n\nvar (\n\t//go:embed \"mailer_emoji_map.json\"\n\temojisJSON string\n)\n\nfunc toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {\n\tvar emojiMap map[string]string\n\tif err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {\n\t\treturn nil, nil, err\n\t}\n\ttagsOut = make([]string, 0)\n\temojisOut = make([]string, 0)\n\tfor _, t := range tags {\n\t\tif emoji, ok := emojiMap[t]; ok {\n\t\t\temojisOut = append(emojisOut, emoji)\n\t\t} else {\n\t\t\ttagsOut = append(tagsOut, t)\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "server/smtp_sender_test.go",
    "content": "package server\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nfunc TestFormatMail_Basic(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:      \"abc\",\n\t\tTime:    1640382204,\n\t\tEvent:   \"message\",\n\t\tTopic:   \"alerts\",\n\t\tMessage: \"A simple message\",\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: A simple message\nContent-Type: text/plain; charset=\"utf-8\"\n\nA simple message\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n\nfunc TestFormatMail_JustEmojis(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:      \"abc\",\n\t\tTime:    1640382204,\n\t\tEvent:   \"message\",\n\t\tTopic:   \"alerts\",\n\t\tMessage: \"A simple message\",\n\t\tTags:    []string{\"grinning\"},\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=\nContent-Type: text/plain; charset=\"utf-8\"\n\nA simple message\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n\nfunc TestFormatMail_JustOtherTags(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:      \"abc\",\n\t\tTime:    1640382204,\n\t\tEvent:   \"message\",\n\t\tTopic:   \"alerts\",\n\t\tMessage: \"A simple message\",\n\t\tTags:    []string{\"not-an-emoji\"},\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: A simple message\nContent-Type: text/plain; charset=\"utf-8\"\n\nA simple message\n\nTags: not-an-emoji\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n\nfunc TestFormatMail_JustPriority(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:       \"abc\",\n\t\tTime:     1640382204,\n\t\tEvent:    \"message\",\n\t\tTopic:    \"alerts\",\n\t\tMessage:  \"A simple message\",\n\t\tPriority: 2,\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: A simple message\nContent-Type: text/plain; charset=\"utf-8\"\n\nA simple message\n\nPriority: low\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n\nfunc TestFormatMail_UTF8Subject(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:      \"abc\",\n\t\tTime:    1640382204,\n\t\tEvent:   \"message\",\n\t\tTopic:   \"alerts\",\n\t\tMessage: \"A simple message\",\n\t\tTitle:   \" :: A not so simple title öäüß ¡Hola, señor!\",\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=\nContent-Type: text/plain; charset=\"utf-8\"\n\nA simple message\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n\nfunc TestFormatMail_WithAllTheThings(t *testing.T) {\n\tactual, _ := formatMail(\"https://ntfy.sh\", \"1.2.3.4\", \"ntfy@ntfy.sh\", \"phil@example.com\", &model.Message{\n\t\tID:       \"abc\",\n\t\tTime:     1640382204,\n\t\tEvent:    \"message\",\n\t\tTopic:    \"alerts\",\n\t\tPriority: 5,\n\t\tTags:     []string{\"warning\", \"skull\", \"tag123\", \"other\"},\n\t\tTitle:    \"Oh no 🙈\\nThis is a message across\\nmultiple lines\",\n\t\tMessage:  \"A message that contains monkeys 🙉\\nNo really, though. Monkeys!\",\n\t})\n\texpected := `From: \"ntfy.sh/alerts\" <ntfy@ntfy.sh>\nTo: phil@example.com\nDate: Fri, 24 Dec 2021 21:43:24 +0000\nSubject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=\nContent-Type: text/plain; charset=\"utf-8\"\n\nA message that contains monkeys 🙉\nNo really, though. Monkeys!\n\nTags: tag123, other\nPriority: max\n\n--\nThis message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`\n\trequire.Equal(t, expected, actual)\n}\n"
  },
  {
    "path": "server/smtp_server.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"mime/multipart\"\n\t\"mime/quotedprintable\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/mail\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/emersion/go-smtp\"\n\t\"github.com/microcosm-cc/bluemonday\"\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nvar (\n\terrInvalidDomain          = errors.New(\"invalid domain\")\n\terrInvalidAddress         = errors.New(\"invalid address\")\n\terrInvalidTopic           = errors.New(\"invalid topic\")\n\terrTooManyRecipients      = errors.New(\"too many recipients\")\n\terrMultipartNestedTooDeep = errors.New(\"multipart message nested too deep\")\n\terrUnsupportedContentType = errors.New(\"unsupported content type\")\n)\n\nvar (\n\tonlySpacesRegex          = regexp.MustCompile(`(?m)^\\s+$`)\n\tconsecutiveNewLinesRegex = regexp.MustCompile(`\\n{3,}`)\n\thtmlLineBreakRegex       = regexp.MustCompile(`(?i)<br\\s*/?>`)\n)\n\nconst (\n\tmaxMultipartDepth = 2\n)\n\n// smtpBackend implements SMTP server methods.\ntype smtpBackend struct {\n\tconfig  *Config\n\thandler func(http.ResponseWriter, *http.Request)\n\tsuccess int64\n\tfailure int64\n\tmu      sync.Mutex\n}\n\nvar _ smtp.Backend = (*smtpBackend)(nil)\nvar _ smtp.Session = (*smtpSession)(nil)\n\nfunc newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {\n\treturn &smtpBackend{\n\t\tconfig:  conf,\n\t\thandler: handler,\n\t}\n}\n\nfunc (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {\n\tlogem(conn).Debug(\"Incoming mail\")\n\treturn &smtpSession{backend: b, conn: conn}, nil\n}\n\nfunc (b *smtpBackend) Counts() (total int64, success int64, failure int64) {\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\treturn b.success + b.failure, b.success, b.failure\n}\n\n// smtpSession is returned after EHLO.\ntype smtpSession struct {\n\tbackend   *smtpBackend\n\tconn      *smtp.Conn\n\ttopic     string\n\ttoken     string // If email address contains token, e.g. topic+token@domain\n\tbasicAuth string // If SMTP AUTH PLAIN was used\n\tmu        sync.Mutex\n}\n\nfunc (s *smtpSession) AuthPlain(username, password string) error {\n\tlogem(s.conn).Field(\"smtp_username\", username).Debug(\"AUTH PLAIN (with username %s)\", username)\n\ts.mu.Lock()\n\ts.basicAuth = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(\"%s:%s\", username, password)))\n\ts.mu.Unlock()\n\treturn nil\n}\n\nfunc (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {\n\tlogem(s.conn).Field(\"smtp_mail_from\", from).Debug(\"MAIL FROM: %s\", from)\n\treturn nil\n}\n\nfunc (s *smtpSession) Rcpt(to string) error {\n\tlogem(s.conn).Field(\"smtp_rcpt_to\", to).Debug(\"RCPT TO: %s\", to)\n\treturn s.withFailCount(func() error {\n\t\ttoken := \"\"\n\t\tconf := s.backend.config\n\t\taddressList, err := mail.ParseAddressList(to)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if len(addressList) != 1 {\n\t\t\treturn errTooManyRecipients\n\t\t}\n\t\tto = addressList[0].Address\n\t\tif !strings.HasSuffix(to, \"@\"+conf.SMTPServerDomain) {\n\t\t\treturn errInvalidDomain\n\t\t}\n\t\t// Remove @ntfy.sh from end of email\n\t\tto = strings.TrimSuffix(to, \"@\"+conf.SMTPServerDomain)\n\t\tif conf.SMTPServerAddrPrefix != \"\" {\n\t\t\tif !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {\n\t\t\t\treturn errInvalidAddress\n\t\t\t}\n\t\t\t// remove ntfy- from beginning of email\n\t\t\tto = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)\n\t\t}\n\t\t// If email contains token, split topic and token\n\t\tif strings.Contains(to, \"+\") {\n\t\t\tparts := strings.Split(to, \"+\")\n\t\t\tto = parts[0]\n\t\t\ttoken = parts[1]\n\t\t}\n\t\tif !topicRegex.MatchString(to) {\n\t\t\treturn errInvalidTopic\n\t\t}\n\t\ts.mu.Lock()\n\t\ts.topic = to\n\t\ts.token = token\n\t\ts.mu.Unlock()\n\t\treturn nil\n\t})\n}\n\nfunc (s *smtpSession) Data(r io.Reader) error {\n\treturn s.withFailCount(func() error {\n\t\tconf := s.backend.config\n\t\tb, err := io.ReadAll(r) // Protected by MaxMessageBytes\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tev := logem(s.conn)\n\t\tif ev.IsTrace() {\n\t\t\tev.Field(\"smtp_data\", string(b)).Trace(\"DATA\")\n\t\t} else if ev.IsDebug() {\n\t\t\tev.Field(\"smtp_data_len\", len(b)).Debug(\"DATA\")\n\t\t}\n\t\tmsg, err := mail.ReadMessage(bytes.NewReader(b))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbody, err := readMailBody(msg.Body, msg.Header)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbody = strings.TrimSpace(body)\n\t\tif len(body) > conf.MessageSizeLimit {\n\t\t\tbody = body[:conf.MessageSizeLimit]\n\t\t}\n\t\tm := model.NewDefaultMessage(s.topic, body)\n\t\tsubject := strings.TrimSpace(msg.Header.Get(\"Subject\"))\n\t\tif subject != \"\" {\n\t\t\tdec := mime.WordDecoder{}\n\t\t\tsubject, err := dec.DecodeHeader(subject)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tm.Title = subject\n\t\t}\n\t\tif m.Title != \"\" && m.Message == \"\" {\n\t\t\tm.Message = m.Title // Flip them, this makes more sense\n\t\t\tm.Title = \"\"\n\t\t}\n\t\tif err := s.publishMessage(m); err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.backend.mu.Lock()\n\t\ts.backend.success++\n\t\ts.backend.mu.Unlock()\n\t\tminc(metricEmailsReceivedSuccess)\n\t\treturn nil\n\t})\n}\n\nfunc (s *smtpSession) publishMessage(m *model.Message) error {\n\t// Extract remote address (for rate limiting)\n\tremoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())\n\tif err != nil {\n\t\tremoteAddr = s.conn.Conn().RemoteAddr().String()\n\t}\n\t// Call HTTP handler with fake HTTP request\n\turl := fmt.Sprintf(\"%s/%s\", s.backend.config.BaseURL, m.Topic)\n\treq, err := http.NewRequest(\"POST\", url, strings.NewReader(m.Message))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.RequestURI = \"/\" + m.Topic                                    // just for the logs\n\treq.RemoteAddr = remoteAddr                                       // rate limiting!!\n\treq.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header\n\tif m.Title != \"\" {\n\t\treq.Header.Set(\"Title\", m.Title)\n\t}\n\tif s.token != \"\" {\n\t\treq.Header.Add(\"Authorization\", \"Bearer \"+s.token)\n\t} else if s.basicAuth != \"\" {\n\t\treq.Header.Add(\"Authorization\", \"Basic \"+s.basicAuth)\n\t}\n\trr := httptest.NewRecorder()\n\ts.backend.handler(rr, req)\n\tif rr.Code != http.StatusOK {\n\t\treturn errors.New(\"error: \" + rr.Body.String())\n\t}\n\treturn nil\n}\n\nfunc (s *smtpSession) Reset() {\n\ts.mu.Lock()\n\ts.topic = \"\"\n\ts.mu.Unlock()\n}\n\nfunc (s *smtpSession) Logout() error {\n\ts.mu.Lock()\n\ts.basicAuth = \"\"\n\ts.mu.Unlock()\n\treturn nil\n}\n\nfunc (s *smtpSession) withFailCount(fn func() error) error {\n\terr := fn()\n\ts.backend.mu.Lock()\n\tdefer s.backend.mu.Unlock()\n\tif err != nil {\n\t\t// Almost all of these errors are parse errors, and user input errors.\n\t\t// We do not want to spam the log with WARN messages.\n\t\tlogem(s.conn).Err(err).Debug(\"Incoming mail error\")\n\t\ts.backend.failure++\n\t\tminc(metricEmailsReceivedFailure)\n\t}\n\treturn err\n}\n\nfunc readMailBody(body io.Reader, header mail.Header) (string, error) {\n\tif header.Get(\"Content-Type\") == \"\" {\n\t\treturn readPlainTextMailBody(body, header.Get(\"Content-Transfer-Encoding\"))\n\t}\n\tcontentType, params, err := mime.ParseMediaType(header.Get(\"Content-Type\"))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tcanonicalContentType := strings.ToLower(contentType)\n\tif canonicalContentType == \"text/plain\" || canonicalContentType == \"text/html\" {\n\t\treturn readTextMailBody(body, canonicalContentType, header.Get(\"Content-Transfer-Encoding\"))\n\t} else if strings.HasPrefix(canonicalContentType, \"multipart/\") {\n\t\treturn readMultipartMailBody(body, params)\n\t}\n\treturn \"\", errUnsupportedContentType\n}\n\nfunc readMultipartMailBody(body io.Reader, params map[string]string) (string, error) {\n\tparts := make(map[string]string)\n\tif err := readMultipartMailBodyParts(body, params, 0, parts); err != nil && err != io.EOF {\n\t\treturn \"\", err\n\t} else if s, ok := parts[\"text/plain\"]; ok {\n\t\treturn s, nil\n\t} else if s, ok := parts[\"text/html\"]; ok {\n\t\treturn s, nil\n\t}\n\treturn \"\", io.EOF\n}\n\nfunc readMultipartMailBodyParts(body io.Reader, params map[string]string, depth int, parts map[string]string) error {\n\tif depth >= maxMultipartDepth {\n\t\treturn errMultipartNestedTooDeep\n\t}\n\tmr := multipart.NewReader(body, params[\"boundary\"])\n\tfor {\n\t\tpart, err := mr.NextPart()\n\t\tif err != nil { // may be io.EOF\n\t\t\treturn err\n\t\t}\n\t\tpartContentType, partParams, err := mime.ParseMediaType(part.Header.Get(\"Content-Type\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcanonicalPartContentType := strings.ToLower(partContentType)\n\t\tif canonicalPartContentType == \"text/plain\" || canonicalPartContentType == \"text/html\" {\n\t\t\ts, err := readTextMailBody(part, canonicalPartContentType, part.Header.Get(\"Content-Transfer-Encoding\"))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tparts[canonicalPartContentType] = s\n\t\t} else if strings.HasPrefix(strings.ToLower(partContentType), \"multipart/\") {\n\t\t\tif err := readMultipartMailBodyParts(part, partParams, depth+1, parts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\t// Continue with next part\n\t}\n}\n\nfunc readTextMailBody(reader io.Reader, contentType, transferEncoding string) (string, error) {\n\tif contentType == \"text/plain\" {\n\t\treturn readPlainTextMailBody(reader, transferEncoding)\n\t} else if contentType == \"text/html\" {\n\t\treturn readHTMLMailBody(reader, transferEncoding)\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported content type: %s\", contentType)\n}\n\nfunc readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {\n\tif strings.ToLower(transferEncoding) == \"base64\" {\n\t\treader = base64.NewDecoder(base64.StdEncoding, reader)\n\t} else if strings.ToLower(transferEncoding) == \"quoted-printable\" {\n\t\treader = quotedprintable.NewReader(reader)\n\t}\n\tbody, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(body), nil\n}\n\nfunc readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) {\n\tbody, err := readPlainTextMailBody(reader, transferEncoding)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t// Convert <br> tags to newlines before stripping HTML, so that line breaks\n\t// in HTML emails (e.g. from Synology DSM, and other appliances) are preserved.\n\tbody = htmlLineBreakRegex.ReplaceAllString(body, \"\\n\")\n\tstripped := bluemonday.\n\t\tStrictPolicy().\n\t\tAddSpaceWhenStrippingTag(true).\n\t\tSanitize(body)\n\treturn removeExtraEmptyLines(stripped), nil\n}\n\nfunc removeExtraEmptyLines(s string) string {\n\ts = onlySpacesRegex.ReplaceAllString(s, \"\")\n\ts = consecutiveNewLinesRegex.ReplaceAllString(s, \"\\n\\n\")\n\treturn s\n}\n"
  },
  {
    "path": "server/smtp_server_test.go",
    "content": "package server\n\nimport (\n\t\"bufio\"\n\t\"github.com/emersion/go-smtp\"\n\t\"github.com/stretchr/testify/require\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestSmtpBackend_Multipart(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nMIME-Version: 1.0\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: ntfy-mytopic@ntfy.sh\nContent-Type: multipart/alternative; boundary=\"000000000000f3320b05d42915c9\"\n\n--000000000000f3320b05d42915c9\nContent-Type: text/plain; charset=\"UTF-8\"\n\nwhat's up\n\n--000000000000f3320b05d42915c9\nContent-Type: text/html; charset=\"UTF-8\"\n\n<div dir=\"ltr\">what&#39;s up<br clear=\"all\"><div><br></div></div>\n\n--000000000000f3320b05d42915c9--\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"and one more\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"what's up\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_MultipartNoBody(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-emailtest@ntfy.sh\nDATA\nMIME-Version: 1.0\nDate: Tue, 28 Dec 2021 01:33:34 +0100\nMessage-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>\nSubject: This email has a subject but no body\nFrom: Phil <phil@example.com>\nTo: ntfy-emailtest@ntfy.sh\nContent-Type: multipart/alternative; boundary=\"000000000000bcf4a405d429f8d4\"\n\n--000000000000bcf4a405d429f8d4\nContent-Type: text/plain; charset=\"UTF-8\"\n\n\n\n--000000000000bcf4a405d429f8d4\nContent-Type: text/html; charset=\"UTF-8\"\n\n<div dir=\"ltr\"><br></div>\n\n--000000000000bcf4a405d429f8d4--\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/emailtest\", r.URL.Path)\n\t\trequire.Equal(t, \"\", r.Header.Get(\"Title\")) // We flipped message and body\n\t\trequire.Equal(t, \"This email has a subject but no body\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Plaintext(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: mytopic@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: mytopic@ntfy.sh\nContent-Type: text/plain; charset=\"UTF-8\"\n\nwhat's up\n.\n`\n\ts, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"and one more\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"what's up\", readAll(t, r.Body))\n\t})\n\tconf.SMTPServerAddrPrefix = \"\"\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: mytopic@ntfy.sh\nDATA\nSubject: Very short mail\n\nwhat's up\n.\n`\n\ts, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Very short mail\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"what's up\", readAll(t, r.Body))\n\t})\n\tconf.SMTPServerAddrPrefix = \"\"\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nSubject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=\nFrom: Phil <phil@example.com>\nTo: ntfy-mytopic@ntfy.sh\nContent-Type: text/plain; charset=\"UTF-8\"\n\nwhat's up\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"Three santas 🎅🎅🎅\", r.Header.Get(\"Title\"))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: mytopic@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: mytopic@ntfy.sh\nContent-Type: text/plain; charset=\"UTF-8\"\n\nyou know this is a string.\nit's a long string.\nit's supposed to be longer than the max message length\nwhich is 4096 bytes,\nit used to be 512 bytes, but I increased that for the UnifiedPush support\nthe 512 bytes was a little short, some people said\nbut it kinda makes sense when you look at what it looks like one a phone\nheck this wasn't even half of it so far.\nso i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\nand with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\nthat should do it\n.\n`\n\ts, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\texpected := `you know this is a string.\nit's a long string.\nit's supposed to be longer than the max message length\nwhich is 4096 bytes,\nit used to be 512 bytes, but I increased that for the UnifiedPush support\nthe 512 bytes was a little short, some people said\nbut it kinda makes sense when you look at what it looks like one a phone\nheck this wasn't even half of it so far.\nso i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\npppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp\nand with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\nBBBBBBBBBBBBBBBBBBBBBBBBB`\n\t\trequire.Equal(t, 4096, len(expected)) // Sanity check\n\t\trequire.Equal(t, expected, readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\tconf.SMTPServerAddrPrefix = \"\"\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Plaintext_QuotedPrintable(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: mytopic@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: mytopic@ntfy.sh\nContent-Type: text/plain; charset=\"UTF-8\"\nContent-Transfer-Encoding: quoted-printable\n\nwhat's\n=C3=A0&=C3=A9\"'(-=C3=A8_=C3=A7=C3=A0)\n=3D=3D=3D=3D=3D\nup\n.\n`\n\ts, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"and one more\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, `what's\nà&é\"'(-è_çà)\n=====\nup`, readAll(t, r.Body))\n\t})\n\tconf.SMTPServerAddrPrefix = \"\"\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Unsupported(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: mytopic@ntfy.sh\nContent-Type: text/SOMETHINGELSE\n\nwhat's up\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Fatal(\"This should not be called\")\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type\")\n}\n\nfunc TestSmtpBackend_InvalidAddress(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: unsupported@ntfy.sh\nDATA\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: mytopic@ntfy.sh\nContent-Type: text/plain\n\nwhat's up\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Fatal(\"This should not be called\")\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"451 4.0.0 invalid address\")\n}\n\nfunc TestSmtpBackend_Base64Body(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: test@mydomain.me\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nContent-Type: multipart/mixed; boundary=\"===============2138658284696597373==\"\nMIME-Version: 1.0\nSubject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local\nFrom: =?utf-8?q?Robbie?= <test@mydomain.me>\nTo: test@mydomain.me\nDate: Thu, 16 Feb 2023 01:04:00 -0000\nMessage-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>\n\nThis is a multi-part message in MIME format.\n--===============2138658284696597373==\nContent-Type: text/plain; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nVGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=\n\n--===============2138658284696597373==\nContent-Type: text/html; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nPCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv\nL0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==\n\n--===============2138658284696597373==--\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"This is a test message from TrueNAS CORE.\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_MultipartQuotedPrintable(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nMIME-Version: 1.0\nDate: Tue, 28 Dec 2021 00:30:10 +0100\nMessage-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>\nSubject: and one more\nFrom: Phil <phil@example.com>\nTo: ntfy-mytopic@ntfy.sh\nContent-Type: multipart/alternative; boundary=\"000000000000f3320b05d42915c9\"\n\n--000000000000f3320b05d42915c9\nContent-Type: text/html; charset=\"UTF-8\"\n\nhtml, ignore me\n\n--000000000000f3320b05d42915c9\nContent-Type: text/plain; charset=\"UTF-8\"\nContent-Transfer-Encoding: quoted-printable\n\nwhat's\n=C3=A0&=C3=A9\"'(-=C3=A8_=C3=A7=C3=A0)\n=3D=3D=3D=3D=3D\nup\n\n--000000000000f3320b05d42915c9--\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"and one more\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, `what's\nà&é\"'(-è_çà)\n=====\nup`, readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_NestedMultipartBase64(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: test@mydomain.me\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nContent-Type: multipart/mixed; boundary=\"===============2138658284696597373==\"\nMIME-Version: 1.0\nSubject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local\nFrom: =?utf-8?q?Robbie?= <test@mydomain.me>\nTo: test@mydomain.me\nDate: Thu, 16 Feb 2023 01:04:00 -0000\nMessage-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>\n\nThis is a multi-part message in MIME format.\n--===============2138658284696597373==\nContent-Type: multipart/alternative; boundary=\"===============2233989480071754745==\"\nMIME-Version: 1.0\n\n--===============2233989480071754745==\nContent-Type: text/plain; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nVGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=\n\n--===============2233989480071754745==\nContent-Type: text/html; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nPCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv\nL0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==\n\n--===============2233989480071754745==--\n\n--===============2138658284696597373==--\n.\n`\n\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"This is a test message from TrueNAS CORE.\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: test@mydomain.me\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nContent-Type: multipart/mixed; boundary=\"===============1==\"\nMIME-Version: 1.0\nSubject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local\nFrom: =?utf-8?q?Robbie?= <test@mydomain.me>\nTo: test@mydomain.me\nDate: Thu, 16 Feb 2023 01:04:00 -0000\nMessage-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>\n\nThis is a multi-part message in MIME format.\n--===============1==\nContent-Type: multipart/alternative; boundary=\"===============2==\"\nMIME-Version: 1.0\n\n--===============2==\nContent-Type: multipart/alternative; boundary=\"===============3==\"\nMIME-Version: 1.0\n\n--===============3==\nContent-Type: text/plain; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nVGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=\n\n--===============3==\nContent-Type: text/html; charset=\"utf-8\"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\n\nPCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv\nL0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==\n\n--===============3==--\n\n--===============2==--\n\n--===============1==--\n.\n`\n\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Fatal(\"This should not be called\")\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep\")\n}\n\nfunc TestSmtpBackend_HTMLEmail(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: test@mydomain.me\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nMessage-Id: <51610934ss4.mmailer@fritz.box>\nFrom: <email@email.com>\nTo: <email@email.com>,\n\t<ntfy-subjectatntfy@ntfy.sh>\nDate: Thu, 30 Mar 2023 02:56:53 +0000\nSubject: A HTML email\nMime-Version: 1.0\nContent-Type: text/html;\n\tcharset=\"utf-8\"\nContent-Transfer-Encoding: quoted-printable\n\n<=21DOCTYPE html>\n<html>\n<head>\n<title>Alerttitle</title>\n<meta http-equiv=3D\"content-type\" content=3D\"text/html;charset=3Dutf-8\"/>\n</head>\n<body style=3D\"color: =23000000; background-color: =23f0eee6;\">\n<table width=3D\"100%\" align=3D\"center\" style=3D\"border:solid 2px =23eeeeee=\n; border-collapse: collapse;\">\n<tr>\n<td>\n<table style=3D\"border-collapse: collapse;\">\n\n\n\n\n\n\n\n<tr>\n<td style=3D\"background: =23FFFFFF;\">\n<table style=3D\"color: =23FFFFFF; background-color: =23006EC0; border-coll=\napse: collapse;\">\n<tr>\n<td style=3D\"width: 1000px; text-align: center; font-size: 18pt; font-fami=\nly: Arial, Helvetica, sans-serif; padding: 10px;\">\n\n\nheadertext of table\n\n</td>\n</tr>\n</table>\n</td>\n</tr>\n\n\n\n\n\n\n\n<tr>\n<td style=3D\"padding: 10px 20px; background: =23FFFFFF;\">\n<table style=3D\"border-collapse: collapse;\">\n<tr>\n<td style=3D\"width: 940px; font-size: 13pt; font-family: Arial, Helvetica,=\n sans-serif; text-align: left;\">\n\" Very important information about a change in your\nhome automation setup \n\nNow the light is on\n</td>\n</tr>\n</table>\n</td>\n</tr>\n\n\n\n<tr>\n<td style=3D\"padding: 10px 20px; background: =23FFFFFF;\">\n<table>\n<tr>\n<td style=3D\"width: 960px; font-size: 10pt; font-family: Arial, Helvetica,=\n sans-serif; text-align: left;\">\n<hr />\nIf you don't want to receive this message anymore, stop the push\n services in your <a href=3D\"https:fritzbox\" target=3D\"_=\nblank\">FRITZ=21Box</a>=2E<br />\nHere you can see the active push services: \"System > Push Service\"=2E\n</td>\n</tr>\n</table>\n</td>\n</tr>\n<tr>\n<td>\n<table style=3D\"color: =23FFFFFF; background-color: =23006EC0;\">\n<tr>\n<td style=3D\"width: 1000px; font-size: 10pt; font-family: Arial, Helvetica=\n, sans-serif; text-align: center; padding: 10px;\">\nThis mail has ben sent by your <a style=3D\"color: =23FFFFFF;\" href=3D\"https:=\n//fritzbox\" target=3D\"_blank\">FRITZ=21Box</a=\n> automatically=2E\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n</body>\n</html>\n.\n`\n\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"A HTML email\", r.Header.Get(\"Title\"))\n\t\texpected := `headertext of table\n\n&#34; Very important information about a change in your\nhome automation setup\n\nNow the light is on\n\nIf you don&#39;t want to receive this message anymore, stop the push\n services in your  FRITZ!Box .\n\nHere you can see the active push services: &#34;System &gt; Push Service&#34;.\n\nThis mail has ben sent by your  FRITZ!Box  automatically.`\n\t\trequire.Equal(t, expected, readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nconst spamEmail = `\nEHLO example.com\nMAIL FROM: test@mydomain.me\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nDelivered-To: somebody@gmail.com\nReceived: by 2002:a05:651c:1248:b0:2bf:c263:285 with SMTP id h8csp1096496ljh;\n        Mon, 30 Oct 2023 06:23:08 -0700 (PDT)\nX-Google-Smtp-Source: AGHT+IFsB3WqbwbeefbeefbeefbeefbeefiXRNDHnIy2xBeaYHZCM3EC8DfPv55qDtgq9djTeBCF\nX-Received: by 2002:a05:6808:147:b0:3af:66e5:5d3c with SMTP id h7-20020a056808014700b003af66e55d3cmr11662458oie.26.1698672188132;\n        Mon, 30 Oct 2023 06:23:08 -0700 (PDT)\nARC-Seal: i=1; a=rsa-sha256; t=1698672188; cv=none;\n        d=google.com; s=arc-20160816;\n        b=XM96KvnTbr4h6bqrTPTuuDNXmFCr9Be/HvVhu+UsSQjP9RxPk0wDTPUPZ/HWIJs52y\n         beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef\n         BUmQ==\nARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;\n        h=list-unsubscribe-post:list-unsubscribe:mime-version:subject:to\n         :reply-to:from:date:message-id:dkim-signature:dkim-signature;\n        bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=;\n        fh=+kTCcNpX22TOI/SVSLygnrDqWeUt4zW7QKiv0TOVSGs=;\n        b=lyIBRuOxPOTY2s36OqP7M7awlBKd4t5PX9mJOEJB0eTnTZqML+cplrXUIg2ZTlAAi9\n         beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef\n         tgVQ==\nARC-Authentication-Results: i=1; mx.google.com;\n       dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;\n       dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;\n       spf=pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) smtp.mailfrom=\"AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com\";\n       dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com\nReturn-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>\nReceived: from ccm30.constantcontact.com (ccm30.constantcontact.com. [208.75.123.226])\n        by mx.google.com with ESMTPS id h2-20020a05620a21c200b0076eeed38118si5450962qka.131.2023.10.30.06.23.07\n        for <somebody@gmail.com>\n        (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128);\n        Mon, 30 Oct 2023 06:23:08 -0700 (PDT)\nReceived-SPF: pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) client-ip=208.75.123.226;\nAuthentication-Results: mx.google.com;\n       dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;\n       dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;\n       spf=pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) smtp.mailfrom=\"AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com\";\n       dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com\nReturn-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>\nReceived: from [10.252.0.3] ([10.252.0.3:53254] helo=p2-jbemailsyndicator12.ctct.net) by 10.249.225.20 (envelope-from <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>) (ecelerity 4.3.1.999 r(:)) with ESMTP id A4/82-60517-B3EAF356; Mon, 30 Oct 2023 09:23:07 -0400\nDKIM-Signature: v=1; q=dns/txt; a=rsa-sha256; c=relaxed/relaxed; s=2020294246; d=spamspam.com; h=date:mime-version:subject:X-Feedback-ID:X-250ok-CID:message-id:from:reply-to:list-unsubscribe:list-unsubscribe-post:to; bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=; b=G8y6xmtKv8asfEXA9o8dP+6foQjclo6j5sFREYVIJBbj5YJ5tqoiv5B04/qoRkoTBFDhmjt+BUua7AqDgPSnwbP2iPSA4fTJehnHhut1PyVUp/9vqSYlhxQehfdhma8tPg8ArKfYIKmfKJwKRaQBU0JHCaB1m+5LNQQX3UjkxAg=\nDKIM-Signature: v=1; q=dns/txt; a=rsa-sha256; c=relaxed/relaxed; s=1000073432; d=auth.ccsend.com; h=date:mime-version:subject:X-Feedback-ID:X-250ok-CID:message-id:from:reply-to:list-unsubscribe:list-unsubscribe-post:to; bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=; b=ht8IksVKYY/Kb3dUERWoeW4eVdYjKL6F4PEoIZOhfFXor6XAIbPnd3A/CPmbmoqFZjnKh5OdcUy1N5qEoj8w1Q3TmN8/ySQkqrlrmSDSZIHZMY7Qp9/TJrqUe4RMFOO1KKIN6Y0vGP1+dWe98msMAHwvi2qMjG9aEKLfFr2JUTQ=\nMessage-ID: <1140728754828.1133104752381.1941549819.0.260913JL.2002@synd.ccsend.com>\nDate: Mon, 30 Oct 2023 09:23:07 -0400 (EDT)\nFrom: spamspam Loan Servicing <marklake@spamspam.com>\nReply-To: marklake@spamspam.com\nTo: somebody@gmail.com\nSubject: Buying a home? You deserve the confidence of Pre-Approval\nMIME-Version: 1.0\nContent-Type: multipart/alternative; boundary=\"----=_Part_75055660_144854819.1698672187348\"\nList-Unsubscribe: <https://visitor.constantcontact.com/do?p=un&m=beefbeefbeef>\nList-Unsubscribe-Post: List-Unsubscribe=One-Click\nX-Campaign-Activity-ID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650\nX-250ok-CID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650\nX-Channel-ID: b1441c50-a541-11ec-a79b-fa163e5bc304\nX-Return-Path-Hint: AbeefbeefbeefbeefbeefUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com\nX-Roving-Campaignid: 1140728754811\nX-Roving-Id: 1133104752381.1111111111\nX-Feedback-ID: b1441c50-a541-11ec-beef-beefbeefbeefbeef5de2a-5c88-44b1-be0e-f5a444cb0650:1133104752381:CTCT\nX-CTCT-ID: b13a9586-a541-11ec-beef-beefbeefbeef\n\n------=_Part_75055660_144854819.1698672187348\nContent-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: quoted-printable\n\nWhen you're buying a home, Pre-Approval gives you confidence you're in the =\nright price range and shows sellers you mean business. xxxxxxxxx SELLING or=\n BUYING? Call: 844-590-2275 Get Your Homebuying PRE-APPROVAL IN 24-HOURS* G=\net Pre-Approved When you're buying a home, Pre-Approval gives you confidenc=\ne you're in the right price range and shows sellers you mean business. xxx=\nxxxxxxGet Pre-Approved today! Click or Call to Get Pre-Approved 844-590-227=\n5 Get Pre-Approved nmlsconsumeraccess.org/ *The 24 hour timeframe is for mo=\nst approvals, however if additional information is needed or a request is o=\nn a holiday, the time for preapproval may be greater than 24 hours. This em=\nail is for informational purposes only and is not an offer, loan approval o=\nr loan commitment. Mortgage rates are subject to change without notice. Som=\ne terms and restrictions may apply to certain loan programs. Refinancing ex=\nisting loans may result in total finance charges being higher over the life=\n of the loan, reduction in payments may partially reflect a longer loan ter=\nm. This information is provided as guidance and illustrative purposes only =\nand does not constitute legal or financial advice. We are not liable or bou=\nnd legally for any answers provided to any user for our process or position=\n on an issue. This information may change from time to time and at any time=\n without notification. The most current information will be updated periodi=\ncally and posted in the online forum. spamspam Loan Servicing, LLC. NMLS#39=\n1521. nmlsconsumeraccess.org. You are receiving this information as a curre=\nnt loan customer with spamspam Loan Servicing, LLC. Not licensed for lendin=\ng activities in any of the U.S. territories. Not authorized to originate lo=\nans in the State of New York. Licensed by the Dept. of Financial Protection=\n and Innovation under the California Residential Mortgage .Lending Act #413=\n1216. This email was sent to somebody@gmail.com Version 103023PCHPrAp=\n9 xxxxxxxxx spamspam Loan Servicing | 4425 Ponce de Leon Blvd 5-251, Coral =\nGables, FL 33146-1837 Unsubscribe somebody@gmail.com Update Profile |=\n Our Privacy Policy | Constant Contact Data Notice Sent by marklake@spamspa=\nm.com\n------=_Part_75055660_144854819.1698672187348\nContent-Type: text/html; charset=utf-8\nContent-Transfer-Encoding: quoted-printable\n\n<!DOCTYPE HTML>\n<html lang=3D\"en-US\"> <head>  <meta http-equiv=3D\"Content-Type\" content=3D\"=\ntext/html; charset=3Dutf-8\"> <meta name=3D\"viewport\" content=3D\"width=3Ddev=\nice-width, initial-scale=3D1, maximum-scale=3D1\">   <style type=3D\"text/css=\n\" data-premailer=3D\"ignore\">=20\n@media only screen and (max-width:480px) { .footer-main-width { width: 100%=\n !important; }  .footer-mobile-hidden { display: none !important; }  .foote=\nr-mobile-hidden { display: none !important; }  .footer-column { display: bl=\nock !important; }  .footer-mobile-stack { display: block !important; }  .fo=\noter-mobile-stack-padding { padding-top: 3px; } }=20\n/* IE: correctly scale images with w/h attbs */ img { -ms-interpolation-mod=\ne: bicubic; }=20\n.layout { min-width: 100%; }=20\ntable { table-layout: fixed; } .shell_outer-row { table-layout: auto; }=20\n/* Gmail/Web viewport fix */ u + .body .shell_outer-row { width: 620px; }=\n=20\n/* LIST AND p STYLE OVERRIDES */ .text .text_content-cell p { margin: 0; pa=\ndding: 0; margin-bottom: 0; } .text .text_content-cell ul, .text .text_cont=\nent-cell ol { padding: 0; margin: 0 0 0 40px; } .text .text_content-cell li=\n { padding: 0; margin: 0; /* line-height: 1.2; Remove after testing */ } /*=\n Text Link Style Reset */ a { text-decoration: underline; } /* iOS: Autolin=\nk styles inherited */ a[x-apple-data-detectors] { text-decoration: underlin=\ne !important; font-size: inherit !important; font-family: inherit !importan=\nt; font-weight: inherit !important; line-height: inherit !important; color:=\n inherit !important; } /* FF/Chrome: Smooth font rendering */ .text .text_c=\nontent-cell { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing:=\n grayscale; }=20\n</style> <!--[if gte mso 9]> <style id=3D\"ol-styles\">=20\n/* OUTLOOK-SPECIFIC STYLES */ li { text-indent: -1em; padding: 0; margin: 0=\n; /* line-height: 1.2; Remove after testing */ } ul, ol { padding: 0; margi=\nn: 0 0 0 40px; } p { margin: 0; padding: 0; margin-bottom: 0; }=20\n</style> <![endif]-->  <style>@media only screen and (max-width:480px) {\n.button_content-cell {\npadding-top: 10px !important; padding-right: 20px !important; padding-botto=\nm: 10px !important; padding-left: 20px !important;\n}\n.button_border-row .button_content-cell {\npadding-top: 10px !important; padding-right: 20px !important; padding-botto=\nm: 10px !important; padding-left: 20px !important;\n}\n.column .content-padding-horizontal {\npadding-left: 20px !important; padding-right: 20px !important;\n}\n.layout .column .content-padding-horizontal .content-padding-horizontal {\npadding-left: 0px !important; padding-right: 0px !important;\n}\n.layout .column .content-padding-horizontal .block-wrapper_border-row .cont=\nent-padding-horizontal {\npadding-left: 20px !important; padding-right: 20px !important;\n}\n.dataTable {\noverflow: auto !important;\n}\n.dataTable .dataTable_content {\nwidth: auto !important;\n}\n.image--mobile-scale .image_container img {\nwidth: auto !important;\n}\n.image--mobile-center .image_container img {\nmargin-left: auto !important; margin-right: auto !important;\n}\n.layout-margin .layout-margin_cell {\npadding: 0px 20px !important;\n}\n.layout-margin--uniform .layout-margin_cell {\npadding: 20px 20px !important;\n}\n.scale {\nwidth: 100% !important;\n}\n.stack {\ndisplay: block !important; box-sizing: border-box;\n}\n.hide {\ndisplay: none !important;\n}\nu + .body .shell_outer-row {\nwidth: 100% !important;\n}\n.socialFollow_container {\ntext-align: center !important;\n}\n.text .text_content-cell {\nfont-size: 16px !important;\n}\n.text .text_content-cell h1 {\nfont-size: 24px !important;\n}\n.text .text_content-cell h2 {\nfont-size: 20px !important;\n}\n.text .text_content-cell h3 {\nfont-size: 20px !important;\n}\n.text--sectionHeading .text_content-cell {\nfont-size: 26px !important;\n}\n.text--heading .text_content-cell {\nfont-size: 26px !important;\n}\n.text--feature .text_content-cell h2 {\nfont-size: 20px !important;\n}\n.text--articleHeading .text_content-cell {\nfont-size: 20px !important;\n}\n.text--article .text_content-cell h3 {\nfont-size: 20px !important;\n}\n.text--featureHeading .text_content-cell {\nfont-size: 20px !important;\n}\n.text--feature .text_content-cell h3 {\nfont-size: 20px !important;\n}\n.text--dataTable .text_content-cell .dataTable .dataTable_content-cell {\nfont-size: 12px !important;\n}\n.text--dataTable .text_content-cell .dataTable th.dataTable_content-cell {\nfont-size: px !important;\n}\n}\n</style>\n</head> <body class=3D\"body template template--en-US\" data-template-version=\n=3D\"1.38.0\" data-canonical-name=3D\"CPE10001\" lang=3D\"en-US\" align=3D\"center=\n\" style=3D\"-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; min-=\nwidth: 100%; width: 100%; margin: 0px; padding: 0px;\"> <div id=3D\"preheader=\n\" style=3D\"color: transparent; display: none; font-size: 1px; line-height: =\n1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;\"><span =\ndata-entity-ref=3D\"preheader\">When you&#x27;re buying a home, Pre-Approval =\ngives you confidence you&#x27;re in the right price range and shows sellers=\n you mean business. </span></div> <div id=3D\"tracking-image\" style=3D\"color=\n: transparent; display: none; font-size: 1px; line-height: 1px; max-height:=\n 0px; max-width: 0px; opacity: 0; overflow: hidden;\"><img src=3D\"https://r2=\n0.rs6.net/on.jsp?ca=beefbeefbe-beef-44b1-be0e-f5a444cb0650&a=3D113310475238=\n1&c=3Db13a9586-a541-11ec-a79b-fa163e5bc304&ch=3Db1441c50-a541-11ec-a79b-fa1=\n63e5bc304\" / alt=3D\"\"></div> <div class=3D\"shell\" lang=3D\"en-US\" style=3D\"b=\nackground-color: #015288;\">  <table class=3D\"shell_panel-row\" width=3D\"100%=\n\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"background-colo=\nr: #015288;\" bgcolor=3D\"#015288\"> <tr class=3D\"\"> <td class=3D\"shell_panel-=\ncell\" style=3D\"\" align=3D\"center\" valign=3D\"top\"> <table class=3D\"shell_wid=\nth-row scale\" style=3D\"width: 620px;\" align=3D\"center\" border=3D\"0\" cellpad=\nding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"shell_width-cell\" style=3D\"=\npadding: 15px 10px;\" align=3D\"center\" valign=3D\"top\"> <table class=3D\"shell=\n_content-row\" width=3D\"100%\" align=3D\"center\" border=3D\"0\" cellpadding=3D\"0=\n\" cellspacing=3D\"0\"> <tr> <td class=3D\"shell_content-cell\" style=3D\"border-=\nradius: 0px; background-color: #FFFFFF; padding: 0; border: 0px solid #0096=\nd6;\" align=3D\"center\" valign=3D\"top\" bgcolor=3D\"#FFFFFF\"> <table class=3D\"l=\nayout layout--1-column\" style=3D\"table-layout: fixed;\" width=3D\"100%\" borde=\nr=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column colum=\nn--1 scale stack\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"divider\" width=3D\"100%\" cellpadding=3D\"0\" cellspacing=3D\"0\"=\n border=3D\"0\"> <tr> <td class=3D\"divider_container\" style=3D\"padding-top: 0=\npx; padding-bottom: 10px;\" width=3D\"100%\" align=3D\"center\" valign=3D\"top\"> =\n<table class=3D\"divider_content-row\" style=3D\"width: 100%; height: 1px;\" ce=\nllpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\"> <tr> <td class=3D\"divider_c=\nontent-cell\" style=3D\"padding-bottom: 5px; height: 1px; line-height: 1px; b=\nackground-color: #0096D6; border-bottom-width: 0px;\" height=3D\"1\" align=3D\"=\ncenter\" bgcolor=3D\"#0096D6\"> <img alt=3D\"\" width=3D\"5\" height=3D\"1\" border=\n=3D\"0\" hspace=3D\"0\" vspace=3D\"0\" src=3D\"https://imgssl.constantcontact.com/=\nletters/images/1101116784221/S.gif\" style=3D\"display: block; height: 1px; w=\nidth: 5px;\"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=\n <table class=3D\"layout layout--1-column\" style=3D\"table-layout: fixed;\" wi=\ndth=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td cla=\nss=3D\"column column--1 scale stack\" style=3D\"width: 100%;\" align=3D\"center\"=\n valign=3D\"top\"><div class=3D\"spacer\" style=3D\"line-height: 10px; height: 1=\n0px;\">&#x200a;</div></td> </tr> </table> <table class=3D\"layout layout--1-c=\nolumn\" style=3D\"table-layout: fixed;\" width=3D\"100%\" border=3D\"0\" cellpaddi=\nng=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column column--1 scale stack\"=\n style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"image image--padding-vertical image--mobile-scale image--mo=\nbile-center\" width=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0=\n\"> <tr> <td class=3D\"image_container\" align=3D\"center\" valign=3D\"top\" style=\n=3D\"padding-top: 10px; padding-bottom: 10px;\"> <a href=3D\"https://r20.rs6.n=\net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvTEiCsv-QEMhmJe9=\nch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D\" data-tra=\nckable=3D\"true\"><img data-image-content class=3D\"image_content\" width=3D\"26=\n2\" src=3D\"https://files.constantcontact.com/beefbeefbee/057bff2a-bdba-4165-=\nb108-a7baa91c42c6.jpg\" alt=3D\"\" style=3D\"display: block; height: auto; max-=\nwidth: 100%;\"></a> </td> </tr> </table> </td> </tr> </table> <table class=\n=3D\"layout layout--heading layout--1-column\" style=3D\"background-color: #00=\n527e; table-layout: fixed;\" width=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" c=\nellspacing=3D\"0\" bgcolor=3D\"#00527e\"> <tr> <td class=3D\"column column--1 sc=\nale stack\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"text text--padding-vertical\" width=3D\"100%\" border=3D\"0\" ce=\nllpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-layout: fixed;\"> <tr> <td =\nclass=3D\"text_content-cell content-padding-horizontal\" style=3D\"text-align:=\n center; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; f=\nont-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; pa=\ndding: 10px 20px;\" align=3D\"center\" valign=3D\"top\">\n<h1 style=3D\"font-family: Arial,Verdana,Helvetica,sans-serif; color: #606d7=\n8; font-size: 26px; font-weight: bold; margin: 0;\"><span style=3D\"color: rg=\nb(0, 150, 214);\">SELLING or BUYING?</span></h1>\n<p style=3D\"margin: 0;\"><span style=3D\"font-size: 16px; color: rgb(255, 255=\n, 255); font-weight: bold;\">Call: 844-590-2275</span></p>\n</td> </tr> </table> </td> </tr> </table> <table class=3D\"layout layout--ar=\nticle layout--1-column\" style=3D\"table-layout: fixed;\" width=3D\"100%\" borde=\nr=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column colum=\nn--1 scale stack\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"text text--heading text--padding-vertical\" width=3D\"100%\" b=\norder=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-layout: fixe=\nd;\"> <tr> <td class=3D\"text_content-cell content-padding-horizontal\" style=\n=3D\"text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif; co=\nlor: #606d78; font-size: 26px; line-height: 1.2; display: block; word-wrap:=\n break-word; font-weight: bold; padding: 10px 20px;\" align=3D\"center\" valig=\nn=3D\"top\">\n<p style=3D\"margin: 0;\"><span style=3D\"font-size: 30px; color: rgb(0, 150, =\n214);\">Get Your Homebuying</span></p>\n<p style=3D\"margin: 0;\"><span style=3D\"font-size: 30px; color: rgb(0, 82, 1=\n26);\">PRE-APPROVAL IN 24-HOURS</span><span style=3D\"font-size: 30px; color:=\n rgb(0, 82, 126); font-weight: normal;\">*</span></p>\n</td> </tr> </table> <table class=3D\"image image--padding-vertical image--m=\nobile-scale image--mobile-center\" width=3D\"100%\" border=3D\"0\" cellpadding=\n=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"image_container content-padding=\n-horizontal\" align=3D\"center\" valign=3D\"top\" style=3D\"padding: 10px 20px;\">=\n <img data-image-content class=3D\"image_content\" width=3D\"548\" src=3D\"https=\n://files.constantcontact.com/df66e42d701/2092a2d7-0bda-4289-910b-bf50a2398d=\n60.jpg\" alt=3D\"\" style=3D\"display: block; height: auto; max-width: 100%;\"> =\n</td> </tr> </table>  <table class=3D\"button button--padding-vertical\" widt=\nh=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-=\nlayout: fixed;\"> <tr> <td class=3D\"button_container content-padding-horizon=\ntal\" align=3D\"center\" style=3D\"padding: 10px 20px;\">    <table class=3D\"but=\nton_content-row\" style=3D\"width: inherit; border-radius: 3px; border-spacin=\ng: 0; background-color: #0096D6; border: none;\" border=3D\"0\" cellpadding=3D=\n\"0\" cellspacing=3D\"0\" bgcolor=3D\"#0096D6\"> <tr> <td class=3D\"button_content=\n-cell\" style=3D\"padding: 10px 40px;\" align=3D\"center\"> <a class=3D\"button_l=\nink\" href=3D\"https://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEG=\nO0v-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCias4f7-QkeggQvxIvbs-6TTaZHHhXLKf88NID=\ndci4Ge7aYN-QihEgqblie1-DQ2Fa1BKLbT3AM8rtrgeYQgVxJ6cG8POsvFzv7JstrGkCkg3a3AE=\n633LfQpAddyVLFkTv6oyS4T2j_YjYIPKDOZktqK_5rOR-Fh8cWGtUD8YPpPNnZ037z6_t9Nkemu=\nhxG&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DH=\nu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D\" data-trackable=\n=3D\"true\" style=3D\"font-size: 16px; font-weight: bold; color: #FFFFFF; font=\n-family: Helvetica,Arial,sans-serif; word-wrap: break-word; text-decoration=\n: none;\">Get Pre-Approved</a> </td> </tr> </table>    </td> </tr> </table> =\n  <table class=3D\"text text--padding-vertical\" width=3D\"100%\" border=3D\"0\" =\ncellpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-layout: fixed;\"> <tr> <t=\nd class=3D\"text_content-cell content-padding-horizontal\" style=3D\"line-heig=\nht: 1; text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif;=\n color: #000000; font-size: 14px; display: block; word-wrap: break-word; pa=\ndding: 10px 20px;\" align=3D\"center\" valign=3D\"top\">\n<p style=3D\"text-align: left; margin: 0;\" align=3D\"left\"><br></p>\n<p style=3D\"margin: 0;\"><span style=3D\"font-size: 19px;\">When you're buying=\n a home, Pre-Approval gives you confidence you're in the right price range =\nand shows sellers you mean business. </span></p>\n<p style=3D\"margin: 0;\"><span style=3D\"font-size: 19px;\">&#xfeff;Get Pre-Ap=\nproved today!</span></p>\n</td> </tr> </table> </td> </tr> </table> <table class=3D\"layout layout--1-=\ncolumn\" style=3D\"table-layout: fixed;\" width=3D\"100%\" border=3D\"0\" cellpadd=\ning=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column column--1 scale stack=\n\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"text text--padding-vertical\" width=3D\"100%\" border=3D\"0\" ce=\nllpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-layout: fixed;\"> <tr> <td =\nclass=3D\"text_content-cell content-padding-horizontal\" style=3D\"text-align:=\n left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=\nt-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=\ning: 10px 20px;\" align=3D\"left\" valign=3D\"top\">\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><br></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 23px; color: rgb(0, 82, 126); font-weight: bold; font-family: A=\nrial, Verdana, Helvetica, sans-serif;\">Click or Call to Get Pre-Approved </=\nspan></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 28px; color: rgb(0, 150, 214); font-weight: bold;\">844-590-2275=\n</span></p>\n</td> </tr> </table> </td> </tr> </table> <table class=3D\"layout layout--1-=\ncolumn\" style=3D\"table-layout: fixed;\" width=3D\"100%\" border=3D\"0\" cellpadd=\ning=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column column--1 scale stack=\n\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\"> <table class=3D\"b=\nutton button--padding-vertical\" width=3D\"100%\" border=3D\"0\" cellpadding=3D\"=\n0\" cellspacing=3D\"0\" style=3D\"table-layout: fixed;\"> <tr> <td class=3D\"butt=\non_container content-padding-horizontal\" align=3D\"center\" style=3D\"padding:=\n 10px 20px;\">    <table class=3D\"button_content-row\" style=3D\"background-co=\nlor: #0096D6; width: inherit; border-radius: 3px; border-spacing: 0; border=\n: none;\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" bgcolor=3D\"#0096D=\n6\"> <tr> <td class=3D\"button_content-cell\" style=3D\"padding: 10px 40px;\" al=\nign=3D\"center\"> <a class=3D\"button_link\" href=3D\"https://r20.rs6.net/tn.jsp=\n?f=3D001thisisfakethisisfakethisisfakev-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCi=\noVIo_si10jiydw=3D=3D\" data-trackable=3D\"true\" style=3D\"font-size: 16px; fon=\nt-weight: bold; color: #FFFFFF; font-family: Helvetica,Arial,sans-serif; wo=\nrd-wrap: break-word; text-decoration: none;\">Get Pre-Approved</a> </td> </t=\nr> </table>    </td> </tr> </table>   </td> </tr> </table> <table class=3D\"=\nlayout layout--1-column\" style=3D\"table-layout: fixed;\" width=3D\"100%\" bord=\ner=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column colu=\nmn--1 scale stack\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"image image--padding-vertical image--mobile-scale image--mo=\nbile-center\" width=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0=\n\"> <tr> <td class=3D\"image_container\" align=3D\"center\" valign=3D\"top\" style=\n=3D\"padding-top: 10px; padding-bottom: 10px;\"> <img data-image-content clas=\ns=3D\"image_content\" width=3D\"87\" src=3D\"https://files.constantcontact.com/d=\nf66e42d701/beefbeef-beef-beef-9a13-2779ab497b8d.png\" alt=3D\"\" style=3D\"disp=\nlay: block; height: auto; max-width: 100%;\"> </td> </tr> </table> </td> </t=\nr> </table> <table class=3D\"layout layout--1-column\" style=3D\"table-layout:=\n fixed;\" width=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <=\ntr> <td class=3D\"column column--1 scale stack\" style=3D\"width: 100%;\" align=\n=3D\"center\" valign=3D\"top\">\n<table class=3D\"text text--padding-vertical\" width=3D\"100%\" border=3D\"0\" ce=\nllpadding=3D\"0\" cellspacing=3D\"0\" style=3D\"table-layout: fixed;\"> <tr> <td =\nclass=3D\"text_content-cell content-padding-horizontal\" style=3D\"text-align:=\n left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=\nt-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=\ning: 10px 20px;\" align=3D\"left\" valign=3D\"top\">\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><br></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><a href=3D\"htt=\nps://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvT=\nEiCsv-QEMgYju54LKeEV1_a2OCyOAfG7VhZpxtOW89WM-s6S5iiXcmnbK-Z6XDc9LL569h6DE4L=\nIRMWiBWHOlFB9TZWQVuX6Ycz3505y1keCrca4QArp&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q=\n8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTg=\nRJoVIo_si10jiydw=3D=3D\" target=3D\"_blank\" style=3D\"font-size: 11px; color: =\nrgb(153, 153, 153); text-decoration: underline; font-weight: normal; font-s=\ntyle: normal;\">nmlsconsumeraccess.org/</a></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(153, 153, 153);\">*The 24 hour timeframe is for=\n most approvals, however if additional information is needed or a request i=\ns on a holiday, the time for preapproval may be greater than 24 hours.</spa=\nn></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=\n, 255);\">This email is for informational purposes only and is not an offer,=\n loan approval or loan commitment. Mortgage rates are subject to change wit=\nhout notice. Some terms and restrictions may apply to certain loan programs=\n. Refinancing existing loans may result in total finance charges being high=\ner over the life of the loan, reduction in payments may partially reflect a=\n longer loan term. This information is provided as guidance and illustrativ=\ne purposes only and does not constitute legal or financial advice. We are n=\not liable or bound legally for any answers provided to any user for our pro=\ncess or position on an issue. This information may change from time to time=\n and at any time without notification. The most current information will be=\n updated periodically and posted in the online forum.</span></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=\n, 255);\">spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org.=\n You are receiving this information as a current loan customer with spamspa=\nm Loan Servicing, LLC. Not licensed for lending activities in any of the U.=\nS. territories. Not authorized to originate loans in the State of New York.=\n Licensed by the Dept. of Financial Protection and Innovation under the Cal=\nifornia Residential Mortgage .Lending Act #4131216.</span></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><br></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(153, 153, 153);\">This email was sent to <span =\ndata-id=3D\"emailAddress\">somebody@gmail.com</span></span></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(153, 153, 153);\">Version 103023PCHPrAp9 </span=\n></p>\n<p style=3D\"text-align: center; margin: 0;\" align=3D\"center\"><span style=3D=\n\"font-size: 11px; color: rgb(162, 162, 162);\">&#xfeff;</span></p>\n</td> </tr> </table> </td> </tr> </table> <table class=3D\"layout layout--1-=\ncolumn\" style=3D\"table-layout: fixed;\" width=3D\"100%\" border=3D\"0\" cellpadd=\ning=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"column column--1 scale stack=\n\" style=3D\"width: 100%;\" align=3D\"center\" valign=3D\"top\">\n<table class=3D\"divider\" width=3D\"100%\" cellpadding=3D\"0\" cellspacing=3D\"0\"=\n border=3D\"0\"> <tr> <td class=3D\"divider_container\" style=3D\"padding-top: 1=\n0px; padding-bottom: 0px;\" width=3D\"100%\" align=3D\"center\" valign=3D\"top\"> =\n<table class=3D\"divider_content-row\" style=3D\"width: 100%; height: 1px;\" ce=\nllpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\"> <tr> <td class=3D\"divider_c=\nontent-cell\" style=3D\"padding-bottom: 2px; height: 1px; line-height: 1px; b=\nackground-color: #0096D6; border-bottom-width: 0px;\" height=3D\"1\" align=3D\"=\ncenter\" bgcolor=3D\"#0096D6\"> <img alt=3D\"\" width=3D\"5\" height=3D\"1\" border=\n=3D\"0\" hspace=3D\"0\" vspace=3D\"0\" src=3D\"https://imgssl.constantcontact.com/=\nletters/images/1111111111111/S.gif\" style=3D\"display: block; height: 1px; w=\nidth: 5px;\"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=\n  </td> </tr> </table> </td> </tr> </table> </td> </tr> <tr> <td class=3D\"s=\nhell_panel-cell shell_panel-cell--systemFooter\" style=3D\"\" align=3D\"center\"=\n valign=3D\"top\"> <table class=3D\"shell_width-row scale\" style=3D\"width: 100=\n%;\" align=3D\"center\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr>=\n <td class=3D\"shell_width-cell\" style=3D\"padding: 0px;\" align=3D\"center\" va=\nlign=3D\"top\"> <table class=3D\"shell_content-row\" width=3D\"100%\" align=3D\"ce=\nnter\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=3D\"s=\nhell_content-cell\" style=3D\"background-color: #FFFFFF; padding: 0; border: =\n0 solid #0096d6;\" align=3D\"center\" valign=3D\"top\" bgcolor=3D\"#FFFFFF\"> <tab=\nle class=3D\"layout layout--1-column\" style=3D\"table-layout: fixed;\" width=\n=3D\"100%\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\"> <tr> <td class=\n=3D\"column column--1 scale stack\" style=3D\"width: 100%;\" align=3D\"center\" v=\nalign=3D\"top\"> <table class=3D\"footer\" width=3D\"100%\" border=3D\"0\" cellpadd=\ning=3D\"0\" cellspacing=3D\"0\" style=3D\"font-family: Verdana,Geneva,sans-serif=\n; color: #5d5d5d; font-size: 12px;\"> <tr> <td class=3D\"footer_container\" al=\nign=3D\"center\"> <table class=3D\"footer-container\" width=3D\"100%\" cellpaddin=\ng=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"background-color: #ffffff; =\nmargin-left: auto; margin-right: auto; table-layout: auto !important;\" bgco=\nlor=3D\"#ffffff\">\n<tr>\n<td width=3D\"100%\" align=3D\"center\" valign=3D\"top\" style=3D\"width: 100%;\">\n<div class=3D\"footer-max-main-width\" align=3D\"center\" style=3D\"margin-left:=\n auto; margin-right: auto; max-width: 100%;\">\n<table width=3D\"100%\" cellpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\">\n<tr>\n<td class=3D\"footer-layout\" align=3D\"center\" valign=3D\"top\" style=3D\"paddin=\ng: 16px 0px;\">\n<table class=3D\"footer-main-width\" style=3D\"width: 580px;\" border=3D\"0\" cel=\nlpadding=3D\"0\" cellspacing=3D\"0\">\n<tr>\n<td class=3D\"footer-text\" align=3D\"center\" valign=3D\"top\" style=3D\"color: #=\n5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=\npx 0px;\">\n<span class=3D\"footer-column\">spamspam Loan Servicing<span class=3D\"footer-=\nmobile-hidden\"> | </span></span><span class=3D\"footer-column\">4425 Ponce de=\n Leon Blvd 5-251<span class=3D\"footer-mobile-hidden\">, </span></span><span =\nclass=3D\"footer-column\"></span><span class=3D\"footer-column\"></span><span c=\nlass=3D\"footer-column\">Coral Gables, FL 33146-1837</span><span class=3D\"foo=\nter-column\"></span>\n</td>\n</tr>\n<tr>\n<td class=3D\"footer-row\" align=3D\"center\" valign=3D\"top\" style=3D\"padding: =\n10px 0px;\">\n<table cellpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\">\n<tr>\n<td class=3D\"footer-text\" align=3D\"center\" valign=3D\"top\" style=3D\"color: #=\n5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=\npx 0px;\">\n<a href=3D\"https://visitor.constantcontact.com/do?p=3Dun&m=3D001g3dtlqhzM3v=\n-44b1-be0e-f5a444cb0650\" data-track=3D\"false\" style=3D\"color: #5d5d5d;\">Uns=\nubscribe somebody@gmail.com<span class=3D\"partnerOptOut\"></span></a>\n<span class=3D\"partnerOptOut\"></span>\n</td>\n</tr>\n<tr>\n<td class=3D\"footer-text\" align=3D\"center\" valign=3D\"top\" style=3D\"color: #=\n5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=\npx 0px;\">\n<a href=3D\"https://visitor.constantcontact.com/do?p=3Doo&m=3D001g3dtlqhzM3v=\n-44b1-be0e-f5a444cb0650\" data-track=3D\"false\" style=3D\"color: #5d5d5d;\">Upd=\nate Profile</a> |\n<a href=3D\"https://spamspam.com/privacy-notice/\" data-track=3D\"false\" style=\n=3D\"color: #5d5d5d;\">Our Privacy Policy</a><span class=3D\"footer-mobile-hid=\nden\"> |</span>\n<a class=3D\"footer-about-provider footer-mobile-stack footer-mobile-stack-p=\nadding\" href=3D\"http://www.constantcontact.com/legal/about-constant-contact=\n\" data-track=3D\"false\" style=3D\"color: #5d5d5d;\">Constant Contact Data Noti=\nce</a>\n</td>\n</tr>\n<tr>\n<td class=3D\"footer-text\" align=3D\"center\" valign=3D\"top\" style=3D\"color: #=\n5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=\npx 0px;\">\nSent by\n<a href=3D\"mailto:marklake@spamspam.com\" style=3D\"color: #5d5d5d; text-deco=\nration: none;\">marklake@spamspam.com</a>\n</td>\n</tr>\n</table>\n</td>\n</tr>\n<tr>\n<td class=3D\"footer-text\" align=3D\"center\" valign=3D\"top\" style=3D\"color: #=\n5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=\npx 0px;\">\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n</div>\n</td>\n</tr>\n</table> </td> </tr> </table>   </td> </tr> </table>  </td> </tr> </table> =\n</td> </tr> </table> </td> </tr>  </table> </div>  </body> </html>\n\n------=_Part_75055660_144854819.1698672187348--\n.\n`\n\nfunc TestSmtpBackend_Spam_Text(t *testing.T) {\n\temail := spamEmail\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Buying a home? You deserve the confidence of Pre-Approval\", r.Header.Get(\"Title\"))\n\t\tactual := readAll(t, r.Body)\n\t\texpected := \"When you're buying a home, Pre-Approval gives you confidence you're in the right price range and shows sellers you mean business. xxxxxxxxx SELLING or BUYING? Call: 844-590-2275 Get Your Homebuying PRE-APPROVAL IN 24-HOURS* Get Pre-Approved When you're buying a home, Pre-Approval gives you confidence you're in the right price range and shows sellers you mean business. xxxxxxxxxGet Pre-Approved today! Click or Call to Get Pre-Approved 844-590-2275 Get Pre-Approved nmlsconsumeraccess.org/ *The 24 hour timeframe is for most approvals, however if additional information is needed or a request is on a holiday, the time for preapproval may be greater than 24 hours. This email is for informational purposes only and is not an offer, loan approval or loan commitment. Mortgage rates are subject to change without notice. Some terms and restrictions may apply to certain loan programs. Refinancing existing loans may result in total finance charges being higher over the life of the loan, reduction in payments may partially reflect a longer loan term. This information is provided as guidance and illustrative purposes only and does not constitute legal or financial advice. We are not liable or bound legally for any answers provided to any user for our process or position on an issue. This information may change from time to time and at any time without notification. The most current information will be updated periodically and posted in the online forum. spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org. You are receiving this information as a current loan customer with spamspam Loan Servicing, LLC. Not licensed for lending activities in any of the U.S. territories. Not authorized to originate loans in the State of New York. Licensed by the Dept. of Financial Protection and Innovation under the California Residential Mortgage .Lending Act #4131216. This email was sent to somebody@gmail.com Version 103023PCHPrAp9 xxxxxxxxx spamspam Loan Servicing | 4425 Ponce de Leon Blvd 5-251, Coral Gables, FL 33146-1837 Unsubscribe somebody@gmail.com Update Profile | Our Privacy Policy | Constant Contact Data Notice Sent by marklake@spamspam.com\"\n\t\trequire.Equal(t, expected, actual)\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_Spam_HTML(t *testing.T) {\n\temail := strings.ReplaceAll(spamEmail, \"text/plain\", \"text/not-plain-anymore\") // We artificially force HTML parsing here\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Buying a home? You deserve the confidence of Pre-Approval\", r.Header.Get(\"Title\"))\n\t\tactual := readAll(t, r.Body)\n\t\texpected := `When you&#39;re buying a home, Pre-Approval gives you confidence you&#39;re in the right price range and shows sellers you mean business.                                  \n                                      ` + \"\\u200a\" + `            \n\n  SELLING or BUYING?  \n  Call: 844-590-2275  \n\n  Get Your Homebuying  \n  PRE-APPROVAL IN 24-HOURS  *  \n                                     Get Pre-Approved                        \n\n  When you&#39;re buying a home, Pre-Approval gives you confidence you&#39;re in the right price range and shows sellers you mean business.   \n  ` + \"\\ufeff\" + `Get Pre-Approved today!  \n\n  Click or Call to Get Pre-Approved   \n  844-590-2275  \n                                  Get Pre-Approved                              \n\n  nmlsconsumeraccess.org/  \n  *The 24 hour timeframe is for most approvals, however if additional information is needed or a request is on a holiday, the time for preapproval may be greater than 24 hours.  \n  This email is for informational purposes only and is not an offer, loan approval or loan commitment. Mortgage rates are subject to change without notice. Some terms and restrictions may apply to certain loan programs Refinancing existing loans may result in total finance charges being higher over the life of the loan, reduction in payments may partially reflect a longer loan term. This information is provided as guidance and illustrative purposes only and does not constitute legal or financial advice. We are not liable or bound legally for any answers provided to any user for our process or position on an issue. This information may change from time to time and at any time without notification. The most current information will be updated periodically and posted in the online forum.  \n  spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org. You are receiving this information as a current loan customer with spamspam Loan Servicing, LLC. Not licensed for lending activities in any of the U.S. territories. Not authorized to originate loans in the State of New York. Licensed by the Dept. of Financial Protection and Innovation under the California Residential Mortgage .Lending Act #4131216.  \n\n  This email was sent to  somebody@gmail.com   \n  Version 103023PCHPrAp9   \n  ` + \"\\ufeff\" + `  \n\n spamspam Loan Servicing  |    4425 Ponce de Leon Blvd 5-251 ,        Coral Gables, FL 33146-1837   \n\n Unsubscribe somebody@gmail.com   \n\n Update Profile  |\n Our Privacy Policy   | \n Constant Contact Data Notice \n\nSent by\n marklake@spamspam.com`\n\t\trequire.Equal(t, expected, actual)\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_HTMLOnly_FromDiskStation(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: synology@mydomain.me\nRCPT TO: synology@mydomain.me\nDATA\nFrom: \"=?UTF-8?B?Um9iYmll?=\" <synology@mydomain.me>\nTo: <synology@mydomain.me>\nMessage-Id: <640e6f562895d.6c9584bcfa491ac9c546b480b32ffc1d@mydomain.me>\nMIME-Version: 1.0\nSubject: =?UTF-8?B?W1N5bm9sb2d5IE5BU10gVGVzdCBNZXNzYWdlIGZyb20gTGl0dHNfTkFT?=\nContent-Type: text/html; charset=utf-8\nContent-Transfer-Encoding: 8bit\n\nCongratulations! You have successfully set up the email notification on Synology_NAS.<BR>For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.<BR>(If you cannot connect to the server, please contact the administrator.)<BR><BR>From Synology_NAS<BR><BR><BR>\n.\n`\n\ts, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/synology\", r.URL.Path)\n\t\trequire.Equal(t, \"[Synology NAS] Test Message from Litts_NAS\", r.Header.Get(\"Title\"))\n\t\texpected := \"Congratulations! You have successfully set up the email notification on Synology_NAS.\\n\" +\n\t\t\t\"For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\\n\" +\n\t\t\t\"(If you cannot connect to the server, please contact the administrator.)\\n\\n\" +\n\t\t\t\"From Synology_NAS\"\n\t\trequire.Equal(t, expected, readAll(t, r.Body))\n\t})\n\tconf.SMTPServerDomain = \"mydomain.me\"\n\tconf.SMTPServerAddrPrefix = \"\"\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_HTMLEmail_BrTagsPreserved(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: nas@example.com\nRCPT TO: ntfy-alerts@ntfy.sh\nDATA\nContent-Type: text/html; charset=utf-8\nContent-Transfer-Encoding: 8bit\nSubject: Task Scheduler: daily-backup\n\nTask Scheduler has completed a scheduled task.<BR><BR>Task: daily-backup<BR>Start time: Mon, 01 Jan 2026 02:00:00 +0000<BR>Stop time: Mon, 01 Jan 2024 02:03:00 +0000<BR>Current status: 0 (Normal)<BR>Standard output/error:<BR>OK<BR><BR>From MyNAS\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/alerts\", r.URL.Path)\n\t\trequire.Equal(t, \"Task Scheduler: daily-backup\", r.Header.Get(\"Title\"))\n\t\texpected := \"Task Scheduler has completed a scheduled task.\\n\\n\" +\n\t\t\t\"Task: daily-backup\\n\" +\n\t\t\t\"Start time: Mon, 01 Jan 2026 02:00:00 +0000\\n\" +\n\t\t\t\"Stop time: Mon, 01 Jan 2024 02:03:00 +0000\\n\" +\n\t\t\t\"Current status: 0 (Normal)\\n\" +\n\t\t\t\"Standard output/error:\\n\" +\n\t\t\t\"OK\\n\\n\" +\n\t\t\t\"From MyNAS\"\n\t\trequire.Equal(t, expected, readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_PlaintextWithToken(t *testing.T) {\n\temail := `EHLO example.com\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh\nDATA\nSubject: Very short mail\n\nwhat's up\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Very short mail\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2\", r.Header.Get(\"Authorization\"))\n\t\trequire.Equal(t, \"what's up\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\nfunc TestSmtpBackend_PlaintextWithPlainAuth(t *testing.T) {\n\temail := `EHLO example.com\nAUTH PLAIN dGVzdAB0ZXN0ADEyMzQ=\nMAIL FROM: phil@example.com\nRCPT TO: ntfy-mytopic@ntfy.sh\nDATA\nSubject: Very short mail\n\nwhat's up\n.\n`\n\ts, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"/mytopic\", r.URL.Path)\n\t\trequire.Equal(t, \"Very short mail\", r.Header.Get(\"Title\"))\n\t\trequire.Equal(t, \"Basic dGVzdDoxMjM0\", r.Header.Get(\"Authorization\"))\n\t\trequire.Equal(t, \"what's up\", readAll(t, r.Body))\n\t})\n\tdefer s.Close()\n\tdefer c.Close()\n\twriteAndReadUntilLine(t, email, c, scanner, \"250 2.0.0 OK: queued\")\n}\n\ntype smtpHandlerFunc func(http.ResponseWriter, *http.Request)\n\nfunc newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {\n\tconf = newTestConfig(t, \"\")\n\tconf.SMTPServerListen = \":25\"\n\tconf.SMTPServerDomain = \"ntfy.sh\"\n\tconf.SMTPServerAddrPrefix = \"ntfy-\"\n\tbackend := newMailBackend(conf, handler)\n\tl, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ts = smtp.NewServer(backend)\n\ts.Domain = conf.SMTPServerDomain\n\ts.AllowInsecureAuth = true\n\tgo func() {\n\t\trequire.Nil(t, s.Serve(l))\n\t}()\n\tc, err = net.Dial(\"tcp\", l.Addr().String())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tscanner = bufio.NewScanner(c)\n\treturn\n}\n\nfunc writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {\n\t_, err := io.WriteString(conn, email)\n\trequire.Nil(t, err)\n\treadUntilLine(t, conn, scanner, expectedLine)\n}\n\nfunc readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {\n\tcancelChan := make(chan bool)\n\tgo func() {\n\t\tselect {\n\t\tcase <-cancelChan:\n\t\tcase <-time.After(3 * time.Second):\n\t\t\tconn.Close()\n\t\t\tt.Error(\"Failed waiting for expected output\")\n\t\t}\n\t}()\n\tvar output string\n\tfor scanner.Scan() {\n\t\ttext := scanner.Text()\n\t\tif strings.TrimSpace(text) == expectedLine {\n\t\t\tcancelChan <- true\n\t\t\treturn\n\t\t}\n\t\toutput += text + \"\\n\"\n\t}\n\tt.Fatalf(\"Expected line '%s' not found in output:\\n%s\", expectedLine, output)\n}\n"
  },
  {
    "path": "server/templates/alertmanager.yml",
    "content": "title: |\n  {{- if eq .status \"firing\" }}\n  🚨 Alert: {{ (first .alerts).labels.alertname }}\n  {{- else if eq .status \"resolved\" }}\n  ✅ Resolved: {{ (first .alerts).labels.alertname }}\n  {{- else }}\n  {{ fail \"Unsupported Alertmanager status.\" }}\n  {{- end }}\nmessage: |\n  Status: {{ .status | title }}\n  Receiver: {{ .receiver }}\n  \n  {{- range .alerts }}\n  Alert: {{ .labels.alertname }}\n  Instance: {{ .labels.instance }}\n  Severity: {{ .labels.severity }}\n  Starts at: {{ .startsAt }}\n  {{- if .endsAt }}Ends at: {{ .endsAt }}{{ end }}\n  {{- if .annotations.summary }}\n  Summary: {{ .annotations.summary }}\n  {{- end }}\n  {{- if .annotations.description }}\n  Description: {{ .annotations.description }}\n  {{- end }}\n  Source: {{ .generatorURL }}\n  \n  {{ end }}\n"
  },
  {
    "path": "server/templates/github.yml",
    "content": "title: |\n  {{- if and .starred_at (eq .action \"created\")}}\n  ⭐ {{ .sender.login }} starred {{ .repository.name }}\n  \n  {{- else if and .repository (eq .action \"started\")}}\n  👀 {{ .sender.login }} started watching {{ .repository.name }}\n  \n  {{- else if and .comment (eq .action \"created\") }}\n  💬 New comment on issue #{{ .issue.number }} {{ .issue.title }}\n  \n  {{- else if .pull_request }}\n  🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }}\n  \n  {{- else if .issue }}\n  🐛 Issue {{ .action }}: #{{ .issue.number }} {{ .issue.title }}\n  \n  {{- else }}\n  {{ fail \"Unsupported GitHub event type or action.\" }}\n  {{- end }}\nmessage: |\n  {{ if and .starred_at (eq .action \"created\")}}\n  Stargazer: {{ .sender.html_url }}\n  Repository: {{ .repository.html_url }}\n  \n  {{- else if and .repository (eq .action \"started\")}}\n  Watcher: {{ .sender.html_url }}\n  Repository: {{ .repository.html_url }}\n  \n  {{- else if and .comment (eq .action \"created\") }}\n  Commenter: {{ .comment.user.html_url }}\n  Repository: {{ .repository.html_url }}\n  Comment link: {{ .comment.html_url }}\n  {{ if .comment.body }}\n  Comment:\n  {{ .comment.body | trunc 2000 }}{{ end }}\n  \n  {{- else if .pull_request }}\n  Branch: {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }}\n  {{ .action | title }} by: {{ .pull_request.user.html_url }}\n  Repository: {{ .repository.html_url }}\n  Pull request: {{ .pull_request.html_url }}\n  {{ if .pull_request.body }}\n  Description:\n  {{ .pull_request.body | trunc 2000 }}{{ end }}\n  \n  {{- else if .issue }}\n  {{ .action | title }} by: {{ .issue.user.html_url }}\n  Repository: {{ .repository.html_url }}\n  Issue link: {{ .issue.html_url }}\n  {{ if .issue.labels }}Labels: {{ range .issue.labels }}{{ .name }} {{ end }}{{ end }}\n  {{ if .issue.body }}\n  Description:\n  {{ .issue.body | trunc 2000 }}{{ end }}\n  \n  {{- else }}\n  {{ fail \"Unsupported GitHub event type or action.\" }}\n  {{- end }}\n"
  },
  {
    "path": "server/templates/grafana.yml",
    "content": "title: |\n  {{- if eq .status \"firing\" }}\n  🚨 {{ .title | default \"Alert firing\" }}\n  {{- else if eq .status \"resolved\" }}\n  ✅ {{ .title | default \"Alert resolved\" }}\n  {{- else }}\n  ⚠️ Unknown alert: {{ .title | default \"Alert\" }}\n  {{- end }}\nmessage: |\n  {{ .message | trunc 2000 }}\n"
  },
  {
    "path": "server/testdata/webhook_alertmanager_firing.json",
    "content": "{\n  \"version\": \"4\",\n  \"groupKey\": \"...\",\n  \"status\": \"firing\",\n  \"receiver\": \"webhook-receiver\",\n  \"groupLabels\": {\n    \"alertname\": \"HighCPUUsage\"\n  },\n  \"commonLabels\": {\n    \"alertname\": \"HighCPUUsage\",\n    \"instance\": \"server01\",\n    \"severity\": \"critical\"\n  },\n  \"commonAnnotations\": {\n    \"summary\": \"High CPU usage detected\"\n  },\n  \"alerts\": [\n    {\n      \"status\": \"firing\",\n      \"labels\": {\n        \"alertname\": \"HighCPUUsage\",\n        \"instance\": \"server01\",\n        \"severity\": \"critical\"\n      },\n      \"annotations\": {\n        \"summary\": \"High CPU usage detected\"\n      },\n      \"startsAt\": \"2025-07-17T07:00:00Z\",\n      \"endsAt\": \"0001-01-01T00:00:00Z\",\n      \"generatorURL\": \"http://prometheus.local/graph?g0.expr=...\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/testdata/webhook_github_comment_created.json",
    "content": "{\n  \"action\": \"created\",\n  \"issue\": {\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389\",\n    \"repository_url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389/labels{/name}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389/comments\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389/events\",\n    \"html_url\": \"https://github.com/binwiederhier/ntfy/issues/1389\",\n    \"id\": 3230655753,\n    \"node_id\": \"I_kwDOGRBhi87Aj-UJ\",\n    \"number\": 1389,\n    \"title\": \"instant alerts without Pull to refresh\",\n    \"user\": {\n      \"login\": \"edbraunh\",\n      \"id\": 8795846,\n      \"node_id\": \"MDQ6VXNlcjg3OTU4NDY=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8795846?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/edbraunh\",\n      \"html_url\": \"https://github.com/edbraunh\",\n      \"followers_url\": \"https://api.github.com/users/edbraunh/followers\",\n      \"following_url\": \"https://api.github.com/users/edbraunh/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/edbraunh/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/edbraunh/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/edbraunh/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/edbraunh/orgs\",\n      \"repos_url\": \"https://api.github.com/users/edbraunh/repos\",\n      \"events_url\": \"https://api.github.com/users/edbraunh/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/edbraunh/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"labels\": [\n      {\n        \"id\": 3480884105,\n        \"node_id\": \"LA_kwDOGRBhi87PehOJ\",\n        \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels/enhancement\",\n        \"name\": \"enhancement\",\n        \"color\": \"a2eeef\",\n        \"default\": true,\n        \"description\": \"New feature or request\"\n      }\n    ],\n    \"state\": \"open\",\n    \"locked\": false,\n    \"assignee\": null,\n    \"assignees\": [\n    ],\n    \"milestone\": null,\n    \"comments\": 3,\n    \"created_at\": \"2025-07-15T03:46:30Z\",\n    \"updated_at\": \"2025-07-16T11:45:57Z\",\n    \"closed_at\": null,\n    \"author_association\": \"NONE\",\n    \"active_lock_reason\": null,\n    \"sub_issues_summary\": {\n      \"total\": 0,\n      \"completed\": 0,\n      \"percent_completed\": 0\n    },\n    \"body\": \"Hello ntfy Team,\\n\\nFirst off, thank you for developing such a powerful and lightweight notification app — it’s been invaluable for receiving timely alerts.\\n\\nI’m a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\\n\\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\\n\\nThank you for considering this request. I appreciate your hard work and look forward to future updates!\",\n    \"reactions\": {\n      \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions\",\n      \"total_count\": 0,\n      \"+1\": 0,\n      \"-1\": 0,\n      \"laugh\": 0,\n      \"hooray\": 0,\n      \"confused\": 0,\n      \"heart\": 0,\n      \"rocket\": 0,\n      \"eyes\": 0\n    },\n    \"timeline_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline\",\n    \"performed_via_github_app\": null,\n    \"state_reason\": null\n  },\n  \"comment\": {\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289\",\n    \"html_url\": \"https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289\",\n    \"issue_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1389\",\n    \"id\": 3078214289,\n    \"node_id\": \"IC_kwDOGRBhi863edKR\",\n    \"user\": {\n      \"login\": \"wunter8\",\n      \"id\": 8421688,\n      \"node_id\": \"MDQ6VXNlcjg0MjE2ODg=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8421688?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/wunter8\",\n      \"html_url\": \"https://github.com/wunter8\",\n      \"followers_url\": \"https://api.github.com/users/wunter8/followers\",\n      \"following_url\": \"https://api.github.com/users/wunter8/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/wunter8/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/wunter8/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/wunter8/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/wunter8/orgs\",\n      \"repos_url\": \"https://api.github.com/users/wunter8/repos\",\n      \"events_url\": \"https://api.github.com/users/wunter8/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/wunter8/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"created_at\": \"2025-07-16T11:45:57Z\",\n    \"updated_at\": \"2025-07-16T11:45:57Z\",\n    \"author_association\": \"CONTRIBUTOR\",\n    \"body\": \"These are the things you need to do to get iOS push notifications to work:\\n1. open a browser to the web app of your ntfy instance and copy the URL (including \\\"http://\\\" or \\\"https://\\\", your domain or IP address, and any ports, and excluding any trailing slashes)\\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \\\"https://ntfy.sh\\\" (without a trailing slash)\",\n    \"reactions\": {\n      \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions\",\n      \"total_count\": 0,\n      \"+1\": 0,\n      \"-1\": 0,\n      \"laugh\": 0,\n      \"hooray\": 0,\n      \"confused\": 0,\n      \"heart\": 0,\n      \"rocket\": 0,\n      \"eyes\": 0\n    },\n    \"performed_via_github_app\": null\n  },\n  \"repository\": {\n    \"id\": 420503947,\n    \"node_id\": \"R_kgDOGRBhiw\",\n    \"name\": \"ntfy\",\n    \"full_name\": \"binwiederhier/ntfy\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n    \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n    \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n    \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n    \"created_at\": \"2021-10-23T19:25:32Z\",\n    \"updated_at\": \"2025-07-16T10:18:34Z\",\n    \"pushed_at\": \"2025-07-13T13:56:19Z\",\n    \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n    \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n    \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n    \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"homepage\": \"https://ntfy.sh\",\n    \"size\": 36740,\n    \"stargazers_count\": 25111,\n    \"watchers_count\": 25111,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 984,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 367,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n      \"curl\",\n      \"notifications\",\n      \"ntfy\",\n      \"ntfysh\",\n      \"pubsub\",\n      \"push-notifications\",\n      \"rest-api\"\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 984,\n    \"open_issues\": 367,\n    \"watchers\": 25111,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"wunter8\",\n    \"id\": 8421688,\n    \"node_id\": \"MDQ6VXNlcjg0MjE2ODg=\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/8421688?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/wunter8\",\n    \"html_url\": \"https://github.com/wunter8\",\n    \"followers_url\": \"https://api.github.com/users/wunter8/followers\",\n    \"following_url\": \"https://api.github.com/users/wunter8/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/wunter8/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/wunter8/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/wunter8/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/wunter8/orgs\",\n    \"repos_url\": \"https://api.github.com/users/wunter8/repos\",\n    \"events_url\": \"https://api.github.com/users/wunter8/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/wunter8/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/testdata/webhook_github_issue_opened.json",
    "content": "{\n  \"action\": \"opened\",\n  \"issue\": {\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391\",\n    \"repository_url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391/labels{/name}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391/comments\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391/events\",\n    \"html_url\": \"https://github.com/binwiederhier/ntfy/issues/1391\",\n    \"id\": 3236389051,\n    \"node_id\": \"I_kwDOGRBhi87A52C7\",\n    \"number\": 1391,\n    \"title\": \"http 500 error (ntfy error 50001)\",\n    \"user\": {\n      \"login\": \"TheUser-dev\",\n      \"id\": 213207407,\n      \"node_id\": \"U_kgDODLVJbw\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/213207407?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/TheUser-dev\",\n      \"html_url\": \"https://github.com/TheUser-dev\",\n      \"followers_url\": \"https://api.github.com/users/TheUser-dev/followers\",\n      \"following_url\": \"https://api.github.com/users/TheUser-dev/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/TheUser-dev/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/TheUser-dev/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/TheUser-dev/orgs\",\n      \"repos_url\": \"https://api.github.com/users/TheUser-dev/repos\",\n      \"events_url\": \"https://api.github.com/users/TheUser-dev/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/TheUser-dev/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"labels\": [\n      {\n        \"id\": 3480884102,\n        \"node_id\": \"LA_kwDOGRBhi87PehOG\",\n        \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug\",\n        \"name\": \"🪲 bug\",\n        \"color\": \"d73a4a\",\n        \"default\": false,\n        \"description\": \"Something isn't working\"\n      }\n    ],\n    \"state\": \"open\",\n    \"locked\": false,\n    \"assignee\": null,\n    \"assignees\": [\n    ],\n    \"milestone\": null,\n    \"comments\": 0,\n    \"created_at\": \"2025-07-16T15:20:56Z\",\n    \"updated_at\": \"2025-07-16T15:20:56Z\",\n    \"closed_at\": null,\n    \"author_association\": \"NONE\",\n    \"active_lock_reason\": null,\n    \"sub_issues_summary\": {\n      \"total\": 0,\n      \"completed\": 0,\n      \"percent_completed\": 0\n    },\n    \"body\": \":lady_beetle: **Describe the bug**\\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\\n\\n:computer: **Components impacted**\\nntfy server 2.13.0 in docker, debian 12 arm64\\n\\n:bulb: **Screenshots and/or logs**\\n```\\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\\n```\\n\\n:crystal_ball: **Additional context**\\nLooks like this has already been fixed by #498, regression?\\n\",\n    \"reactions\": {\n      \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions\",\n      \"total_count\": 0,\n      \"+1\": 0,\n      \"-1\": 0,\n      \"laugh\": 0,\n      \"hooray\": 0,\n      \"confused\": 0,\n      \"heart\": 0,\n      \"rocket\": 0,\n      \"eyes\": 0\n    },\n    \"timeline_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline\",\n    \"performed_via_github_app\": null,\n    \"state_reason\": null\n  },\n  \"repository\": {\n    \"id\": 420503947,\n    \"node_id\": \"R_kgDOGRBhiw\",\n    \"name\": \"ntfy\",\n    \"full_name\": \"binwiederhier/ntfy\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n    \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n    \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n    \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n    \"created_at\": \"2021-10-23T19:25:32Z\",\n    \"updated_at\": \"2025-07-16T14:54:16Z\",\n    \"pushed_at\": \"2025-07-16T11:49:26Z\",\n    \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n    \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n    \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n    \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"homepage\": \"https://ntfy.sh\",\n    \"size\": 36831,\n    \"stargazers_count\": 25112,\n    \"watchers_count\": 25112,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 984,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 369,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n      \"curl\",\n      \"notifications\",\n      \"ntfy\",\n      \"ntfysh\",\n      \"pubsub\",\n      \"push-notifications\",\n      \"rest-api\"\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 984,\n    \"open_issues\": 369,\n    \"watchers\": 25112,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"TheUser-dev\",\n    \"id\": 213207407,\n    \"node_id\": \"U_kgDODLVJbw\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/213207407?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/TheUser-dev\",\n    \"html_url\": \"https://github.com/TheUser-dev\",\n    \"followers_url\": \"https://api.github.com/users/TheUser-dev/followers\",\n    \"following_url\": \"https://api.github.com/users/TheUser-dev/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/TheUser-dev/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/TheUser-dev/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/TheUser-dev/orgs\",\n    \"repos_url\": \"https://api.github.com/users/TheUser-dev/repos\",\n    \"events_url\": \"https://api.github.com/users/TheUser-dev/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/TheUser-dev/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/testdata/webhook_github_pr_opened.json",
    "content": "{\n  \"action\": \"opened\",\n  \"number\": 1390,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390\",\n    \"id\": 2670425869,\n    \"node_id\": \"PR_kwDOGRBhi86fK3cN\",\n    \"html_url\": \"https://github.com/binwiederhier/ntfy/pull/1390\",\n    \"diff_url\": \"https://github.com/binwiederhier/ntfy/pull/1390.diff\",\n    \"patch_url\": \"https://github.com/binwiederhier/ntfy/pull/1390.patch\",\n    \"issue_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1390\",\n    \"number\": 1390,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"WIP Template dir\",\n    \"user\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": null,\n    \"created_at\": \"2025-07-16T11:49:31Z\",\n    \"updated_at\": \"2025-07-16T11:49:31Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"assignee\": null,\n    \"assignees\": [\n    ],\n    \"requested_reviewers\": [\n    ],\n    \"requested_teams\": [\n    ],\n    \"labels\": [\n    ],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd\",\n    \"head\": {\n      \"label\": \"binwiederhier:template-dir\",\n      \"ref\": \"template-dir\",\n      \"sha\": \"b1e935da45365c5e7e731d544a1ad4c7ea3643cd\",\n      \"user\": {\n        \"login\": \"binwiederhier\",\n        \"id\": 664597,\n        \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/binwiederhier\",\n        \"html_url\": \"https://github.com/binwiederhier\",\n        \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n        \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n        \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n        \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 420503947,\n        \"node_id\": \"R_kgDOGRBhiw\",\n        \"name\": \"ntfy\",\n        \"full_name\": \"binwiederhier/ntfy\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"binwiederhier\",\n          \"id\": 664597,\n          \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/binwiederhier\",\n          \"html_url\": \"https://github.com/binwiederhier\",\n          \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n          \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n          \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n          \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n        \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n        \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n        \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n        \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n        \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n        \"created_at\": \"2021-10-23T19:25:32Z\",\n        \"updated_at\": \"2025-07-16T10:18:34Z\",\n        \"pushed_at\": \"2025-07-16T11:49:26Z\",\n        \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n        \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n        \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n        \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n        \"homepage\": \"https://ntfy.sh\",\n        \"size\": 36740,\n        \"stargazers_count\": 25111,\n        \"watchers_count\": 25111,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 984,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 368,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [\n          \"curl\",\n          \"notifications\",\n          \"ntfy\",\n          \"ntfysh\",\n          \"pubsub\",\n          \"push-notifications\",\n          \"rest-api\"\n        ],\n        \"visibility\": \"public\",\n        \"forks\": 984,\n        \"open_issues\": 368,\n        \"watchers\": 25111,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": true,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"binwiederhier:main\",\n      \"ref\": \"main\",\n      \"sha\": \"81a486adc11fe24efcbedefb28ae946028597c2f\",\n      \"user\": {\n        \"login\": \"binwiederhier\",\n        \"id\": 664597,\n        \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/binwiederhier\",\n        \"html_url\": \"https://github.com/binwiederhier\",\n        \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n        \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n        \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n        \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 420503947,\n        \"node_id\": \"R_kgDOGRBhiw\",\n        \"name\": \"ntfy\",\n        \"full_name\": \"binwiederhier/ntfy\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"binwiederhier\",\n          \"id\": 664597,\n          \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/binwiederhier\",\n          \"html_url\": \"https://github.com/binwiederhier\",\n          \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n          \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n          \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n          \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n        \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n        \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n        \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n        \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n        \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n        \"created_at\": \"2021-10-23T19:25:32Z\",\n        \"updated_at\": \"2025-07-16T10:18:34Z\",\n        \"pushed_at\": \"2025-07-16T11:49:26Z\",\n        \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n        \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n        \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n        \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n        \"homepage\": \"https://ntfy.sh\",\n        \"size\": 36740,\n        \"stargazers_count\": 25111,\n        \"watchers_count\": 25111,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 984,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 368,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [\n          \"curl\",\n          \"notifications\",\n          \"ntfy\",\n          \"ntfysh\",\n          \"pubsub\",\n          \"push-notifications\",\n          \"rest-api\"\n        ],\n        \"visibility\": \"public\",\n        \"forks\": 984,\n        \"open_issues\": 368,\n        \"watchers\": 25111,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": true,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/binwiederhier/ntfy/pull/1390\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1390\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": null,\n    \"rebaseable\": null,\n    \"mergeable_state\": \"unknown\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 7,\n    \"additions\": 5506,\n    \"deletions\": 42,\n    \"changed_files\": 58\n  },\n  \"repository\": {\n    \"id\": 420503947,\n    \"node_id\": \"R_kgDOGRBhiw\",\n    \"name\": \"ntfy\",\n    \"full_name\": \"binwiederhier/ntfy\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n    \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n    \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n    \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n    \"created_at\": \"2021-10-23T19:25:32Z\",\n    \"updated_at\": \"2025-07-16T10:18:34Z\",\n    \"pushed_at\": \"2025-07-16T11:49:26Z\",\n    \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n    \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n    \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n    \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"homepage\": \"https://ntfy.sh\",\n    \"size\": 36740,\n    \"stargazers_count\": 25111,\n    \"watchers_count\": 25111,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 984,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 368,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n      \"curl\",\n      \"notifications\",\n      \"ntfy\",\n      \"ntfysh\",\n      \"pubsub\",\n      \"push-notifications\",\n      \"rest-api\"\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 984,\n    \"open_issues\": 368,\n    \"watchers\": 25111,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"binwiederhier\",\n    \"id\": 664597,\n    \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/binwiederhier\",\n    \"html_url\": \"https://github.com/binwiederhier\",\n    \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n    \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n    \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n    \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/testdata/webhook_github_star_created.json",
    "content": "{\n  \"action\": \"created\",\n  \"starred_at\": \"2025-07-16T12:57:43Z\",\n  \"repository\": {\n    \"id\": 420503947,\n    \"node_id\": \"R_kgDOGRBhiw\",\n    \"name\": \"ntfy\",\n    \"full_name\": \"binwiederhier/ntfy\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n    \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n    \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n    \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n    \"created_at\": \"2021-10-23T19:25:32Z\",\n    \"updated_at\": \"2025-07-16T12:57:43Z\",\n    \"pushed_at\": \"2025-07-16T11:49:26Z\",\n    \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n    \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n    \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n    \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"homepage\": \"https://ntfy.sh\",\n    \"size\": 36831,\n    \"stargazers_count\": 25112,\n    \"watchers_count\": 25112,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 984,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 368,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n      \"curl\",\n      \"notifications\",\n      \"ntfy\",\n      \"ntfysh\",\n      \"pubsub\",\n      \"push-notifications\",\n      \"rest-api\"\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 984,\n    \"open_issues\": 368,\n    \"watchers\": 25112,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"mbilby\",\n    \"id\": 51273322,\n    \"node_id\": \"MDQ6VXNlcjUxMjczMzIy\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/51273322?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/mbilby\",\n    \"html_url\": \"https://github.com/mbilby\",\n    \"followers_url\": \"https://api.github.com/users/mbilby/followers\",\n    \"following_url\": \"https://api.github.com/users/mbilby/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/mbilby/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/mbilby/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/mbilby/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/mbilby/orgs\",\n    \"repos_url\": \"https://api.github.com/users/mbilby/repos\",\n    \"events_url\": \"https://api.github.com/users/mbilby/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/mbilby/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n\n"
  },
  {
    "path": "server/testdata/webhook_github_watch_created.json",
    "content": "{\n  \"action\": \"started\",\n  \"repository\": {\n    \"id\": 420503947,\n    \"node_id\": \"R_kgDOGRBhiw\",\n    \"name\": \"ntfy\",\n    \"full_name\": \"binwiederhier/ntfy\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"binwiederhier\",\n      \"id\": 664597,\n      \"node_id\": \"MDQ6VXNlcjY2NDU5Nw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/664597?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/binwiederhier\",\n      \"html_url\": \"https://github.com/binwiederhier\",\n      \"followers_url\": \"https://api.github.com/users/binwiederhier/followers\",\n      \"following_url\": \"https://api.github.com/users/binwiederhier/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/binwiederhier/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/binwiederhier/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/binwiederhier/orgs\",\n      \"repos_url\": \"https://api.github.com/users/binwiederhier/repos\",\n      \"events_url\": \"https://api.github.com/users/binwiederhier/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/binwiederhier/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"description\": \"Send push notifications to your phone or desktop using PUT/POST\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/binwiederhier/ntfy\",\n    \"forks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/forks\",\n    \"keys_url\": \"https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/binwiederhier/ntfy/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/binwiederhier/ntfy/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/binwiederhier/ntfy/events\",\n    \"assignees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/binwiederhier/ntfy/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/binwiederhier/ntfy/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/binwiederhier/ntfy/merges\",\n    \"archive_url\": \"https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/binwiederhier/ntfy/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/binwiederhier/ntfy/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/binwiederhier/ntfy/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/binwiederhier/ntfy/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/binwiederhier/ntfy/deployments\",\n    \"created_at\": \"2021-10-23T19:25:32Z\",\n    \"updated_at\": \"2025-07-16T12:57:43Z\",\n    \"pushed_at\": \"2025-07-16T11:49:26Z\",\n    \"git_url\": \"git://github.com/binwiederhier/ntfy.git\",\n    \"ssh_url\": \"git@github.com:binwiederhier/ntfy.git\",\n    \"clone_url\": \"https://github.com/binwiederhier/ntfy.git\",\n    \"svn_url\": \"https://github.com/binwiederhier/ntfy\",\n    \"homepage\": \"https://ntfy.sh\",\n    \"size\": 36831,\n    \"stargazers_count\": 25112,\n    \"watchers_count\": 25112,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 984,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 368,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n      \"curl\",\n      \"notifications\",\n      \"ntfy\",\n      \"ntfysh\",\n      \"pubsub\",\n      \"push-notifications\",\n      \"rest-api\"\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 984,\n    \"open_issues\": 368,\n    \"watchers\": 25112,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"mbilby\",\n    \"id\": 51273322,\n    \"node_id\": \"MDQ6VXNlcjUxMjczMzIy\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/51273322?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/mbilby\",\n    \"html_url\": \"https://github.com/mbilby\",\n    \"followers_url\": \"https://api.github.com/users/mbilby/followers\",\n    \"following_url\": \"https://api.github.com/users/mbilby/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/mbilby/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/mbilby/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/mbilby/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/mbilby/orgs\",\n    \"repos_url\": \"https://api.github.com/users/mbilby/repos\",\n    \"events_url\": \"https://api.github.com/users/mbilby/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/mbilby/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/testdata/webhook_grafana_resolved.json",
    "content": "{\n  \"receiver\": \"ntfy\\\\.example\\\\.com/alerts\",\n  \"status\": \"resolved\",\n  \"alerts\": [\n    {\n      \"status\": \"resolved\",\n      \"labels\": {\n        \"alertname\": \"Load avg 15m too high\",\n        \"grafana_folder\": \"Node alerts\",\n        \"instance\": \"10.108.0.2:9100\",\n        \"job\": \"node-exporter\"\n      },\n      \"annotations\": {\n        \"summary\": \"15m load average too high\"\n      },\n      \"startsAt\": \"2024-03-15T02:28:00Z\",\n      \"endsAt\": \"2024-03-15T02:42:00Z\",\n      \"generatorURL\": \"localhost:3000/alerting/grafana/NW9oDw-4z/view\",\n      \"fingerprint\": \"becbfb94bd81ef48\",\n      \"silenceURL\": \"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\",\n      \"dashboardURL\": \"\",\n      \"panelURL\": \"\",\n      \"values\": {\n        \"B\": 18.98211314475876,\n        \"C\": 0\n      },\n      \"valueString\": \"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]\"\n    }\n  ],\n  \"groupLabels\": {\n    \"alertname\": \"Load avg 15m too high\",\n    \"grafana_folder\": \"Node alerts\"\n  },\n  \"commonLabels\": {\n    \"alertname\": \"Load avg 15m too high\",\n    \"grafana_folder\": \"Node alerts\",\n    \"instance\": \"10.108.0.2:9100\",\n    \"job\": \"node-exporter\"\n  },\n  \"commonAnnotations\": {\n    \"summary\": \"15m load average too high\"\n  },\n  \"externalURL\": \"localhost:3000/\",\n  \"version\": \"1\",\n  \"groupKey\": \"{}:{alertname=\\\"Load avg 15m too high\\\", grafana_folder=\\\"Node alerts\\\"}\",\n  \"truncatedAlerts\": 0,\n  \"orgId\": 1,\n  \"title\": \"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)\",\n  \"state\": \"ok\",\n  \"message\": \"**Resolved**\\n\\nValue: B=18.98211314475876, C=0\\nLabels:\\n - alertname = Load avg 15m too high\\n - grafana_folder = Node alerts\\n - instance = 10.108.0.2:9100\\n - job = node-exporter\\n\"\n}\n"
  },
  {
    "path": "server/topic.go",
    "content": "package server\n\nimport (\n\t\"math/rand\"\n\t\"sync\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\t// topicExpungeAfter defines how long a topic is active before it is removed from memory.\n\t// This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give\n\t// time for more requests to come in, so that we can send a {\"rejected\":[\"<pushkey>\"]} response back.\n\ttopicExpungeAfter = 16 * time.Hour\n)\n\n// topic represents a channel to which subscribers can subscribe, and publishers\n// can publish a message\ntype topic struct {\n\tID          string\n\tsubscribers map[int]*topicSubscriber\n\trateVisitor *visitor\n\tlastAccess  time.Time\n\tmu          sync.RWMutex\n}\n\ntype topicSubscriber struct {\n\tuserID     string // User ID associated with this subscription, may be empty\n\tsubscriber subscriber\n\tcancel     func()\n}\n\n// subscriber is a function that is called for every new message on a topic\ntype subscriber func(v *visitor, msg *model.Message) error\n\n// newTopic creates a new topic\nfunc newTopic(id string) *topic {\n\treturn &topic{\n\t\tID:          id,\n\t\tsubscribers: make(map[int]*topicSubscriber),\n\t\tlastAccess:  time.Now(),\n\t}\n}\n\n// Subscribe subscribes to this topic\nfunc (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscriberID int) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tfor i := 0; i < 5; i++ { // Best effort retry\n\t\tsubscriberID = rand.Int()\n\t\t_, exists := t.subscribers[subscriberID]\n\t\tif !exists {\n\t\t\tbreak\n\t\t}\n\t}\n\tt.subscribers[subscriberID] = &topicSubscriber{\n\t\tuserID:     userID, // May be empty\n\t\tsubscriber: s,\n\t\tcancel:     cancel,\n\t}\n\tt.lastAccess = time.Now()\n\treturn subscriberID\n}\n\nfunc (t *topic) Stale() bool {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif t.rateVisitor != nil && !t.rateVisitor.Stale() {\n\t\treturn false\n\t}\n\treturn len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter\n}\n\nfunc (t *topic) LastAccess() time.Time {\n\tt.mu.RLock()\n\tdefer t.mu.RUnlock()\n\treturn t.lastAccess\n}\n\nfunc (t *topic) SetRateVisitor(v *visitor) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tt.rateVisitor = v\n\tt.lastAccess = time.Now()\n}\n\nfunc (t *topic) RateVisitor() *visitor {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif t.rateVisitor != nil && t.rateVisitor.Stale() {\n\t\tt.rateVisitor = nil\n\t}\n\treturn t.rateVisitor\n}\n\n// Unsubscribe removes the subscription from the list of subscribers\nfunc (t *topic) Unsubscribe(id int) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tdelete(t.subscribers, id)\n}\n\n// Publish asynchronously publishes to all subscribers\nfunc (t *topic) Publish(v *visitor, m *model.Message) error {\n\tgo func() {\n\t\t// We want to lock the topic as short as possible, so we make a shallow copy of the\n\t\t// subscribers map here. Actually sending out the messages then doesn't have to lock.\n\t\tsubscribers := t.subscribersCopy()\n\t\tif len(subscribers) > 0 {\n\t\t\tlogvm(v, m).Tag(tagPublish).Debug(\"Forwarding to %d subscriber(s)\", len(subscribers))\n\t\t\tfor _, s := range subscribers {\n\t\t\t\t// We call the subscriber functions in their own Go routines because they are blocking, and\n\t\t\t\t// we don't want individual slow subscribers to be able to block others.\n\t\t\t\tgo func(s subscriber) {\n\t\t\t\t\tif err := s(v, m); err != nil {\n\t\t\t\t\t\tlogvm(v, m).Tag(tagPublish).Err(err).Warn(\"Error forwarding to subscriber\")\n\t\t\t\t\t}\n\t\t\t\t}(s.subscriber)\n\t\t\t}\n\t\t} else {\n\t\t\tlogvm(v, m).Tag(tagPublish).Trace(\"No stream or WebSocket subscribers, not forwarding\")\n\t\t}\n\t\tt.Keepalive()\n\t}()\n\treturn nil\n}\n\n// Stats returns the number of subscribers and last access to this topic\nfunc (t *topic) Stats() (int, time.Time) {\n\tt.mu.RLock()\n\tdefer t.mu.RUnlock()\n\treturn len(t.subscribers), t.lastAccess\n}\n\n// Keepalive sets the last access time and ensures that Stale does not return true\nfunc (t *topic) Keepalive() {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tt.lastAccess = time.Now()\n}\n\n// CancelSubscribersExceptUser calls the cancel function for all subscribers, forcing\nfunc (t *topic) CancelSubscribersExceptUser(exceptUserID string) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tfor _, s := range t.subscribers {\n\t\tif s.userID != exceptUserID {\n\t\t\tt.cancelUserSubscriber(s)\n\t\t}\n\t}\n}\n\n// CancelSubscriberUser kills the subscriber with the given user ID\nfunc (t *topic) CancelSubscriberUser(userID string) {\n\tt.mu.RLock()\n\tdefer t.mu.RUnlock()\n\tfor _, s := range t.subscribers {\n\t\tif s.userID == userID {\n\t\t\tt.cancelUserSubscriber(s)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (t *topic) cancelUserSubscriber(s *topicSubscriber) {\n\tlog.\n\t\tTag(tagSubscribe).\n\t\tWith(t).\n\t\tFields(log.Context{\n\t\t\t\"user_id\": s.userID,\n\t\t}).\n\t\tDebug(\"Canceling subscriber with user ID %s\", s.userID)\n\ts.cancel()\n}\n\nfunc (t *topic) Context() log.Context {\n\tt.mu.RLock()\n\tdefer t.mu.RUnlock()\n\tfields := map[string]any{\n\t\t\"topic\":             t.ID,\n\t\t\"topic_subscribers\": len(t.subscribers),\n\t\t\"topic_last_access\": util.FormatTime(t.lastAccess),\n\t}\n\tif t.rateVisitor != nil {\n\t\tfor k, v := range t.rateVisitor.Context() {\n\t\t\tfields[\"topic_rate_\"+k] = v\n\t\t}\n\t}\n\treturn fields\n}\n\n// subscribersCopy returns a shallow copy of the subscribers map\nfunc (t *topic) subscribersCopy() map[int]*topicSubscriber {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tsubscribers := make(map[int]*topicSubscriber)\n\tfor k, sub := range t.subscribers {\n\t\tsubscribers[k] = &topicSubscriber{\n\t\t\tuserID:     sub.userID,\n\t\t\tsubscriber: sub.subscriber,\n\t\t\tcancel:     sub.cancel,\n\t\t}\n\t}\n\treturn subscribers\n}\n"
  },
  {
    "path": "server/topic_test.go",
    "content": "package server\n\nimport (\n\t\"math/rand\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/model\"\n)\n\nfunc TestTopic_CancelSubscribersExceptUser(t *testing.T) {\n\tsubFn := func(v *visitor, msg *model.Message) error {\n\t\treturn nil\n\t}\n\tcanceled1 := atomic.Bool{}\n\tcancelFn1 := func() {\n\t\tcanceled1.Store(true)\n\t}\n\tcanceled2 := atomic.Bool{}\n\tcancelFn2 := func() {\n\t\tcanceled2.Store(true)\n\t}\n\tto := newTopic(\"mytopic\")\n\tto.Subscribe(subFn, \"\", cancelFn1)\n\tto.Subscribe(subFn, \"u_phil\", cancelFn2)\n\n\tto.CancelSubscribersExceptUser(\"u_phil\")\n\trequire.True(t, canceled1.Load())\n\trequire.False(t, canceled2.Load())\n}\n\nfunc TestTopic_CancelSubscribersUser(t *testing.T) {\n\tt.Parallel()\n\n\tsubFn := func(v *visitor, msg *model.Message) error {\n\t\treturn nil\n\t}\n\tcanceled1 := atomic.Bool{}\n\tcancelFn1 := func() {\n\t\tcanceled1.Store(true)\n\t}\n\tcanceled2 := atomic.Bool{}\n\tcancelFn2 := func() {\n\t\tcanceled2.Store(true)\n\t}\n\tto := newTopic(\"mytopic\")\n\tto.Subscribe(subFn, \"u_another\", cancelFn1)\n\tto.Subscribe(subFn, \"u_phil\", cancelFn2)\n\n\tto.CancelSubscriberUser(\"u_phil\")\n\trequire.False(t, canceled1.Load())\n\trequire.True(t, canceled2.Load())\n}\n\nfunc TestTopic_Keepalive(t *testing.T) {\n\tt.Parallel()\n\n\tto := newTopic(\"mytopic\")\n\tto.lastAccess = time.Now().Add(-1 * time.Hour)\n\tto.Keepalive()\n\trequire.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2)\n\trequire.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2)\n}\n\nfunc TestTopic_Subscribe_DuplicateID(t *testing.T) {\n\tt.Parallel()\n\tto := newTopic(\"mytopic\")\n\n\t//lint:ignore SA1019 Fix random seed to force same number generation\n\trand.Seed(1)\n\ta := rand.Int()\n\tto.subscribers[a] = &topicSubscriber{\n\t\tuserID:     \"a\",\n\t\tsubscriber: nil,\n\t\tcancel:     func() {},\n\t}\n\n\tsubFn := func(v *visitor, msg *model.Message) error {\n\t\treturn nil\n\t}\n\n\t//lint:ignore SA1019 Force rand.Int to generate the same id once more\n\trand.Seed(1)\n\tid := to.Subscribe(subFn, \"b\", func() {})\n\tres := to.subscribers[id]\n\n\trequire.NotEqual(t, id, a)\n\trequire.Equal(t, \"b\", res.userID, \"b\")\n}\n"
  },
  {
    "path": "server/types.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\n\t\"heckel.io/ntfy/v2/model\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// publishMessage is used as input when publishing as JSON\ntype publishMessage struct {\n\tTopic      string         `json:\"topic\"`\n\tSequenceID string         `json:\"sequence_id\"`\n\tTitle      string         `json:\"title\"`\n\tMessage    string         `json:\"message\"`\n\tPriority   int            `json:\"priority\"`\n\tTags       []string       `json:\"tags\"`\n\tClick      string         `json:\"click\"`\n\tIcon       string         `json:\"icon\"`\n\tActions    []model.Action `json:\"actions\"`\n\tAttach     string         `json:\"attach\"`\n\tMarkdown   bool           `json:\"markdown\"`\n\tFilename   string         `json:\"filename\"`\n\tEmail      string         `json:\"email\"`\n\tCall       string         `json:\"call\"`\n\tCache      string         `json:\"cache\"`    // use string as it defaults to true (or use &bool instead)\n\tFirebase   string         `json:\"firebase\"` // use string as it defaults to true (or use &bool instead)\n\tDelay      string         `json:\"delay\"`\n}\n\n// messageEncoder is a function that knows how to encode a message\ntype messageEncoder func(msg *model.Message) (string, error)\n\ntype queryFilter struct {\n\tID       string\n\tMessage  string\n\tTitle    string\n\tTags     []string\n\tPriority []int\n}\n\nfunc parseQueryFilters(r *http.Request) (*queryFilter, error) {\n\tidFilter := readParam(r, \"x-id\", \"id\")\n\tmessageFilter := readParam(r, \"x-message\", \"message\", \"m\")\n\ttitleFilter := readParam(r, \"x-title\", \"title\", \"t\")\n\ttagsFilter := util.SplitNoEmpty(readParam(r, \"x-tags\", \"tags\", \"tag\", \"ta\"), \",\")\n\tpriorityFilter := make([]int, 0)\n\tfor _, p := range util.SplitNoEmpty(readParam(r, \"x-priority\", \"priority\", \"prio\", \"p\"), \",\") {\n\t\tpriority, err := util.ParsePriority(p)\n\t\tif err != nil {\n\t\t\treturn nil, errHTTPBadRequestPriorityInvalid\n\t\t}\n\t\tpriorityFilter = append(priorityFilter, priority)\n\t}\n\treturn &queryFilter{\n\t\tID:       idFilter,\n\t\tMessage:  messageFilter,\n\t\tTitle:    titleFilter,\n\t\tTags:     tagsFilter,\n\t\tPriority: priorityFilter,\n\t}, nil\n}\n\nfunc (q *queryFilter) Pass(msg *model.Message) bool {\n\tif msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {\n\t\treturn true // filters only apply to messages\n\t} else if q.ID != \"\" && msg.ID != q.ID {\n\t\treturn false\n\t} else if q.Message != \"\" && msg.Message != q.Message {\n\t\treturn false\n\t} else if q.Title != \"\" && msg.Title != q.Title {\n\t\treturn false\n\t}\n\tmessagePriority := msg.Priority\n\tif messagePriority == 0 {\n\t\tmessagePriority = 3 // For query filters, default priority (3) is the same as \"not set\" (0)\n\t}\n\tif len(q.Priority) > 0 && !util.Contains(q.Priority, messagePriority) {\n\t\treturn false\n\t}\n\tif len(q.Tags) > 0 && !util.ContainsAll(msg.Tags, q.Tags) {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// templateMode represents the mode in which templates are used\n//\n// It can be\n// - empty: templating is disabled\n// - a boolean string (yes/1/true/no/0/false): inline-templating mode\n// - a filename (e.g. grafana): template mode with a file\ntype templateMode string\n\n// Enabled returns true if templating is enabled\nfunc (t templateMode) Enabled() bool {\n\treturn t != \"\"\n}\n\n// InlineMode returns true if inline-templating mode is enabled\nfunc (t templateMode) InlineMode() bool {\n\treturn t.Enabled() && isBoolValue(string(t))\n}\n\n// FileMode returns true if file-templating mode is enabled\nfunc (t templateMode) FileMode() bool {\n\treturn t.Enabled() && !isBoolValue(string(t))\n}\n\n// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise\nfunc (t templateMode) FileName() string {\n\tif t.FileMode() {\n\t\treturn string(t)\n\t}\n\treturn \"\"\n}\n\n// templateFile represents a template file with title, message, and priority\n// It is used for file-based templates, e.g. grafana, influxdb, etc.\n//\n// Example YAML:\n//\n//\t  title: \"Alert: {{ .Title }}\"\n//\t  message: |\n//\t\t   This is a {{ .Type }} alert.\n//\t\t   It can be multiline.\n//\t  priority: '{{ if eq .status \"Error\" }}5{{ else }}3{{ end }}'\ntype templateFile struct {\n\tTitle    *string `yaml:\"title\"`\n\tMessage  *string `yaml:\"message\"`\n\tPriority *string `yaml:\"priority\"`\n}\n\ntype apiHealthResponse struct {\n\tHealthy bool `json:\"healthy\"`\n}\n\ntype apiVersionResponse struct {\n\tVersion string `json:\"version\"`\n\tCommit  string `json:\"commit\"`\n\tDate    string `json:\"date\"`\n}\n\ntype apiStatsResponse struct {\n\tMessages     int64   `json:\"messages\"`\n\tMessagesRate float64 `json:\"messages_rate\"` // Average number of messages per second\n}\n\ntype apiUserAddOrUpdateRequest struct {\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n\tHash     string `json:\"hash\"`\n\tTier     string `json:\"tier\"`\n\t// Do not add 'role' here. We don't want to add admins via the API.\n}\n\ntype apiUserResponse struct {\n\tUsername string                  `json:\"username\"`\n\tRole     string                  `json:\"role\"`\n\tTier     string                  `json:\"tier,omitempty\"`\n\tGrants   []*apiUserGrantResponse `json:\"grants,omitempty\"`\n}\n\ntype apiUserGrantResponse struct {\n\tTopic      string `json:\"topic\"` // This may be a pattern\n\tPermission string `json:\"permission\"`\n}\n\ntype apiUserDeleteRequest struct {\n\tUsername string `json:\"username\"`\n}\n\ntype apiAccessAllowRequest struct {\n\tUsername   string `json:\"username\"`\n\tTopic      string `json:\"topic\"` // This may be a pattern\n\tPermission string `json:\"permission\"`\n}\n\ntype apiAccessResetRequest struct {\n\tUsername string `json:\"username\"`\n\tTopic    string `json:\"topic\"`\n}\n\ntype apiAccountCreateRequest struct {\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\ntype apiAccountPasswordChangeRequest struct {\n\tPassword    string `json:\"password\"`\n\tNewPassword string `json:\"new_password\"`\n}\n\ntype apiAccountDeleteRequest struct {\n\tPassword string `json:\"password\"`\n}\n\ntype apiAccountTokenIssueRequest struct {\n\tLabel   *string `json:\"label\"`\n\tExpires *int64  `json:\"expires\"` // Unix timestamp\n}\n\ntype apiAccountTokenUpdateRequest struct {\n\tToken   string  `json:\"token\"`\n\tLabel   *string `json:\"label\"`\n\tExpires *int64  `json:\"expires\"` // Unix timestamp\n}\n\ntype apiAccountTokenResponse struct {\n\tToken       string `json:\"token\"`\n\tLabel       string `json:\"label,omitempty\"`\n\tLastAccess  int64  `json:\"last_access,omitempty\"`\n\tLastOrigin  string `json:\"last_origin,omitempty\"`\n\tExpires     int64  `json:\"expires,omitempty\"`     // Unix timestamp\n\tProvisioned bool   `json:\"provisioned,omitempty\"` // True if this token was provisioned by the server config\n}\n\ntype apiAccountPhoneNumberVerifyRequest struct {\n\tNumber  string `json:\"number\"`\n\tChannel string `json:\"channel\"`\n}\n\ntype apiAccountPhoneNumberAddRequest struct {\n\tNumber string `json:\"number\"`\n\tCode   string `json:\"code\"` // Only set when adding a phone number\n}\n\ntype apiAccountTier struct {\n\tCode string `json:\"code\"`\n\tName string `json:\"name\"`\n}\n\ntype apiAccountLimits struct {\n\tBasis                    string `json:\"basis,omitempty\"` // \"ip\" or \"tier\"\n\tMessages                 int64  `json:\"messages\"`\n\tMessagesExpiryDuration   int64  `json:\"messages_expiry_duration\"`\n\tEmails                   int64  `json:\"emails\"`\n\tCalls                    int64  `json:\"calls\"`\n\tReservations             int64  `json:\"reservations\"`\n\tAttachmentTotalSize      int64  `json:\"attachment_total_size\"`\n\tAttachmentFileSize       int64  `json:\"attachment_file_size\"`\n\tAttachmentExpiryDuration int64  `json:\"attachment_expiry_duration\"`\n\tAttachmentBandwidth      int64  `json:\"attachment_bandwidth\"`\n}\n\ntype apiAccountStats struct {\n\tMessages                     int64 `json:\"messages\"`\n\tMessagesRemaining            int64 `json:\"messages_remaining\"`\n\tEmails                       int64 `json:\"emails\"`\n\tEmailsRemaining              int64 `json:\"emails_remaining\"`\n\tCalls                        int64 `json:\"calls\"`\n\tCallsRemaining               int64 `json:\"calls_remaining\"`\n\tReservations                 int64 `json:\"reservations\"`\n\tReservationsRemaining        int64 `json:\"reservations_remaining\"`\n\tAttachmentTotalSize          int64 `json:\"attachment_total_size\"`\n\tAttachmentTotalSizeRemaining int64 `json:\"attachment_total_size_remaining\"`\n}\n\ntype apiAccountReservation struct {\n\tTopic    string `json:\"topic\"`\n\tEveryone string `json:\"everyone\"`\n}\n\ntype apiAccountBilling struct {\n\tCustomer     bool   `json:\"customer\"`\n\tSubscription bool   `json:\"subscription\"`\n\tStatus       string `json:\"status,omitempty\"`\n\tInterval     string `json:\"interval,omitempty\"`\n\tPaidUntil    int64  `json:\"paid_until,omitempty\"`\n\tCancelAt     int64  `json:\"cancel_at,omitempty\"`\n}\n\ntype apiAccountResponse struct {\n\tUsername      string                     `json:\"username\"`\n\tRole          string                     `json:\"role,omitempty\"`\n\tSyncTopic     string                     `json:\"sync_topic,omitempty\"`\n\tProvisioned   bool                       `json:\"provisioned,omitempty\"`\n\tLanguage      string                     `json:\"language,omitempty\"`\n\tNotification  *user.NotificationPrefs    `json:\"notification,omitempty\"`\n\tSubscriptions []*user.Subscription       `json:\"subscriptions,omitempty\"`\n\tReservations  []*apiAccountReservation   `json:\"reservations,omitempty\"`\n\tTokens        []*apiAccountTokenResponse `json:\"tokens,omitempty\"`\n\tPhoneNumbers  []string                   `json:\"phone_numbers,omitempty\"`\n\tTier          *apiAccountTier            `json:\"tier,omitempty\"`\n\tLimits        *apiAccountLimits          `json:\"limits,omitempty\"`\n\tStats         *apiAccountStats           `json:\"stats,omitempty\"`\n\tBilling       *apiAccountBilling         `json:\"billing,omitempty\"`\n}\n\ntype apiAccountReservationRequest struct {\n\tTopic    string `json:\"topic\"`\n\tEveryone string `json:\"everyone\"`\n}\n\ntype apiConfigResponse struct {\n\tBaseURL            string   `json:\"base_url\"`\n\tAppRoot            string   `json:\"app_root\"`\n\tEnableLogin        bool     `json:\"enable_login\"`\n\tRequireLogin       bool     `json:\"require_login\"`\n\tEnableSignup       bool     `json:\"enable_signup\"`\n\tEnablePayments     bool     `json:\"enable_payments\"`\n\tEnableCalls        bool     `json:\"enable_calls\"`\n\tEnableEmails       bool     `json:\"enable_emails\"`\n\tEnableReservations bool     `json:\"enable_reservations\"`\n\tEnableWebPush      bool     `json:\"enable_web_push\"`\n\tBillingContact     string   `json:\"billing_contact\"`\n\tWebPushPublicKey   string   `json:\"web_push_public_key\"`\n\tDisallowedTopics   []string `json:\"disallowed_topics\"`\n\tConfigHash         string   `json:\"config_hash\"`\n}\n\ntype apiAccountBillingPrices struct {\n\tMonth int64 `json:\"month\"`\n\tYear  int64 `json:\"year\"`\n}\n\ntype apiAccountBillingTier struct {\n\tCode   string                   `json:\"code,omitempty\"`\n\tName   string                   `json:\"name,omitempty\"`\n\tPrices *apiAccountBillingPrices `json:\"prices,omitempty\"`\n\tLimits *apiAccountLimits        `json:\"limits\"`\n}\n\ntype apiAccountBillingSubscriptionCreateResponse struct {\n\tRedirectURL string `json:\"redirect_url\"`\n}\n\ntype apiAccountBillingSubscriptionChangeRequest struct {\n\tTier     string `json:\"tier\"`\n\tInterval string `json:\"interval\"`\n}\n\ntype apiAccountBillingPortalRedirectResponse struct {\n\tRedirectURL string `json:\"redirect_url\"`\n}\n\ntype apiAccountSyncTopicResponse struct {\n\tEvent string `json:\"event\"`\n}\n\ntype apiSuccessResponse struct {\n\tSuccess bool `json:\"success\"`\n}\n\nfunc newSuccessResponse() *apiSuccessResponse {\n\treturn &apiSuccessResponse{\n\t\tSuccess: true,\n\t}\n}\n\ntype apiStripeSubscriptionUpdatedEvent struct {\n\tID               string `json:\"id\"`\n\tCustomer         string `json:\"customer\"`\n\tStatus           string `json:\"status\"`\n\tCurrentPeriodEnd int64  `json:\"current_period_end\"`\n\tCancelAt         int64  `json:\"cancel_at\"`\n\tItems            *struct {\n\t\tData []*struct {\n\t\t\tPrice *struct {\n\t\t\t\tID        string `json:\"id\"`\n\t\t\t\tRecurring *struct {\n\t\t\t\t\tInterval string `json:\"interval\"`\n\t\t\t\t} `json:\"recurring\"`\n\t\t\t} `json:\"price\"`\n\t\t} `json:\"data\"`\n\t} `json:\"items\"`\n}\n\ntype apiStripeSubscriptionDeletedEvent struct {\n\tID       string `json:\"id\"`\n\tCustomer string `json:\"customer\"`\n}\n\ntype apiWebPushUpdateSubscriptionRequest struct {\n\tEndpoint string   `json:\"endpoint\"`\n\tAuth     string   `json:\"auth\"`\n\tP256dh   string   `json:\"p256dh\"`\n\tTopics   []string `json:\"topics\"`\n}\n\n// List of possible Web Push events (see sw.js)\nconst (\n\twebPushMessageEvent  = \"message\"\n\twebPushExpiringEvent = \"subscription_expiring\"\n)\n\ntype webPushPayload struct {\n\tEvent          string         `json:\"event\"`\n\tSubscriptionID string         `json:\"subscription_id\"`\n\tMessage        *model.Message `json:\"message\"`\n}\n\nfunc newWebPushPayload(subscriptionID string, message *model.Message) *webPushPayload {\n\treturn &webPushPayload{\n\t\tEvent:          webPushMessageEvent,\n\t\tSubscriptionID: subscriptionID,\n\t\tMessage:        message,\n\t}\n}\n\ntype webPushControlMessagePayload struct {\n\tEvent string `json:\"event\"`\n}\n\nfunc newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {\n\treturn &webPushControlMessagePayload{\n\t\tEvent: webPushExpiringEvent,\n\t}\n}\n\n// https://developer.mozilla.org/en-US/docs/Web/Manifest\ntype webManifestResponse struct {\n\tName            string             `json:\"name\"`\n\tDescription     string             `json:\"description\"`\n\tShortName       string             `json:\"short_name\"`\n\tScope           string             `json:\"scope\"`\n\tStartURL        string             `json:\"start_url\"`\n\tDisplay         string             `json:\"display\"`\n\tBackgroundColor string             `json:\"background_color\"`\n\tThemeColor      string             `json:\"theme_color\"`\n\tIcons           []*webManifestIcon `json:\"icons\"`\n}\n\ntype webManifestIcon struct {\n\tSRC   string `json:\"src\"`\n\tSizes string `json:\"sizes\"`\n\tType  string `json:\"type\"`\n}\n"
  },
  {
    "path": "server/util.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nvar (\n\tmimeDecoder mime.WordDecoder\n\n\t// priorityHeaderIgnoreRegex matches specific patterns of the \"Priority\" header (RFC 9218), so that it can be ignored\n\tpriorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\\d,\\s*(i|\\d)$|^u=\\d$`)\n\n\t// forwardedHeaderRegex parses IPv4 and IPv6 addresses from the \"Forwarded\" header (RFC 7239)\n\t// IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional.\n\t//\n\t// Examples:\n\t//  for=\"1.2.3.4\"\n\t//  for=\"[2001:db8::1]\"; for=1.2.3.4:8080, by=phil\n\t//  for=\"1.2.3.4:8080\"\n\tforwardedHeaderRegex = regexp.MustCompile(`(?i)\\bfor=\"?(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|\\[[0-9a-f:]+])(?::\\d+)?\"?`)\n)\n\nfunc readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {\n\tvalue := strings.ToLower(readParam(r, names...))\n\tif value == \"\" {\n\t\treturn defaultValue\n\t}\n\treturn toBool(value)\n}\n\nfunc isBoolValue(value string) bool {\n\treturn value == \"1\" || value == \"yes\" || value == \"true\" || value == \"0\" || value == \"no\" || value == \"false\"\n}\n\nfunc toBool(value string) bool {\n\treturn value == \"1\" || value == \"yes\" || value == \"true\"\n}\n\nfunc readCommaSeparatedParam(r *http.Request, names ...string) []string {\n\tif paramStr := readParam(r, names...); paramStr != \"\" {\n\t\treturn util.Map(util.SplitNoEmpty(paramStr, \",\"), strings.TrimSpace)\n\t}\n\treturn []string{}\n}\n\nfunc readParam(r *http.Request, names ...string) string {\n\tvalue := readHeaderParam(r, names...)\n\tif value != \"\" {\n\t\treturn value\n\t}\n\treturn readQueryParam(r, names...)\n}\n\nfunc readHeaderParam(r *http.Request, names ...string) string {\n\tfor _, name := range names {\n\t\tvalue := strings.TrimSpace(maybeDecodeHeader(name, r.Header.Get(name)))\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc readQueryParam(r *http.Request, names ...string) string {\n\tfor _, name := range names {\n\t\tvalue := r.URL.Query().Get(strings.ToLower(name))\n\t\tif value != \"\" {\n\t\t\treturn strings.TrimSpace(value)\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// extractIPAddress extracts the IP address of the visitor from the request,\n// either from the TCP socket or from a proxy header.\nfunc extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr {\n\tif behindProxy && proxyForwardedHeader != \"\" {\n\t\tif addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {\n\t\t\treturn addr\n\t\t}\n\t\t// Fall back to the remote address if the header is not found or invalid\n\t}\n\taddrPort, err := netip.ParseAddrPort(r.RemoteAddr)\n\tif err != nil {\n\t\tlogr(r).Err(err).Warn(\"unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created\", r.RemoteAddr)\n\t\treturn netip.IPv4Unspecified()\n\t}\n\treturn addrPort.Addr()\n}\n\n// extractIPAddressFromHeader extracts the last IP address from the specified header.\n//\n// It supports multiple formats:\n// - single IP address\n// - comma-separated list\n// - RFC 7239-style list (Forwarded header)\n//\n// If there are multiple addresses, we first remove the trusted IP addresses from the list, and\n// then take the right-most address in the list (as this is the one added by our proxy server).\n// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.\nfunc extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) {\n\tvalue := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))\n\tif value == \"\" {\n\t\treturn netip.IPv4Unspecified(), fmt.Errorf(\"no %s header found\", forwardedHeader)\n\t}\n\t// Extract valid addresses\n\taddrsStrs := util.Map(util.SplitNoEmpty(value, \",\"), strings.TrimSpace)\n\tvar validAddrs []netip.Addr\n\tfor _, addrStr := range addrsStrs {\n\t\t// Handle Forwarded header with for=\"[IPv6]\" or for=\"IPv4\"\n\t\tif m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {\n\t\t\taddrRaw := m[1]\n\t\t\tif strings.HasPrefix(addrRaw, \"[\") && strings.HasSuffix(addrRaw, \"]\") {\n\t\t\t\taddrRaw = addrRaw[1 : len(addrRaw)-1]\n\t\t\t}\n\t\t\tif addr, err := netip.ParseAddr(addrRaw); err == nil {\n\t\t\t\tvalidAddrs = append(validAddrs, addr)\n\t\t\t}\n\t\t} else if addr, err := netip.ParseAddr(addrStr); err == nil {\n\t\t\tvalidAddrs = append(validAddrs, addr)\n\t\t}\n\t}\n\t// Filter out proxy addresses\n\tclientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {\n\t\tfor _, prefix := range trustedPrefixes {\n\t\t\tif prefix.Contains(addr) {\n\t\t\t\treturn false // Address is in the trusted range, ignore it\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\tif len(clientAddrs) == 0 {\n\t\treturn netip.IPv4Unspecified(), fmt.Errorf(\"no client IP address found in %s header: %s\", forwardedHeader, value)\n\t}\n\treturn clientAddrs[len(clientAddrs)-1], nil\n}\n\nfunc readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {\n\tobj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)\n\tif errors.Is(err, util.ErrUnmarshalJSON) {\n\t\treturn nil, errHTTPBadRequestJSONInvalid\n\t} else if errors.Is(err, util.ErrTooLargeJSON) {\n\t\treturn nil, errHTTPEntityTooLargeJSONBody\n\t} else if err != nil {\n\t\treturn nil, err\n\t}\n\treturn obj, nil\n}\n\nfunc withContext(r *http.Request, ctx map[contextKey]any) *http.Request {\n\tc := r.Context()\n\tfor k, v := range ctx {\n\t\tc = context.WithValue(c, k, v)\n\t}\n\treturn r.WithContext(c)\n}\n\nfunc fromContext[T any](r *http.Request, key contextKey) (T, error) {\n\tt, ok := r.Context().Value(key).(T)\n\tif !ok {\n\t\treturn t, fmt.Errorf(\"cannot find key %v in request context\", key)\n\t}\n\treturn t, nil\n}\n\n// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. \"=?utf-8?q?Hello_World?=\",\n// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader\n// to ignore the new HTTP \"Priority\" header.\nfunc maybeDecodeHeader(name, value string) string {\n\tdecoded, err := mimeDecoder.DecodeHeader(value)\n\tif err != nil {\n\t\treturn maybeIgnoreSpecialHeader(name, value)\n\t}\n\treturn maybeIgnoreSpecialHeader(name, decoded)\n}\n\n// maybeIgnoreSpecialHeader ignores the new HTTP \"Priority\" header (RFC 9218, see https://datatracker.ietf.org/doc/html/rfc9218)\n//\n// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),\n// so we just ignore it. If the \"Priority\" header is set to \"u=*, i\" or \"u=*\" (by Cloudflare), the header will be ignored.\n// Returning an empty string will allow the rest of the logic to continue searching for another header (x-priority, prio, p),\n// or in the Query parameters.\nfunc maybeIgnoreSpecialHeader(name, value string) string {\n\tif strings.ToLower(name) == \"priority\" && priorityHeaderIgnoreRegex.MatchString(strings.TrimSpace(value)) {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n"
  },
  {
    "path": "server/util_test.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestReadBoolParam(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"https://ntfy.sh/mytopic?up=1&firebase=no\", nil)\n\tup := readBoolParam(r, false, \"x-up\", \"up\")\n\tfirebase := readBoolParam(r, true, \"x-firebase\", \"firebase\")\n\trequire.Equal(t, true, up)\n\trequire.Equal(t, false, firebase)\n\n\tr, _ = http.NewRequest(\"GET\", \"https://ntfy.sh/mytopic\", nil)\n\tr.Header.Set(\"X-Up\", \"yes\")\n\tr.Header.Set(\"X-Firebase\", \"0\")\n\tup = readBoolParam(r, false, \"x-up\", \"up\")\n\tfirebase = readBoolParam(r, true, \"x-firebase\", \"firebase\")\n\trequire.Equal(t, true, up)\n\trequire.Equal(t, false, firebase)\n\n\tr, _ = http.NewRequest(\"GET\", \"https://ntfy.sh/mytopic\", nil)\n\tup = readBoolParam(r, false, \"x-up\", \"up\")\n\tfirebase = readBoolParam(r, true, \"x-up\", \"up\")\n\trequire.Equal(t, false, up)\n\trequire.Equal(t, true, firebase)\n}\n\nfunc TestRenderHTTPRequest_ValidShort(t *testing.T) {\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.sh/mytopic?p=2\", strings.NewReader(\"some message\"))\n\tr.Header.Set(\"Title\", \"A title\")\n\texpected := `POST /mytopic?p=2 HTTP/1.1\nTitle: A title\n\nsome message`\n\trequire.Equal(t, expected, renderHTTPRequest(r))\n}\n\nfunc TestRenderHTTPRequest_ValidLong(t *testing.T) {\n\tbody := strings.Repeat(\"a\", 5000)\n\tr, _ := http.NewRequest(\"POST\", \"http://ntfy.sh/mytopic?p=2\", strings.NewReader(body))\n\tr.Header.Set(\"Accept\", \"*/*\")\n\texpected := `POST /mytopic?p=2 HTTP/1.1\nAccept: */*\n\n` + strings.Repeat(\"a\", 4096) + \" ... (peeked 4096 bytes)\"\n\trequire.Equal(t, expected, renderHTTPRequest(r))\n}\n\nfunc TestRenderHTTPRequest_InvalidShort(t *testing.T) {\n\tbody := []byte{0xc3, 0x28}\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", bytes.NewReader(body))\n\tr.Header.Set(\"Accept\", \"*/*\")\n\texpected := `GET /mytopic/json?since=all HTTP/1.1\nAccept: */*\n\n(peeked bytes not UTF-8, 2 bytes, hex: c328)`\n\trequire.Equal(t, expected, renderHTTPRequest(r))\n}\n\nfunc TestRenderHTTPRequest_InvalidLong(t *testing.T) {\n\tbody := make([]byte, 5000)\n\trand.Read(body)\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", bytes.NewReader(body))\n\tr.Header.Set(\"Accept\", \"*/*\")\n\texpected := `GET /mytopic/json?since=all HTTP/1.1\nAccept: */*\n\n(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf(\"%x\", body[:4096]) + ` ...)`\n\trequire.Equal(t, expected, renderHTTPRequest(r))\n}\n\nfunc TestMaybeIgnoreSpecialHeader(t *testing.T) {\n\trequire.Empty(t, maybeIgnoreSpecialHeader(\"priority\", \"u=1\"))\n\trequire.Empty(t, maybeIgnoreSpecialHeader(\"Priority\", \"u=1\"))\n\trequire.Empty(t, maybeIgnoreSpecialHeader(\"Priority\", \"u=1, i\"))\n}\n\nfunc TestMaybeDecodeHeaders(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", nil)\n\tr.Header.Set(\"Priority\", \"u=1\") // Cloudflare priority header\n\tr.Header.Set(\"X-Priority\", \"5\") // ntfy priority header\n\trequire.Equal(t, \"5\", readHeaderParam(r, \"x-priority\", \"priority\", \"p\"))\n}\n\nfunc TestExtractIPAddress(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", nil)\n\tr.RemoteAddr = \"10.0.0.1:1234\"\n\tr.Header.Set(\"X-Forwarded-For\", \"  1.2.3.4  , 5.6.7.8\")\n\tr.Header.Set(\"X-Client-IP\", \"9.10.11.12\")\n\tr.Header.Set(\"X-Real-IP\", \"13.14.15.16, 1.1.1.1\")\n\tr.Header.Set(\"Forwarded\", \"for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1\")\n\n\ttrustedProxies := []netip.Prefix{netip.MustParsePrefix(\"1.1.1.1/32\")}\n\n\trequire.Equal(t, \"5.6.7.8\", extractIPAddress(r, true, \"X-Forwarded-For\", trustedProxies).String())\n\trequire.Equal(t, \"9.10.11.12\", extractIPAddress(r, true, \"X-Client-IP\", trustedProxies).String())\n\trequire.Equal(t, \"13.14.15.16\", extractIPAddress(r, true, \"X-Real-IP\", trustedProxies).String())\n\trequire.Equal(t, \"17.18.19.20\", extractIPAddress(r, true, \"Forwarded\", trustedProxies).String())\n\trequire.Equal(t, \"10.0.0.1\", extractIPAddress(r, false, \"X-Forwarded-For\", trustedProxies).String())\n}\n\nfunc TestExtractIPAddress_UnixSocket(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", nil)\n\tr.RemoteAddr = \"@\"\n\tr.Header.Set(\"X-Forwarded-For\", \"1.2.3.4, 5.6.7.8, 1.1.1.1\")\n\tr.Header.Set(\"Forwarded\", \"by=bla.example.com;for=17.18.19.20\")\n\n\ttrustedProxies := []netip.Prefix{netip.MustParsePrefix(\"1.1.1.1/32\")}\n\n\trequire.Equal(t, \"5.6.7.8\", extractIPAddress(r, true, \"X-Forwarded-For\", trustedProxies).String())\n\trequire.Equal(t, \"17.18.19.20\", extractIPAddress(r, true, \"Forwarded\", trustedProxies).String())\n\trequire.Equal(t, \"0.0.0.0\", extractIPAddress(r, false, \"X-Forwarded-For\", trustedProxies).String())\n}\n\nfunc TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", nil)\n\tr.RemoteAddr = \"[2001:db8:abcd::1]:1234\"\n\tr.Header.Set(\"X-Forwarded-For\", \"1.2.3.4, 2001:db8:abcd::2, 5.6.7.8\")\n\ttrustedProxies := []netip.Prefix{netip.MustParsePrefix(\"1.2.3.0/24\")}\n\trequire.Equal(t, \"5.6.7.8\", extractIPAddress(r, true, \"X-Forwarded-For\", trustedProxies).String())\n}\n\nfunc TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://ntfy.sh/mytopic/json?since=all\", nil)\n\tr.RemoteAddr = \"[2001:db8:abcd::1]:1234\"\n\tr.Header.Set(\"X-Forwarded-For\", \"2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3\")\n\ttrustedProxies := []netip.Prefix{netip.MustParsePrefix(\"2001:db8:aaaa::/48\")}\n\trequire.Equal(t, \"2001:db8:abcd:2::3\", extractIPAddress(r, true, \"X-Forwarded-For\", trustedProxies).String())\n}\n\nfunc TestVisitorID(t *testing.T) {\n\tconfWithDefaults := &Config{\n\t\tVisitorPrefixBitsIPv4: 32,\n\t\tVisitorPrefixBitsIPv6: 64,\n\t}\n\tconfWithShortenedPrefixes := &Config{\n\t\tVisitorPrefixBitsIPv4: 16,\n\t\tVisitorPrefixBitsIPv6: 56,\n\t}\n\tuserWithTier := &user.User{\n\t\tID:   \"u_123\",\n\t\tTier: &user.Tier{},\n\t}\n\trequire.Equal(t, \"ip:1.2.3.4\", visitorID(netip.MustParseAddr(\"1.2.3.4\"), nil, confWithDefaults))\n\trequire.Equal(t, \"ip:2a01:599:b26:2397::\", visitorID(netip.MustParseAddr(\"2a01:599:b26:2397:dbe7:5aa2:95ce:1e83\"), nil, confWithDefaults))\n\trequire.Equal(t, \"ip:2001:db8:25:86::\", visitorID(netip.MustParseAddr(\"2001:db8:25:86:1::1\"), nil, confWithDefaults))\n\trequire.Equal(t, \"ip:2001:db8:25:86::\", visitorID(netip.MustParseAddr(\"2001:db8:25:86:2::1\"), nil, confWithDefaults))\n\n\trequire.Equal(t, \"user:u_123\", visitorID(netip.MustParseAddr(\"1.2.3.4\"), userWithTier, confWithDefaults))\n\trequire.Equal(t, \"user:u_123\", visitorID(netip.MustParseAddr(\"2a01:599:b26:2397:dbe7:5aa2:95ce:1e83\"), userWithTier, confWithDefaults))\n\n\trequire.Equal(t, \"ip:1.2.0.0\", visitorID(netip.MustParseAddr(\"1.2.3.4\"), nil, confWithShortenedPrefixes))\n\trequire.Equal(t, \"ip:2a01:599:b26:2300::\", visitorID(netip.MustParseAddr(\"2a01:599:b26:2397:dbe7:5aa2:95ce:1e83\"), nil, confWithShortenedPrefixes))\n}\n"
  },
  {
    "path": "server/visitor.go",
    "content": "package server\n\nimport (\n\t\"fmt\"\n\t\"net/netip\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/time/rate\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/message\"\n\t\"heckel.io/ntfy/v2/user\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\t// oneDay is an approximation of a day as a time.Duration\n\toneDay = 24 * time.Hour\n\n\t// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number\n\t// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since\n\t// they are replenished faster (typically).\n\tvisitorExpungeAfter = oneDay\n\n\t// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.\n\t// This number is zero, and changing it may have unintended consequences in the web app, or otherwise\n\tvisitorDefaultReservationsLimit = int64(0)\n\n\t// visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make.\n\t// This number is zero, because phone numbers have to be verified first.\n\tvisitorDefaultCallsLimit = int64(0)\n)\n\n// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter\n// values (token bucket). This is only used to increase the values in server.yml, never decrease them.\n//\n// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:\n// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)\n// - the replenish rate is 2 * 10,000 / 24 hours\nconst (\n\tvisitorMessageToRequestLimitBurstRate       = 0.05\n\tvisitorMessageToRequestLimitBurstMax        = 1000\n\tvisitorMessageToRequestLimitReplenishFactor = 2\n)\n\n// Constants used to convert a tier-user's EmailLimit (see user.Tier) into adequate email limiter\n// values (token bucket). Example: Assuming a user.Tier's EmailLimit is 200, the allowed burst is\n// 40 (= 200 * 20%), which is <150 (the max).\nconst (\n\tvisitorEmailLimitBurstRate = 0.2\n\tvisitorEmailLimitBurstMax  = 150\n)\n\n// visitor represents an API user, and its associated rate.Limiter used for rate limiting\ntype visitor struct {\n\tconfig              *Config\n\tmessageCache        *message.Cache\n\tuserManager         *user.Manager      // May be nil\n\tip                  netip.Addr         // Visitor IP address\n\tuser                *user.User         // Only set if authenticated user, otherwise nil\n\trequestLimiter      *rate.Limiter      // Rate limiter for (almost) all requests (including messages)\n\tmessagesLimiter     *util.FixedLimiter // Rate limiter for messages\n\temailsLimiter       *util.RateLimiter  // Rate limiter for emails\n\tcallsLimiter        *util.FixedLimiter // Rate limiter for calls\n\tsubscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)\n\tbandwidthLimiter    *util.RateLimiter  // Limiter for attachment bandwidth downloads\n\taccountLimiter      *rate.Limiter      // Rate limiter for account creation, may be nil\n\tauthLimiter         *rate.Limiter      // Limiter for incorrect login attempts, may be nil\n\tfirebase            time.Time          // Next allowed Firebase message\n\tseen                time.Time          // Last seen time of this visitor (needed for removal of stale visitors)\n\tmu                  sync.RWMutex\n}\n\ntype visitorInfo struct {\n\tLimits *visitorLimits\n\tStats  *visitorStats\n}\n\ntype visitorLimits struct {\n\tBasis                    visitorLimitBasis\n\tRequestLimitBurst        int\n\tRequestLimitReplenish    rate.Limit\n\tMessageLimit             int64\n\tMessageExpiryDuration    time.Duration\n\tEmailLimit               int64\n\tEmailLimitBurst          int\n\tEmailLimitReplenish      rate.Limit\n\tCallLimit                int64\n\tReservationsLimit        int64\n\tAttachmentTotalSizeLimit int64\n\tAttachmentFileSizeLimit  int64\n\tAttachmentExpiryDuration time.Duration\n\tAttachmentBandwidthLimit int64\n}\n\ntype visitorStats struct {\n\tMessages                     int64\n\tMessagesRemaining            int64\n\tEmails                       int64\n\tEmailsRemaining              int64\n\tCalls                        int64\n\tCallsRemaining               int64\n\tReservations                 int64\n\tReservationsRemaining        int64\n\tAttachmentTotalSize          int64\n\tAttachmentTotalSizeRemaining int64\n}\n\n// visitorLimitBasis describes how the visitor limits were derived, either from a user's\n// IP address (default config), or from its tier\ntype visitorLimitBasis string\n\nconst (\n\tvisitorLimitBasisIP   = visitorLimitBasis(\"ip\")\n\tvisitorLimitBasisTier = visitorLimitBasis(\"tier\")\n)\n\nfunc newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {\n\tvar messages, emails, calls int64\n\tif user != nil {\n\t\tmessages = user.Stats.Messages\n\t\temails = user.Stats.Emails\n\t\tcalls = user.Stats.Calls\n\t}\n\tv := &visitor{\n\t\tconfig:              conf,\n\t\tmessageCache:        messageCache,\n\t\tuserManager:         userManager, // May be nil\n\t\tip:                  ip,\n\t\tuser:                user,\n\t\tfirebase:            time.Unix(0, 0),\n\t\tseen:                time.Now(),\n\t\tsubscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),\n\t\trequestLimiter:      nil, // Set in resetLimiters\n\t\tmessagesLimiter:     nil, // Set in resetLimiters, may be nil\n\t\temailsLimiter:       nil, // Set in resetLimiters\n\t\tcallsLimiter:        nil, // Set in resetLimiters, may be nil\n\t\tbandwidthLimiter:    nil, // Set in resetLimiters\n\t\taccountLimiter:      nil, // Set in resetLimiters, may be nil\n\t\tauthLimiter:         nil, // Set in resetLimiters, may be nil\n\t}\n\tv.resetLimitersNoLock(messages, emails, calls, false)\n\treturn v\n}\n\nfunc (v *visitor) Context() log.Context {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn v.contextNoLock()\n}\n\nfunc (v *visitor) contextNoLock() log.Context {\n\tinfo := v.infoLightNoLock()\n\tfields := log.Context{\n\t\t\"visitor_id\":                     visitorID(v.ip, v.user, v.config),\n\t\t\"visitor_ip\":                     v.ip.String(),\n\t\t\"visitor_seen\":                   util.FormatTime(v.seen),\n\t\t\"visitor_messages\":               info.Stats.Messages,\n\t\t\"visitor_messages_limit\":         info.Limits.MessageLimit,\n\t\t\"visitor_messages_remaining\":     info.Stats.MessagesRemaining,\n\t\t\"visitor_request_limiter_limit\":  v.requestLimiter.Limit(),\n\t\t\"visitor_request_limiter_tokens\": v.requestLimiter.Tokens(),\n\t}\n\tif v.config.SMTPSenderFrom != \"\" {\n\t\tfields[\"visitor_emails\"] = info.Stats.Emails\n\t\tfields[\"visitor_emails_limit\"] = info.Limits.EmailLimit\n\t\tfields[\"visitor_emails_remaining\"] = info.Stats.EmailsRemaining\n\t}\n\tif v.config.TwilioAccount != \"\" {\n\t\tfields[\"visitor_calls\"] = info.Stats.Calls\n\t\tfields[\"visitor_calls_limit\"] = info.Limits.CallLimit\n\t\tfields[\"visitor_calls_remaining\"] = info.Stats.CallsRemaining\n\t}\n\tif v.authLimiter != nil {\n\t\tfields[\"visitor_auth_limiter_limit\"] = v.authLimiter.Limit()\n\t\tfields[\"visitor_auth_limiter_tokens\"] = v.authLimiter.Tokens()\n\t}\n\tif v.user != nil {\n\t\tfields[\"user_id\"] = v.user.ID\n\t\tfields[\"user_name\"] = v.user.Name\n\t\tif v.user.Tier != nil {\n\t\t\tfor field, value := range v.user.Tier.Context() {\n\t\t\t\tfields[field] = value\n\t\t\t}\n\t\t}\n\t\tif v.user.Billing.StripeCustomerID != \"\" {\n\t\t\tfields[\"stripe_customer_id\"] = v.user.Billing.StripeCustomerID\n\t\t}\n\t\tif v.user.Billing.StripeSubscriptionID != \"\" {\n\t\t\tfields[\"stripe_subscription_id\"] = v.user.Billing.StripeSubscriptionID\n\t\t}\n\t}\n\treturn fields\n}\n\nfunc visitorExtendedInfoContext(info *visitorInfo) log.Context {\n\treturn log.Context{\n\t\t\"visitor_reservations\":                    info.Stats.Reservations,\n\t\t\"visitor_reservations_limit\":              info.Limits.ReservationsLimit,\n\t\t\"visitor_reservations_remaining\":          info.Stats.ReservationsRemaining,\n\t\t\"visitor_attachment_total_size\":           info.Stats.AttachmentTotalSize,\n\t\t\"visitor_attachment_total_size_limit\":     info.Limits.AttachmentTotalSizeLimit,\n\t\t\"visitor_attachment_total_size_remaining\": info.Stats.AttachmentTotalSizeRemaining,\n\t}\n\n}\nfunc (v *visitor) RequestAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.requestLimiter.Allow()\n}\n\nfunc (v *visitor) FirebaseAllowed() bool {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn !time.Now().Before(v.firebase)\n}\n\nfunc (v *visitor) FirebaseTemporarilyDeny() {\n\tv.mu.Lock()\n\tdefer v.mu.Unlock()\n\tv.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)\n}\n\nfunc (v *visitor) MessageAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.messagesLimiter.Allow()\n}\n\nfunc (v *visitor) EmailAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.emailsLimiter.Allow()\n}\n\nfunc (v *visitor) CallAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.callsLimiter.Allow()\n}\n\nfunc (v *visitor) SubscriptionAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.subscriptionLimiter.Allow()\n}\n\n// AuthAllowed returns true if an auth request can be attempted (> 1 token available)\nfunc (v *visitor) AuthAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\tif v.authLimiter == nil {\n\t\treturn true\n\t}\n\treturn v.authLimiter.Tokens() > 1\n}\n\n// AuthFailed records an auth failure\nfunc (v *visitor) AuthFailed() {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\tif v.authLimiter != nil {\n\t\tv.authLimiter.Allow()\n\t}\n}\n\n// AccountCreationAllowed returns true if a new account can be created\nfunc (v *visitor) AccountCreationAllowed() bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\tif v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// AccountCreated decreases the account limiter. This is to be called after an account was created.\nfunc (v *visitor) AccountCreated() {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\tif v.accountLimiter != nil {\n\t\tv.accountLimiter.Allow()\n\t}\n}\n\nfunc (v *visitor) BandwidthAllowed(bytes int64) bool {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.bandwidthLimiter.AllowN(bytes)\n}\n\nfunc (v *visitor) RemoveSubscription() {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\tv.subscriptionLimiter.AllowN(-1)\n}\n\nfunc (v *visitor) Keepalive() {\n\tv.mu.Lock()\n\tdefer v.mu.Unlock()\n\tv.seen = time.Now()\n}\n\nfunc (v *visitor) BandwidthLimiter() util.Limiter {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn v.bandwidthLimiter\n}\n\nfunc (v *visitor) Stale() bool {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn time.Since(v.seen) > visitorExpungeAfter\n}\n\nfunc (v *visitor) Stats() *user.Stats {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\treturn &user.Stats{\n\t\tMessages: v.messagesLimiter.Value(),\n\t\tEmails:   v.emailsLimiter.Value(),\n\t\tCalls:    v.callsLimiter.Value(),\n\t}\n}\n\nfunc (v *visitor) ResetStats() {\n\tv.mu.RLock() // limiters could be replaced!\n\tdefer v.mu.RUnlock()\n\tv.emailsLimiter.Reset()\n\tv.messagesLimiter.Reset()\n\tv.callsLimiter.Reset()\n}\n\n// User returns the visitor user, or nil if there is none\nfunc (v *visitor) User() *user.User {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn v.user // May be nil\n}\n\n// IP returns the visitor IP address\nfunc (v *visitor) IP() netip.Addr {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn v.ip\n}\n\n// Authenticated returns true if a user successfully authenticated\nfunc (v *visitor) Authenticated() bool {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn v.user != nil\n}\n\n// SetUser sets the visitors user to the given value\nfunc (v *visitor) SetUser(u *user.User) {\n\tv.mu.Lock()\n\tdefer v.mu.Unlock()\n\tshouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver\n\tv.user = u                                           // u may be nil!\n\tif shouldResetLimiters {\n\t\tvar messages, emails, calls int64\n\t\tif u != nil {\n\t\t\tmessages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls\n\t\t}\n\t\tv.resetLimitersNoLock(messages, emails, calls, true)\n\t}\n}\n\n// MaybeUserID returns the user ID of the visitor (if any). If this is an anonymous visitor,\n// an empty string is returned.\nfunc (v *visitor) MaybeUserID() string {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\tif v.user != nil {\n\t\treturn v.user.ID\n\t}\n\treturn \"\"\n}\n\nfunc (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {\n\tlimits := v.limitsNoLock()\n\tv.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)\n\tv.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)\n\tv.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)\n\tv.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)\n\tv.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)\n\tif v.user == nil {\n\t\tv.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)\n\t\tv.authLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAuthFailureLimitReplenish), v.config.VisitorAuthFailureLimitBurst)\n\t} else {\n\t\tv.accountLimiter = nil // Users cannot create accounts when logged in\n\t\tv.authLimiter = nil    // Users are already logged in, no need to limit requests\n\t}\n\tif enqueueUpdate && v.user != nil {\n\t\tgo v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{\n\t\t\tMessages: messages,\n\t\t\tEmails:   emails,\n\t\t\tCalls:    calls,\n\t\t})\n\t}\n\tlog.Fields(v.contextNoLock()).Debug(\"Rate limiters reset for visitor\") // Must be after function, because contextNoLock() describes rate limiters\n}\n\nfunc (v *visitor) Limits() *visitorLimits {\n\tv.mu.RLock()\n\tdefer v.mu.RUnlock()\n\treturn v.limitsNoLock()\n}\n\nfunc (v *visitor) limitsNoLock() *visitorLimits {\n\tif v.user != nil && v.user.Tier != nil {\n\t\treturn tierBasedVisitorLimits(v.config, v.user.Tier)\n\t}\n\treturn configBasedVisitorLimits(v.config)\n}\n\nfunc tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {\n\treturn &visitorLimits{\n\t\tBasis:                    visitorLimitBasisTier,\n\t\tRequestLimitBurst:        util.MinMax(int(float64(tier.MessageLimit)*visitorMessageToRequestLimitBurstRate), conf.VisitorRequestLimitBurst, visitorMessageToRequestLimitBurstMax),\n\t\tRequestLimitReplenish:    util.Max(rate.Every(conf.VisitorRequestLimitReplenish), dailyLimitToRate(tier.MessageLimit*visitorMessageToRequestLimitReplenishFactor)),\n\t\tMessageLimit:             tier.MessageLimit,\n\t\tMessageExpiryDuration:    tier.MessageExpiryDuration,\n\t\tEmailLimit:               tier.EmailLimit,\n\t\tEmailLimitBurst:          util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),\n\t\tEmailLimitReplenish:      dailyLimitToRate(tier.EmailLimit),\n\t\tCallLimit:                tier.CallLimit,\n\t\tReservationsLimit:        tier.ReservationLimit,\n\t\tAttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,\n\t\tAttachmentFileSizeLimit:  tier.AttachmentFileSizeLimit,\n\t\tAttachmentExpiryDuration: tier.AttachmentExpiryDuration,\n\t\tAttachmentBandwidthLimit: tier.AttachmentBandwidthLimit,\n\t}\n}\n\nfunc configBasedVisitorLimits(conf *Config) *visitorLimits {\n\tmessagesLimit := replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish) // Approximation!\n\tif conf.VisitorMessageDailyLimit > 0 {\n\t\tmessagesLimit = int64(conf.VisitorMessageDailyLimit)\n\t}\n\treturn &visitorLimits{\n\t\tBasis:                    visitorLimitBasisIP,\n\t\tRequestLimitBurst:        conf.VisitorRequestLimitBurst,\n\t\tRequestLimitReplenish:    rate.Every(conf.VisitorRequestLimitReplenish),\n\t\tMessageLimit:             messagesLimit,\n\t\tMessageExpiryDuration:    conf.CacheDuration,\n\t\tEmailLimit:               replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!\n\t\tEmailLimitBurst:          conf.VisitorEmailLimitBurst,\n\t\tEmailLimitReplenish:      rate.Every(conf.VisitorEmailLimitReplenish),\n\t\tCallLimit:                visitorDefaultCallsLimit,\n\t\tReservationsLimit:        visitorDefaultReservationsLimit,\n\t\tAttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,\n\t\tAttachmentFileSizeLimit:  conf.AttachmentFileSizeLimit,\n\t\tAttachmentExpiryDuration: conf.AttachmentExpiryDuration,\n\t\tAttachmentBandwidthLimit: conf.VisitorAttachmentDailyBandwidthLimit,\n\t}\n}\n\nfunc (v *visitor) Info() (*visitorInfo, error) {\n\tv.mu.RLock()\n\tinfo := v.infoLightNoLock()\n\tv.mu.RUnlock()\n\n\t// Attachment stats from database\n\tvar attachmentsBytesUsed int64\n\tvar err error\n\tu := v.User()\n\tif u != nil {\n\t\tattachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(u.ID)\n\t} else {\n\t\tattachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.IP().String())\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tinfo.Stats.AttachmentTotalSize = attachmentsBytesUsed\n\tinfo.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed)\n\n\t// Reservation stats from database\n\tvar reservations int64\n\tif v.userManager != nil && u != nil {\n\t\treservations, err = v.userManager.ReservationsCount(u.Name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tinfo.Stats.Reservations = reservations\n\tinfo.Stats.ReservationsRemaining = zeroIfNegative(info.Limits.ReservationsLimit - reservations)\n\n\treturn info, nil\n}\n\nfunc (v *visitor) infoLightNoLock() *visitorInfo {\n\tmessages := v.messagesLimiter.Value()\n\temails := v.emailsLimiter.Value()\n\tcalls := v.callsLimiter.Value()\n\tlimits := v.limitsNoLock()\n\tstats := &visitorStats{\n\t\tMessages:          messages,\n\t\tMessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),\n\t\tEmails:            emails,\n\t\tEmailsRemaining:   zeroIfNegative(limits.EmailLimit - emails),\n\t\tCalls:             calls,\n\t\tCallsRemaining:    zeroIfNegative(limits.CallLimit - calls),\n\t}\n\treturn &visitorInfo{\n\t\tLimits: limits,\n\t\tStats:  stats,\n\t}\n}\nfunc zeroIfNegative(value int64) int64 {\n\tif value < 0 {\n\t\treturn 0\n\t}\n\treturn value\n}\n\nfunc replenishDurationToDailyLimit(duration time.Duration) int64 {\n\treturn int64(oneDay / duration)\n}\n\nfunc dailyLimitToRate(limit int64) rate.Limit {\n\treturn rate.Limit(limit) * rate.Every(oneDay)\n}\n\n// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6\nfunc visitorID(ip netip.Addr, u *user.User, conf *Config) string {\n\tif u != nil && u.Tier != nil {\n\t\treturn fmt.Sprintf(\"user:%s\", u.ID)\n\t}\n\tif ip.Is4() {\n\t\tip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked().Addr()\n\t} else if ip.Is6() {\n\t\tip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked().Addr()\n\t}\n\treturn fmt.Sprintf(\"ip:%s\", ip.String())\n}\n"
  },
  {
    "path": "test/server.go",
    "content": "package test\n\nimport (\n\t\"fmt\"\n\t\"heckel.io/ntfy/v2/server\"\n\t\"net\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\n// StartServer starts a server.Server with a random port and waits for the server to be up\nfunc StartServer(t *testing.T) (*server.Server, int) {\n\treturn StartServerWithConfig(t, server.NewConfig())\n}\n\n// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up\nfunc StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {\n\tport := findAvailablePort(t)\n\tconf.ListenHTTP = fmt.Sprintf(\":%d\", port)\n\tconf.AttachmentCacheDir = t.TempDir()\n\tconf.CacheFile = filepath.Join(t.TempDir(), \"cache.db\")\n\ts, err := server.New(conf)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgo func() {\n\t\tif err := s.Run(); err != nil && err != http.ErrServerClosed {\n\t\t\tpanic(err) // 'go vet' complains about 't.Fatal(err)'\n\t\t}\n\t}()\n\tWaitForPortUp(t, port)\n\treturn s, port\n}\n\n// findAvailablePort asks the OS for a free port by binding to :0\nfunc findAvailablePort(t *testing.T) int {\n\tlistener, err := net.Listen(\"tcp\", \":0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tport := listener.Addr().(*net.TCPAddr).Port\n\tlistener.Close()\n\treturn port\n}\n\n// StopServer stops the test server and waits for the port to be down\nfunc StopServer(t *testing.T, s *server.Server, port int) {\n\ts.Stop()\n\tWaitForPortDown(t, port)\n}\n"
  },
  {
    "path": "test/test.go",
    "content": "// Package test provides test helpers for unit and integration tests.\n// This code is not meant to be used outside of tests.\npackage test\n"
  },
  {
    "path": "test/util.go",
    "content": "package test\n\nimport (\n\t\"net\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n)\n\n// WaitForPortUp waits up to 7s for a port to come up and fails t if that fails\nfunc WaitForPortUp(t *testing.T, port int) {\n\tsuccess := false\n\tfor i := 0; i < 500; i++ {\n\t\tstartTime := time.Now()\n\t\tconn, _ := net.DialTimeout(\"tcp\", net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(port)), 10*time.Millisecond)\n\t\tif conn != nil {\n\t\t\tsuccess = true\n\t\t\tconn.Close()\n\t\t\tbreak\n\t\t}\n\t\tif time.Since(startTime) < 10*time.Millisecond {\n\t\t\ttime.Sleep(10*time.Millisecond - time.Since(startTime))\n\t\t}\n\t}\n\tif !success {\n\t\tt.Fatalf(\"Failed waiting for port %d to be UP\", port)\n\t}\n}\n\n// WaitForPortDown waits up to 5s for a port to come down and fails t if that fails\nfunc WaitForPortDown(t *testing.T, port int) {\n\tsuccess := false\n\tfor i := 0; i < 100; i++ {\n\t\tconn, _ := net.DialTimeout(\"tcp\", net.JoinHostPort(\"\", strconv.Itoa(port)), 50*time.Millisecond)\n\t\tif conn == nil {\n\t\t\tsuccess = true\n\t\t\tbreak\n\t\t}\n\t\tconn.Close()\n\t}\n\tif !success {\n\t\tt.Fatalf(\"Failed waiting for port %d to be DOWN\", port)\n\t}\n}\n"
  },
  {
    "path": "tools/fbsend/README.md",
    "content": "# fbsend\nfbsend is a tiny tool to send data messages to Firebase. It's only used for testing.\n"
  },
  {
    "path": "tools/fbsend/main.go",
    "content": "//go:build !nofirebase\n\npackage main\n\nimport (\n\t\"context\"\n\tfirebase \"firebase.google.com/go/v4\"\n\t\"firebase.google.com/go/v4/messaging\"\n\t\"flag\"\n\t\"fmt\"\n\t\"google.golang.org/api/option\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc main() {\n\tconffile := flag.String(\"config\", \"/etc/fbsend/fbsend.json\", \"config file\")\n\tflag.Parse()\n\tif flag.NArg() < 2 {\n\t\tfail(\"Syntax: fbsend [-config FILE] topic key=value ...\")\n\t}\n\ttopic := flag.Arg(0)\n\tdata := make(map[string]string)\n\tfor i := 1; i < flag.NArg(); i++ {\n\t\tkv := strings.SplitN(flag.Arg(i), \"=\", 2)\n\t\tif len(kv) != 2 {\n\t\t\tfail(fmt.Sprintf(\"Invalid argument: %s (%v)\", flag.Arg(i), kv))\n\t\t}\n\t\tdata[kv[0]] = kv[1]\n\t}\n\tfb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, *conffile))\n\tif err != nil {\n\t\tfail(err.Error())\n\t}\n\tmsg, err := fb.Messaging(context.Background())\n\tif err != nil {\n\t\tfail(err.Error())\n\t}\n\t_, err = msg.Send(context.Background(), &messaging.Message{\n\t\tTopic: topic,\n\t\tData:  data,\n\t})\n\tif err != nil {\n\t\tfail(err.Error())\n\t}\n\tfmt.Println(\"Sent successfully\")\n}\n\nfunc fail(s string) {\n\tfmt.Println(s)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "tools/loadgen/main.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n)\n\nfunc main() {\n\tbaseURL := \"https://staging.ntfy.sh\"\n\tif len(os.Args) > 1 {\n\t\tbaseURL = os.Args[1]\n\t}\n\tfor i := 0; i < 2000; i++ {\n\t\tgo subscribe(i, baseURL)\n\t}\n\ttime.Sleep(5 * time.Second)\n\tfor i := 0; i < 2000; i++ {\n\t\tgo func(worker int) {\n\t\t\tfor {\n\t\t\t\tpoll(worker, baseURL)\n\t\t\t}\n\t\t}(i)\n\t}\n\ttime.Sleep(time.Hour)\n}\n\nfunc subscribe(worker int, baseURL string) {\n\tfmt.Printf(\"[subscribe] worker=%d STARTING\\n\", worker)\n\tstart := time.Now()\n\ttopic, ip := fmt.Sprintf(\"subtopic%d\", worker), fmt.Sprintf(\"1.2.%d.%d\", (worker/255)%255, worker%255)\n\treq, _ := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/%s/json\", baseURL, topic), nil)\n\treq.Header.Set(\"X-Forwarded-For\", ip)\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"[subscribe] worker=%d time=%d error=%s\\n\", worker, time.Since(start).Milliseconds(), err.Error())\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tscanner := bufio.NewScanner(resp.Body)\n\tfor scanner.Scan() {\n\t\t// Do nothing\n\t}\n\tfmt.Printf(\"[subscribe] worker=%d status=%d time=%d EXITED\\n\", worker, resp.StatusCode, time.Since(start).Milliseconds())\n}\n\nfunc poll(worker int, baseURL string) {\n\tfmt.Printf(\"[poll] worker=%d STARTING\\n\", worker)\n\ttopic, ip := fmt.Sprintf(\"polltopic%d\", worker), fmt.Sprintf(\"1.2.%d.%d\", (worker/255)%255, worker%255)\n\tstart := time.Now()\n\tctx, cancel := context.WithTimeout(context.Background(), time.Second*2)\n\tdefer cancel()\n\n\t//req, _ := http.NewRequestWithContext(ctx, \"GET\", fmt.Sprintf(\"https://staging.ntfy.sh/%s/json?poll=1&since=all\", topic), nil)\n\treq, _ := http.NewRequestWithContext(ctx, \"GET\", fmt.Sprintf(\"%s/%s/json?poll=1&since=all\", baseURL, topic), nil)\n\treq.Header.Set(\"X-Forwarded-For\", ip)\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"[poll] worker=%d time=%d status=- error=%s\\n\", worker, time.Since(start).Milliseconds(), err.Error())\n\t\tcancel()\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tfmt.Printf(\"[poll] worker=%d time=%d status=%s\\n\", worker, time.Since(start).Milliseconds(), resp.Status)\n}\n"
  },
  {
    "path": "tools/loadtest/go.mod",
    "content": "module loadtest\n\ngo 1.25.2\n\nrequire github.com/gorilla/websocket v1.5.3\n"
  },
  {
    "path": "tools/loadtest/go.sum",
    "content": "github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\n"
  },
  {
    "path": "tools/loadtest/main.go",
    "content": "// Load test program for ntfy staging server.\n// Replicates production traffic patterns derived from access.log analysis.\n//\n// Traffic profile (from ~5M requests over 20 hours):\n//   ~71 req/sec average, ~4,300 req/min\n//   49.6% poll requests      (GET /TOPIC/json?poll=1&since=ID)\n//   21.4% publish POST       (POST /TOPIC with small body)\n//    6.2% subscribe stream   (GET /TOPIC/json?since=X, long-lived)\n//    4.1% config check       (GET /v1/config)\n//    2.3% other topic GET    (GET /TOPIC)\n//    2.2% account check      (GET /v1/account)\n//    1.9% websocket sub      (GET /TOPIC/ws?since=X)\n//    1.5% publish PUT        (PUT /TOPIC with small body)\n//    1.5% raw subscribe      (GET /TOPIC/raw?since=X)\n//    1.1% json subscribe     (GET /TOPIC/json, no since)\n//    0.7% SSE subscribe      (GET /TOPIC/sse?since=X)\n//    remaining: static, PATCH, OPTIONS, etc. (omitted)\n\npackage main\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"math/big\"\n\tmrand \"math/rand\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\nvar (\n\tbaseURL    string\n\tusername   string\n\tpassword   string\n\trps        float64\n\tscale      float64\n\tnumTopics  int\n\tsubStreams int\n\twsStreams  int\n\tsseStreams int\n\trawStreams int\n\tduration   time.Duration\n\n\ttotalRequests atomic.Int64\n\ttotalErrors   atomic.Int64\n\tactiveStreams atomic.Int64\n\n\t// Error tracking by category\n\terrMu        sync.Mutex\n\trecentErrors []string // last N unique error messages\n\terrorCounts  = make(map[string]int64)\n)\n\nfunc main() {\n\tflag.StringVar(&baseURL, \"url\", \"https://staging.ntfy.sh\", \"Base URL of ntfy server\")\n\tflag.StringVar(&username, \"user\", \"\", \"Username for authentication\")\n\tflag.StringVar(&password, \"pass\", \"\", \"Password for authentication\")\n\tflag.Float64Var(&rps, \"rps\", 71, \"Target requests per second (default: prod average)\")\n\tflag.Float64Var(&scale, \"scale\", 1.0, \"Scale factor for all load (0.5 = half load, 2.0 = double)\")\n\tflag.IntVar(&numTopics, \"topics\", 500, \"Number of unique topics to use\")\n\tflag.IntVar(&subStreams, \"sub-streams\", 200, \"Number of concurrent JSON streaming subscriptions\")\n\tflag.IntVar(&wsStreams, \"ws-streams\", 50, \"Number of concurrent WebSocket subscriptions\")\n\tflag.IntVar(&sseStreams, \"sse-streams\", 20, \"Number of concurrent SSE subscriptions\")\n\tflag.IntVar(&rawStreams, \"raw-streams\", 30, \"Number of concurrent raw subscriptions\")\n\tflag.DurationVar(&duration, \"duration\", 10*time.Minute, \"Test duration\")\n\tflag.Parse()\n\n\trps *= scale\n\tsubStreams = int(float64(subStreams) * scale)\n\twsStreams = int(float64(wsStreams) * scale)\n\tsseStreams = int(float64(sseStreams) * scale)\n\trawStreams = int(float64(rawStreams) * scale)\n\n\ttopics := generateTopics(numTopics)\n\n\tfmt.Printf(\"ntfy load test\\n\")\n\tfmt.Printf(\"  Target:       %s\\n\", baseURL)\n\tfmt.Printf(\"  RPS:          %.1f\\n\", rps)\n\tfmt.Printf(\"  Scale:        %.1fx\\n\", scale)\n\tfmt.Printf(\"  Topics:       %d\\n\", numTopics)\n\tfmt.Printf(\"  Sub streams:  %d json, %d ws, %d sse, %d raw\\n\", subStreams, wsStreams, sseStreams, rawStreams)\n\tfmt.Printf(\"  Duration:     %s\\n\", duration)\n\tfmt.Println()\n\n\tctx, cancel := context.WithTimeout(context.Background(), duration)\n\tdefer cancel()\n\n\t// Also handle Ctrl+C\n\tsigCtx, sigCancel := signal.NotifyContext(ctx, os.Interrupt)\n\tdefer sigCancel()\n\tctx = sigCtx\n\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        1000,\n\t\t\tMaxIdleConnsPerHost: 1000,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t},\n\t}\n\n\t// Long-lived streaming client (no timeout)\n\tstreamClient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        500,\n\t\t\tMaxIdleConnsPerHost: 500,\n\t\t\tIdleConnTimeout:     0,\n\t\t},\n\t}\n\n\tvar wg sync.WaitGroup\n\n\t// Start long-lived streaming subscriptions\n\tfor i := 0; i < subStreams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tstreamSubscription(ctx, streamClient, topics, \"json\")\n\t\t}()\n\t}\n\tfor i := 0; i < wsStreams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\twsSubscription(ctx, topics)\n\t\t}()\n\t}\n\tfor i := 0; i < sseStreams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tstreamSubscription(ctx, streamClient, topics, \"sse\")\n\t\t}()\n\t}\n\tfor i := 0; i < rawStreams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tstreamSubscription(ctx, streamClient, topics, \"raw\")\n\t\t}()\n\t}\n\n\t// Start request generators based on traffic weights\n\t// Weights from log analysis (normalized to sum ~100):\n\t//   poll=49.6, publish_post=21.4, config=4.1, other_get=2.3, account=2.2, publish_put=1.5\n\t//   Total short-lived weight ≈ 81.1\n\ttype requestType struct {\n\t\tname   string\n\t\tweight float64\n\t\tfn     func(ctx context.Context, client *http.Client, topics []string)\n\t}\n\n\ttypes := []requestType{\n\t\t{\"poll\", 49.6, doPoll},\n\t\t{\"publish_post\", 21.4, doPublishPost},\n\t\t{\"config\", 4.1, doConfig},\n\t\t{\"other_get\", 2.3, doOtherGet},\n\t\t{\"account\", 2.2, doAccountCheck},\n\t\t{\"publish_put\", 1.5, doPublishPut},\n\t}\n\n\ttotalWeight := 0.0\n\tfor _, t := range types {\n\t\ttotalWeight += t.weight\n\t}\n\n\tfor _, t := range types {\n\t\tt := t\n\t\ttypeRPS := rps * (t.weight / totalWeight)\n\t\tif typeRPS < 0.1 {\n\t\t\tcontinue\n\t\t}\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\trunAtRate(ctx, typeRPS, func() {\n\t\t\t\tt.fn(ctx, client, topics)\n\t\t\t})\n\t\t}()\n\t}\n\n\t// Stats reporter\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\treportStats(ctx)\n\t}()\n\n\twg.Wait()\n\tfmt.Printf(\"\\nDone. Total requests: %d, errors: %d\\n\", totalRequests.Load(), totalErrors.Load())\n}\n\nfunc trackError(category string, err error) {\n\ttotalErrors.Add(1)\n\tkey := fmt.Sprintf(\"%s: %s\", category, truncateErr(err))\n\terrMu.Lock()\n\terrorCounts[key]++\n\terrMu.Unlock()\n}\n\nfunc trackErrorMsg(category string, msg string) {\n\ttotalErrors.Add(1)\n\tkey := fmt.Sprintf(\"%s: %s\", category, msg)\n\terrMu.Lock()\n\terrorCounts[key]++\n\terrMu.Unlock()\n}\n\nfunc truncateErr(err error) string {\n\ts := err.Error()\n\tif len(s) > 120 {\n\t\ts = s[:120] + \"...\"\n\t}\n\treturn s\n}\n\nfunc setAuth(req *http.Request) {\n\tif username != \"\" && password != \"\" {\n\t\treq.SetBasicAuth(username, password)\n\t}\n}\n\nfunc generateTopics(n int) []string {\n\ttopics := make([]string, n)\n\tfor i := 0; i < n; i++ {\n\t\tb := make([]byte, 8)\n\t\trand.Read(b)\n\t\ttopics[i] = \"loadtest-\" + hex.EncodeToString(b)\n\t}\n\treturn topics\n}\n\nfunc pickTopic(topics []string) string {\n\tn, _ := rand.Int(rand.Reader, big.NewInt(int64(len(topics))))\n\treturn topics[n.Int64()]\n}\n\nfunc randomSince() string {\n\tb := make([]byte, 6)\n\trand.Read(b)\n\treturn hex.EncodeToString(b)\n}\n\nfunc randomMessage() string {\n\tmessages := []string{\n\t\t\"Test notification\",\n\t\t\"Server backup completed successfully\",\n\t\t\"Deployment finished\",\n\t\t\"Alert: disk usage above 80%\",\n\t\t\"Build #1234 passed\",\n\t\t\"New order received\",\n\t\t\"Temperature sensor reading: 72F\",\n\t\t\"Cron job completed\",\n\t}\n\treturn messages[mrand.Intn(len(messages))]\n}\n\n// runAtRate executes fn at approximately the given rate per second\nfunc runAtRate(ctx context.Context, rate float64, fn func()) {\n\tinterval := time.Duration(float64(time.Second) / rate)\n\tticker := time.NewTicker(interval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tgo fn()\n\t\t}\n\t}\n}\n\n// --- Short-lived request types ---\n\nfunc doPoll(ctx context.Context, client *http.Client, topics []string) {\n\ttopic := pickTopic(topics)\n\turl := fmt.Sprintf(\"%s/%s/json?poll=1&since=%s\", baseURL, topic, randomSince())\n\tdoGet(ctx, client, url)\n}\n\nfunc doPublishPost(ctx context.Context, client *http.Client, topics []string) {\n\ttopic := pickTopic(topics)\n\turl := fmt.Sprintf(\"%s/%s\", baseURL, topic)\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", url, strings.NewReader(randomMessage()))\n\tif err != nil {\n\t\ttrackError(\"publish_post_req\", err)\n\t\treturn\n\t}\n\tsetAuth(req)\n\t// Some messages have titles/priorities like real traffic\n\tif mrand.Float32() < 0.3 {\n\t\treq.Header.Set(\"X-Title\", \"Load Test\")\n\t}\n\tif mrand.Float32() < 0.1 {\n\t\treq.Header.Set(\"X-Priority\", fmt.Sprintf(\"%d\", mrand.Intn(5)+1))\n\t}\n\tresp, err := client.Do(req)\n\ttotalRequests.Add(1)\n\tif err != nil {\n\t\ttrackError(\"publish_post\", err)\n\t\treturn\n\t}\n\tio.Copy(io.Discard, resp.Body)\n\tresp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\ttrackErrorMsg(\"publish_post_http\", fmt.Sprintf(\"status %d\", resp.StatusCode))\n\t}\n}\n\nfunc doPublishPut(ctx context.Context, client *http.Client, topics []string) {\n\ttopic := pickTopic(topics)\n\turl := fmt.Sprintf(\"%s/%s\", baseURL, topic)\n\treq, err := http.NewRequestWithContext(ctx, \"PUT\", url, strings.NewReader(randomMessage()))\n\tif err != nil {\n\t\ttrackError(\"publish_put_req\", err)\n\t\treturn\n\t}\n\tsetAuth(req)\n\tresp, err := client.Do(req)\n\ttotalRequests.Add(1)\n\tif err != nil {\n\t\ttrackError(\"publish_put\", err)\n\t\treturn\n\t}\n\tio.Copy(io.Discard, resp.Body)\n\tresp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\ttrackErrorMsg(\"publish_put_http\", fmt.Sprintf(\"status %d\", resp.StatusCode))\n\t}\n}\n\nfunc doConfig(ctx context.Context, client *http.Client, topics []string) {\n\turl := fmt.Sprintf(\"%s/v1/config\", baseURL)\n\tdoGet(ctx, client, url)\n}\n\nfunc doAccountCheck(ctx context.Context, client *http.Client, topics []string) {\n\turl := fmt.Sprintf(\"%s/v1/account\", baseURL)\n\tdoGet(ctx, client, url)\n}\n\nfunc doOtherGet(ctx context.Context, client *http.Client, topics []string) {\n\ttopic := pickTopic(topics)\n\turl := fmt.Sprintf(\"%s/%s\", baseURL, topic)\n\tdoGet(ctx, client, url)\n}\n\nfunc doGet(ctx context.Context, client *http.Client, url string) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\ttrackError(\"get_req\", err)\n\t\treturn\n\t}\n\tsetAuth(req)\n\tresp, err := client.Do(req)\n\ttotalRequests.Add(1)\n\tif err != nil {\n\t\ttrackError(\"get\", err)\n\t\treturn\n\t}\n\tio.Copy(io.Discard, resp.Body)\n\tresp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\ttrackErrorMsg(\"get_http\", fmt.Sprintf(\"status %d for %s\", resp.StatusCode, url))\n\t}\n}\n\n// --- Long-lived streaming subscriptions ---\n\nfunc streamSubscription(ctx context.Context, client *http.Client, topics []string, format string) {\n\tfor {\n\t\tif ctx.Err() != nil {\n\t\t\treturn\n\t\t}\n\t\ttopic := pickTopic(topics)\n\t\turl := fmt.Sprintf(\"%s/%s/%s?since=all\", baseURL, topic, format)\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\t\tif err != nil {\n\t\t\ttime.Sleep(time.Second)\n\t\t\tcontinue\n\t\t}\n\t\tsetAuth(req)\n\t\tactiveStreams.Add(1)\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tactiveStreams.Add(-1)\n\t\t\tif ctx.Err() == nil {\n\t\t\t\ttrackError(\"stream_\"+format+\"_connect\", err)\n\t\t\t}\n\t\t\ttime.Sleep(time.Second)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode >= 400 {\n\t\t\ttrackErrorMsg(\"stream_\"+format+\"_http\", fmt.Sprintf(\"status %d\", resp.StatusCode))\n\t\t\tresp.Body.Close()\n\t\t\tactiveStreams.Add(-1)\n\t\t\ttime.Sleep(time.Second)\n\t\t\tcontinue\n\t\t}\n\t\t// Read from stream until context cancelled or connection drops\n\t\tbuf := make([]byte, 4096)\n\t\tfor {\n\t\t\t_, err := resp.Body.Read(buf)\n\t\t\tif err != nil {\n\t\t\t\tif ctx.Err() == nil {\n\t\t\t\t\ttrackError(\"stream_\"+format+\"_read\", err)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tresp.Body.Close()\n\t\tactiveStreams.Add(-1)\n\t\t// Reconnect with small delay (like real clients do)\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(time.Duration(mrand.Intn(3000)) * time.Millisecond):\n\t\t}\n\t}\n}\n\nfunc wsSubscription(ctx context.Context, topics []string) {\n\twsURL := strings.Replace(baseURL, \"https://\", \"wss://\", 1)\n\twsURL = strings.Replace(wsURL, \"http://\", \"ws://\", 1)\n\n\tfor {\n\t\tif ctx.Err() != nil {\n\t\t\treturn\n\t\t}\n\t\ttopic := pickTopic(topics)\n\t\turl := fmt.Sprintf(\"%s/%s/ws?since=all\", wsURL, topic)\n\n\t\tdialer := websocket.Dialer{\n\t\t\tHandshakeTimeout: 10 * time.Second,\n\t\t}\n\t\tvar wsHeader http.Header\n\t\tif username != \"\" && password != \"\" {\n\t\t\twsHeader = http.Header{}\n\t\t\treq, _ := http.NewRequest(\"GET\", url, nil)\n\t\t\treq.SetBasicAuth(username, password)\n\t\t\twsHeader.Set(\"Authorization\", req.Header.Get(\"Authorization\"))\n\t\t}\n\t\tactiveStreams.Add(1)\n\t\tconn, _, err := dialer.DialContext(ctx, url, wsHeader)\n\t\tif err != nil {\n\t\t\tactiveStreams.Add(-1)\n\t\t\tif ctx.Err() == nil {\n\t\t\t\ttrackError(\"ws_connect\", err)\n\t\t\t}\n\t\t\ttime.Sleep(time.Second)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Read messages until context cancelled or error\n\t\tdone := make(chan struct{})\n\t\tgo func() {\n\t\t\tdefer close(done)\n\t\t\tfor {\n\t\t\t\tconn.SetReadDeadline(time.Now().Add(5 * time.Minute))\n\t\t\t\t_, _, err := conn.ReadMessage()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tconn.Close()\n\t\t\tactiveStreams.Add(-1)\n\t\t\treturn\n\t\tcase <-done:\n\t\t\tconn.Close()\n\t\t\tactiveStreams.Add(-1)\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(time.Duration(mrand.Intn(3000)) * time.Millisecond):\n\t\t}\n\t}\n}\n\nfunc reportStats(ctx context.Context) {\n\tticker := time.NewTicker(5 * time.Second)\n\tdefer ticker.Stop()\n\n\tvar lastRequests, lastErrors int64\n\tlastTime := time.Now()\n\treportCount := 0\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tnow := time.Now()\n\t\t\tcurrentRequests := totalRequests.Load()\n\t\t\tcurrentErrors := totalErrors.Load()\n\t\t\telapsed := now.Sub(lastTime).Seconds()\n\t\t\tcurrentRPS := float64(currentRequests-lastRequests) / elapsed\n\t\t\terrorRate := float64(currentErrors-lastErrors) / elapsed\n\n\t\t\tfmt.Printf(\"[%s] rps=%.1f err/s=%.1f total=%d errors=%d streams=%d\\n\",\n\t\t\t\tnow.Format(\"15:04:05\"),\n\t\t\t\tcurrentRPS,\n\t\t\t\terrorRate,\n\t\t\t\tcurrentRequests,\n\t\t\t\tcurrentErrors,\n\t\t\t\tactiveStreams.Load(),\n\t\t\t)\n\n\t\t\t// Print error breakdown every 30 seconds\n\t\t\treportCount++\n\t\t\tif reportCount%6 == 0 && currentErrors > 0 {\n\t\t\t\terrMu.Lock()\n\t\t\t\tfmt.Printf(\"  Error breakdown:\\n\")\n\t\t\t\tfor k, v := range errorCounts {\n\t\t\t\t\tfmt.Printf(\"    %s: %d\\n\", k, v)\n\t\t\t\t}\n\t\t\t\terrMu.Unlock()\n\t\t\t}\n\n\t\t\tlastRequests = currentRequests\n\t\t\tlastErrors = currentErrors\n\t\t\tlastTime = now\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tools/pgimport/README.md",
    "content": "# pgimport\n\nOne-off migration script to import ntfy data from SQLite to PostgreSQL.\n\nThis is **not** a generic migration tool. It only works with specific SQLite schema versions\n(message cache v14, user db v6, web push v1) and their corresponding PostgreSQL schemas.\nIf your database versions differ, this tool will refuse to run.\n\n## Build\n\n```bash\ngo build -o pgimport ./tools/pgimport/\n```\n\n## Usage\n\n```bash\n# Using CLI flags\npgimport \\\n  --database-url \"postgres://user:pass@host:5432/ntfy?sslmode=require\" \\\n  --cache-file /var/cache/ntfy/cache.db \\\n  --auth-file /var/lib/ntfy/user.db \\\n  --web-push-file /var/lib/ntfy/webpush.db\n\n# Using --create-schema to set up PostgreSQL schema automatically\npgimport \\\n  --create-schema \\\n  --database-url \"postgres://user:pass@host:5432/ntfy?sslmode=require\" \\\n  --cache-file /var/cache/ntfy/cache.db \\\n  --auth-file /var/lib/ntfy/user.db \\\n  --web-push-file /var/lib/ntfy/webpush.db\n\n# Using server.yml (flags override config values)\npgimport --config /etc/ntfy/server.yml\n```\n\n## Prerequisites\n\n- PostgreSQL schema must already be set up, either by running ntfy with `database-url` once,\n  or by passing `--create-schema` to pgimport to create the initial schema automatically\n- ntfy must not be running during the import\n- All three SQLite files are optional; only the ones specified will be imported\n\n## Notes\n\n- The tool is idempotent and safe to re-run\n- After importing, row counts and content are verified against the SQLite sources\n- Invalid UTF-8 in messages is replaced with the Unicode replacement character\n"
  },
  {
    "path": "tools/pgimport/main.go",
    "content": "// pgimport is a one-off migration script to import ntfy data from SQLite to PostgreSQL.\n// It is not a generic migration tool. It expects specific schema versions for each database\n// (message cache v14, user db v6, web push v1) and will refuse to run if versions don't match.\npackage main\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\n\t_ \"github.com/mattn/go-sqlite3\"\n\t\"github.com/urfave/cli/v2\"\n\t\"github.com/urfave/cli/v2/altsrc\"\n\t\"gopkg.in/yaml.v2\"\n\t\"heckel.io/ntfy/v2/db/pg\"\n)\n\nconst (\n\tbatchSize = 1000\n\n\texpectedMessageSchemaVersion = 14\n\texpectedUserSchemaVersion    = 6\n\texpectedWebPushSchemaVersion = 1\n\n\teveryoneID = \"u_everyone\"\n\n\t// Initial PostgreSQL schema for message store (from message/cache_postgres_schema.go)\n\tcreateMessageSchemaQuery = `\n\t\tCREATE TABLE IF NOT EXISTS message (\n\t\t\tid BIGSERIAL PRIMARY KEY,\n\t\t\tmid TEXT NOT NULL,\n\t\t\tsequence_id TEXT NOT NULL,\n\t\t\ttime BIGINT NOT NULL,\n\t\t\tevent TEXT NOT NULL,\n\t\t\texpires BIGINT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tmessage TEXT NOT NULL,\n\t\t\ttitle TEXT NOT NULL,\n\t\t\tpriority INT NOT NULL,\n\t\t\ttags TEXT NOT NULL,\n\t\t\tclick TEXT NOT NULL,\n\t\t\ticon TEXT NOT NULL,\n\t\t\tactions TEXT NOT NULL,\n\t\t\tattachment_name TEXT NOT NULL,\n\t\t\tattachment_type TEXT NOT NULL,\n\t\t\tattachment_size BIGINT NOT NULL,\n\t\t\tattachment_expires BIGINT NOT NULL,\n\t\t\tattachment_url TEXT NOT NULL,\n\t\t\tattachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,\n\t\t\tsender TEXT NOT NULL,\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tcontent_type TEXT NOT NULL,\n\t\t\tencoding TEXT NOT NULL,\n\t\t\tpublished BOOLEAN NOT NULL DEFAULT FALSE\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_mid ON message (mid);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires);\n\t\tCREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';\n\t\tCREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);\n\t\tCREATE TABLE IF NOT EXISTS message_stats (\n\t\t\tkey TEXT PRIMARY KEY,\n\t\t\tvalue BIGINT\n\t\t);\n\t\tINSERT INTO message_stats (key, value) VALUES ('messages', 0) ON CONFLICT (key) DO NOTHING;\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO schema_version (store, version) VALUES ('message', 14) ON CONFLICT (store) DO NOTHING;\n\t`\n\n\t// Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go)\n\tcreateUserSchemaQuery = `\n\t\tCREATE TABLE IF NOT EXISTS tier (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcode TEXT NOT NULL,\n\t\t\tname TEXT NOT NULL,\n\t\t\tmessages_limit BIGINT NOT NULL,\n\t\t\tmessages_expiry_duration BIGINT NOT NULL,\n\t\t\temails_limit BIGINT NOT NULL,\n\t\t\tcalls_limit BIGINT NOT NULL,\n\t\t\treservations_limit BIGINT NOT NULL,\n\t\t\tattachment_file_size_limit BIGINT NOT NULL,\n\t\t\tattachment_total_size_limit BIGINT NOT NULL,\n\t\t\tattachment_expiry_duration BIGINT NOT NULL,\n\t\t\tattachment_bandwidth_limit BIGINT NOT NULL,\n\t\t\tstripe_monthly_price_id TEXT,\n\t\t\tstripe_yearly_price_id TEXT,\n\t\t\tUNIQUE(code),\n\t\t\tUNIQUE(stripe_monthly_price_id),\n\t\t\tUNIQUE(stripe_yearly_price_id)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS \"user\" (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT REFERENCES tier(id),\n\t\t\tuser_name TEXT NOT NULL UNIQUE,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),\n\t\t\tprefs JSONB NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tstats_messages BIGINT NOT NULL DEFAULT 0,\n\t\t\tstats_emails BIGINT NOT NULL DEFAULT 0,\n\t\t\tstats_calls BIGINT NOT NULL DEFAULT 0,\n\t\t\tstripe_customer_id TEXT UNIQUE,\n\t\t\tstripe_subscription_id TEXT UNIQUE,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_interval TEXT,\n\t\t\tstripe_subscription_paid_until BIGINT,\n\t\t\tstripe_subscription_cancel_at BIGINT,\n\t\t\tcreated BIGINT NOT NULL,\n\t\t\tdeleted BIGINT\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_access (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread BOOLEAN NOT NULL,\n\t\t\twrite BOOLEAN NOT NULL,\n\t\t\towner_user_id TEXT REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tPRIMARY KEY (user_id, topic)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\ttoken TEXT NOT NULL UNIQUE,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access BIGINT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires BIGINT NOT NULL,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_phone (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\tphone_number TEXT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, phone_number)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO \"user\" (id, user_name, pass, role, sync_topic, provisioned, created)\n\t\tVALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)\n\t\tON CONFLICT (id) DO NOTHING;\n\t\tINSERT INTO schema_version (store, version) VALUES ('user', 6) ON CONFLICT (store) DO NOTHING;\n\t`\n\n\t// Initial PostgreSQL schema for web push store (from webpush/store_postgres.go)\n\tcreateWebPushSchemaQuery = `\n\t\tCREATE TABLE IF NOT EXISTS webpush_subscription (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tendpoint TEXT NOT NULL UNIQUE,\n\t\t\tkey_auth TEXT NOT NULL,\n\t\t\tkey_p256dh TEXT NOT NULL,\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tsubscriber_ip TEXT NOT NULL,\n\t\t\tupdated_at BIGINT NOT NULL,\n\t\t\twarned_at BIGINT NOT NULL DEFAULT 0\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);\n\t\tCREATE TABLE IF NOT EXISTS webpush_subscription_topic (\n\t\t\tsubscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tPRIMARY KEY (subscription_id, topic)\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO schema_version (store, version) VALUES ('webpush', 1) ON CONFLICT (store) DO NOTHING;\n\t`\n)\n\nvar flags = []cli.Flag{\n\t&cli.StringFlag{Name: \"config\", Aliases: []string{\"c\"}, Usage: \"path to server.yml config file\"},\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"database-url\", Aliases: []string{\"database_url\"}, Usage: \"PostgreSQL connection string\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"cache-file\", Aliases: []string{\"cache_file\"}, Usage: \"SQLite message cache file path\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"auth-file\", Aliases: []string{\"auth_file\"}, Usage: \"SQLite user/auth database file path\"}),\n\taltsrc.NewStringFlag(&cli.StringFlag{Name: \"web-push-file\", Aliases: []string{\"web_push_file\"}, Usage: \"SQLite web push database file path\"}),\n\t&cli.BoolFlag{Name: \"create-schema\", Usage: \"create initial PostgreSQL schema before importing\"},\n\t&cli.BoolFlag{Name: \"pre-import\", Usage: \"pre-import messages while ntfy is still running (only imports messages)\"},\n}\n\nfunc main() {\n\tapp := &cli.App{\n\t\tName:      \"pgimport\",\n\t\tUsage:     \"One-off SQLite to PostgreSQL migration script for ntfy\",\n\t\tUsageText: \"pgimport [OPTIONS]\",\n\t\tFlags:     flags,\n\t\tBefore:    loadConfigFile(\"config\", flags),\n\t\tAction:    execImport,\n\t}\n\tif err := app.Run(os.Args); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc execImport(c *cli.Context) error {\n\tdatabaseURL := c.String(\"database-url\")\n\tcacheFile := c.String(\"cache-file\")\n\tauthFile := c.String(\"auth-file\")\n\twebPushFile := c.String(\"web-push-file\")\n\tpreImport := c.Bool(\"pre-import\")\n\n\tif databaseURL == \"\" {\n\t\treturn fmt.Errorf(\"database-url must be set (via --database-url or config file)\")\n\t}\n\tif preImport {\n\t\tif cacheFile == \"\" {\n\t\t\treturn fmt.Errorf(\"--cache-file must be set when using --pre-import\")\n\t\t}\n\t\treturn execPreImport(c, databaseURL, cacheFile)\n\t}\n\tif cacheFile == \"\" && authFile == \"\" && webPushFile == \"\" {\n\t\treturn fmt.Errorf(\"at least one of --cache-file, --auth-file, or --web-push-file must be set\")\n\t}\n\n\tfmt.Println(\"pgimport - SQLite to PostgreSQL migration tool for ntfy\")\n\tfmt.Println()\n\tfmt.Println(\"Sources:\")\n\tprintSource(\"  Cache file:    \", cacheFile)\n\tprintSource(\"  Auth file:     \", authFile)\n\tprintSource(\"  Web push file: \", webPushFile)\n\tfmt.Println()\n\tfmt.Println(\"Target:\")\n\tfmt.Printf(\"  Database URL:  %s\\n\", maskPassword(databaseURL))\n\tfmt.Println()\n\tfmt.Println(\"This will import data from the SQLite databases into PostgreSQL.\")\n\tfmt.Print(\"Make sure ntfy is not running. Continue? (y/n): \")\n\n\tvar answer string\n\tfmt.Scanln(&answer)\n\tif strings.TrimSpace(strings.ToLower(answer)) != \"y\" {\n\t\tfmt.Println(\"Aborted.\")\n\t\treturn nil\n\t}\n\tfmt.Println()\n\n\tpgHost, err := pg.Open(databaseURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot connect to PostgreSQL: %w\", err)\n\t}\n\tpgDB := pgHost.DB\n\tdefer pgDB.Close()\n\n\tif c.Bool(\"create-schema\") {\n\t\tif err := createSchema(pgDB, cacheFile, authFile, webPushFile); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot create schema: %w\", err)\n\t\t}\n\t}\n\n\tif authFile != \"\" {\n\t\tif err := verifySchemaVersion(pgDB, \"user\", expectedUserSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := importUsers(authFile, pgDB); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot import users: %w\", err)\n\t\t}\n\t}\n\tif cacheFile != \"\" {\n\t\tif err := verifySchemaVersion(pgDB, \"message\", expectedMessageSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsinceTime := maxMessageTime(pgDB)\n\t\tif err := importMessages(cacheFile, pgDB, sinceTime); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot import messages: %w\", err)\n\t\t}\n\t}\n\tif webPushFile != \"\" {\n\t\tif err := verifySchemaVersion(pgDB, \"webpush\", expectedWebPushSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := importWebPush(webPushFile, pgDB); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot import web push subscriptions: %w\", err)\n\t\t}\n\t}\n\n\tfmt.Println()\n\tfmt.Println(\"Verifying migration ...\")\n\tfailed := false\n\tif authFile != \"\" {\n\t\tif err := verifyUsers(authFile, pgDB, &failed); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot verify users: %w\", err)\n\t\t}\n\t}\n\tif cacheFile != \"\" {\n\t\tif err := verifyMessages(cacheFile, pgDB, &failed); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot verify messages: %w\", err)\n\t\t}\n\t}\n\tif webPushFile != \"\" {\n\t\tif err := verifyWebPush(webPushFile, pgDB, &failed); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot verify web push: %w\", err)\n\t\t}\n\t}\n\tfmt.Println()\n\tif failed {\n\t\treturn fmt.Errorf(\"verification FAILED, see above for details\")\n\t}\n\tfmt.Println(\"Verification successful. Migration complete.\")\n\treturn nil\n}\n\nfunc execPreImport(c *cli.Context, databaseURL, cacheFile string) error {\n\tfmt.Println(\"pgimport - PRE-IMPORT mode (ntfy can keep running)\")\n\tfmt.Println()\n\tfmt.Println(\"Source:\")\n\tprintSource(\"  Cache file:    \", cacheFile)\n\tfmt.Println()\n\tfmt.Println(\"Target:\")\n\tfmt.Printf(\"  Database URL:  %s\\n\", maskPassword(databaseURL))\n\tfmt.Println()\n\tfmt.Println(\"This will pre-import messages into PostgreSQL while ntfy is still running.\")\n\tfmt.Println(\"After this completes, stop ntfy and run pgimport again without --pre-import\")\n\tfmt.Println(\"to import remaining messages, users, and web push subscriptions.\")\n\tfmt.Print(\"Continue? (y/n): \")\n\n\tvar answer string\n\tfmt.Scanln(&answer)\n\tif strings.TrimSpace(strings.ToLower(answer)) != \"y\" {\n\t\tfmt.Println(\"Aborted.\")\n\t\treturn nil\n\t}\n\tfmt.Println()\n\n\tpgHost, err := pg.Open(databaseURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot connect to PostgreSQL: %w\", err)\n\t}\n\tpgDB := pgHost.DB\n\tdefer pgDB.Close()\n\n\tif c.Bool(\"create-schema\") {\n\t\tif err := createSchema(pgDB, cacheFile, \"\", \"\"); err != nil {\n\t\t\treturn fmt.Errorf(\"cannot create schema: %w\", err)\n\t\t}\n\t}\n\n\tif err := verifySchemaVersion(pgDB, \"message\", expectedMessageSchemaVersion); err != nil {\n\t\treturn err\n\t}\n\tif err := importMessages(cacheFile, pgDB, 0); err != nil {\n\t\treturn fmt.Errorf(\"cannot import messages: %w\", err)\n\t}\n\n\tfmt.Println()\n\tfmt.Println(\"Pre-import complete. Now stop ntfy and run pgimport again without --pre-import\")\n\tfmt.Println(\"to import any remaining messages, users, and web push subscriptions.\")\n\treturn nil\n}\n\nfunc createSchema(pgDB *sql.DB, cacheFile, authFile, webPushFile string) error {\n\tfmt.Println(\"Creating initial PostgreSQL schema ...\")\n\t// User schema must be created before message schema, because message_stats and\n\t// schema_version use \"INSERT INTO\" without \"ON CONFLICT\", so user schema (which\n\t// also creates the schema_version table) must come first.\n\tif authFile != \"\" {\n\t\tfmt.Println(\"  Creating user schema ...\")\n\t\tif _, err := pgDB.Exec(createUserSchemaQuery); err != nil {\n\t\t\treturn fmt.Errorf(\"creating user schema: %w\", err)\n\t\t}\n\t}\n\tif cacheFile != \"\" {\n\t\tfmt.Println(\"  Creating message schema ...\")\n\t\tif _, err := pgDB.Exec(createMessageSchemaQuery); err != nil {\n\t\t\treturn fmt.Errorf(\"creating message schema: %w\", err)\n\t\t}\n\t}\n\tif webPushFile != \"\" {\n\t\tfmt.Println(\"  Creating web push schema ...\")\n\t\tif _, err := pgDB.Exec(createWebPushSchemaQuery); err != nil {\n\t\t\treturn fmt.Errorf(\"creating web push schema: %w\", err)\n\t\t}\n\t}\n\tfmt.Println(\"  Schema creation complete.\")\n\tfmt.Println()\n\treturn nil\n}\n\nfunc loadConfigFile(configFlag string, flags []cli.Flag) cli.BeforeFunc {\n\treturn func(c *cli.Context) error {\n\t\tconfigFile := c.String(configFlag)\n\t\tif configFile == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\tif _, err := os.Stat(configFile); os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"config file %s does not exist\", configFile)\n\t\t}\n\t\tinputSource, err := newYamlSourceFromFile(configFile, flags)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn altsrc.ApplyInputSourceValues(c, inputSource, flags)\n\t}\n}\n\nfunc newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {\n\tvar rawConfig map[any]any\n\tb, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := yaml.Unmarshal(b, &rawConfig); err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, f := range flags {\n\t\tflagName := f.Names()[0]\n\t\tfor _, flagAlias := range f.Names()[1:] {\n\t\t\tif _, ok := rawConfig[flagAlias]; ok {\n\t\t\t\trawConfig[flagName] = rawConfig[flagAlias]\n\t\t\t}\n\t\t}\n\t}\n\treturn altsrc.NewMapInputSource(file, rawConfig), nil\n}\n\nfunc verifySchemaVersion(pgDB *sql.DB, store string, expected int) error {\n\tvar version int\n\terr := pgDB.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot read %s schema version from PostgreSQL (is the schema set up?): %w\", store, err)\n\t}\n\tif version != expected {\n\t\treturn fmt.Errorf(\"%s schema version mismatch: expected %d, got %d\", store, expected, version)\n\t}\n\treturn nil\n}\n\nfunc printSource(label, path string) {\n\tif path == \"\" {\n\t\tfmt.Printf(\"%s(not set, skipping)\\n\", label)\n\t} else if _, err := os.Stat(path); os.IsNotExist(err) {\n\t\tfmt.Printf(\"%s%s (NOT FOUND, skipping)\\n\", label, path)\n\t} else {\n\t\tfmt.Printf(\"%s%s\\n\", label, path)\n\t}\n}\n\nfunc maskPassword(databaseURL string) string {\n\tu, err := url.Parse(databaseURL)\n\tif err != nil {\n\t\treturn databaseURL\n\t}\n\tif u.User != nil {\n\t\tif _, hasPass := u.User.Password(); hasPass {\n\t\t\tmasked := u.Scheme + \"://\" + u.User.Username() + \":****@\" + u.Host + u.Path\n\t\t\tif u.RawQuery != \"\" {\n\t\t\t\tmasked += \"?\" + u.RawQuery\n\t\t\t}\n\t\t\treturn masked\n\t\t}\n\t}\n\treturn u.String()\n}\n\nfunc openSQLite(filename string) (*sql.DB, error) {\n\tif _, err := os.Stat(filename); os.IsNotExist(err) {\n\t\treturn nil, fmt.Errorf(\"file %s does not exist\", filename)\n\t}\n\treturn sql.Open(\"sqlite3\", filename+\"?mode=ro\")\n}\n\n// User import\n\nfunc importUsers(sqliteFile string, pgDB *sql.DB) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\tfmt.Printf(\"Skipping user import: %s\\n\", err)\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\tfmt.Printf(\"Importing users from %s ...\\n\", sqliteFile)\n\n\tcount, err := importTiers(sqlDB, pgDB)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"importing tiers: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d tiers\\n\", count)\n\n\tcount, err = importUserRows(sqlDB, pgDB)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"importing users: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d users\\n\", count)\n\n\tcount, err = importUserAccess(sqlDB, pgDB)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"importing user access: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d access entries\\n\", count)\n\n\tcount, err = importUserTokens(sqlDB, pgDB)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"importing user tokens: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d tokens\\n\", count)\n\n\tcount, err = importUserPhones(sqlDB, pgDB)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"importing user phones: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d phone numbers\\n\", count)\n\n\treturn nil\n}\n\nfunc importTiers(sqlDB, pgDB *sql.DB) (int, error) {\n\trows, err := sqlDB.Query(`SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO NOTHING`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar id, code, name string\n\t\tvar messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit int64\n\t\tvar attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit int64\n\t\tvar stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString\n\t\tif err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tif _, err := stmt.Exec(id, code, name, messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeMonthlyPriceID, stripeYearlyPriceID); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, tx.Commit()\n}\n\nfunc importUserRows(sqlDB, pgDB *sql.DB) (int, error) {\n\trows, err := sqlDB.Query(`SELECT id, user, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id FROM user`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`\n\t\tINSERT INTO \"user\" (id, user_name, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)\n\t\tON CONFLICT (id) DO NOTHING\n\t`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar id, userName, pass, role, prefs, syncTopic string\n\t\tvar provisioned int\n\t\tvar statsMessages, statsEmails, statsCalls int64\n\t\tvar stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval sql.NullString\n\t\tvar stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64\n\t\tvar created int64\n\t\tvar deleted sql.NullInt64\n\t\tvar tierID sql.NullString\n\t\tif err := rows.Scan(&id, &userName, &pass, &role, &prefs, &syncTopic, &provisioned, &statsMessages, &statsEmails, &statsCalls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &created, &deleted, &tierID); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tprovisionedBool := provisioned != 0\n\t\tif _, err := stmt.Exec(id, userName, pass, role, prefs, syncTopic, provisionedBool, statsMessages, statsEmails, statsCalls, stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, created, deleted, tierID); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, tx.Commit()\n}\n\nfunc importUserAccess(sqlDB, pgDB *sql.DB) (int, error) {\n\trows, err := sqlDB.Query(`SELECT a.user_id, a.topic, a.read, a.write, a.owner_user_id, a.provisioned FROM user_access a JOIN user u ON u.id = a.user_id`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, topic) DO NOTHING`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar userID, topic string\n\t\tvar read, write, provisioned int\n\t\tvar ownerUserID sql.NullString\n\t\tif err := rows.Scan(&userID, &topic, &read, &write, &ownerUserID, &provisioned); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\treadBool := read != 0\n\t\twriteBool := write != 0\n\t\tprovisionedBool := provisioned != 0\n\t\tif _, err := stmt.Exec(userID, topic, readBool, writeBool, ownerUserID, provisionedBool); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, tx.Commit()\n}\n\nfunc importUserTokens(sqlDB, pgDB *sql.DB) (int, error) {\n\trows, err := sqlDB.Query(`SELECT t.user_id, t.token, t.label, t.last_access, t.last_origin, t.expires, t.provisioned FROM user_token t JOIN user u ON u.id = t.user_id`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, token) DO NOTHING`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar userID, token, label, lastOrigin string\n\t\tvar lastAccess, expires int64\n\t\tvar provisioned int\n\t\tif err := rows.Scan(&userID, &token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tprovisionedBool := provisioned != 0\n\t\tif _, err := stmt.Exec(userID, token, label, lastAccess, lastOrigin, expires, provisionedBool); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, tx.Commit()\n}\n\nfunc importUserPhones(sqlDB, pgDB *sql.DB) (int, error) {\n\trows, err := sqlDB.Query(`SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2) ON CONFLICT (user_id, phone_number) DO NOTHING`)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar userID, phoneNumber string\n\t\tif err := rows.Scan(&userID, &phoneNumber); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tif _, err := stmt.Exec(userID, phoneNumber); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, tx.Commit()\n}\n\n// Message import\n\nconst preImportTimeDelta = 30 // seconds to subtract from max time to account for in-flight messages\n\n// maxMessageTime returns the maximum message time in PostgreSQL minus a small buffer,\n// or 0 if there are no messages yet. This is used after a --pre-import run to only\n// import messages that arrived since the pre-import.\nfunc maxMessageTime(pgDB *sql.DB) int64 {\n\tvar maxTime sql.NullInt64\n\tif err := pgDB.QueryRow(`SELECT MAX(time) FROM message`).Scan(&maxTime); err != nil || !maxTime.Valid || maxTime.Int64 == 0 {\n\t\treturn 0\n\t}\n\tsinceTime := maxTime.Int64 - preImportTimeDelta\n\tif sinceTime < 0 {\n\t\treturn 0\n\t}\n\tfmt.Printf(\"Pre-imported messages detected (max time: %d), importing delta (since time %d) ...\\n\", maxTime.Int64, sinceTime)\n\treturn sinceTime\n}\n\nfunc importMessages(sqliteFile string, pgDB *sql.DB, sinceTime int64) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\tfmt.Printf(\"Skipping message import: %s\\n\", err)\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\n\tquery := `SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published FROM messages`\n\tvar rows *sql.Rows\n\tif sinceTime > 0 {\n\t\tfmt.Printf(\"Importing messages from %s (since time %d) ...\\n\", sqliteFile, sinceTime)\n\t\trows, err = sqlDB.Query(query+` WHERE time >= ?`, sinceTime)\n\t} else {\n\t\tfmt.Printf(\"Importing messages from %s ...\\n\", sqliteFile)\n\t\trows, err = sqlDB.Query(query)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"querying messages: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tif _, err := pgDB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_mid_unique ON message (mid)`); err != nil {\n\t\treturn fmt.Errorf(\"creating unique index on mid: %w\", err)\n\t}\n\n\tinsertQuery := `INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) ON CONFLICT (mid) DO NOTHING`\n\n\tcount := 0\n\tbatchCount := 0\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(insertQuery)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\n\tfor rows.Next() {\n\t\tvar mid, sequenceID, event, topic, message, title, tags, click, icon, actions string\n\t\tvar attachmentName, attachmentType, attachmentURL, sender, userID, contentType, encoding string\n\t\tvar msgTime, expires, attachmentExpires int64\n\t\tvar priority int\n\t\tvar attachmentSize int64\n\t\tvar attachmentDeleted, published int\n\t\tif err := rows.Scan(&mid, &sequenceID, &msgTime, &event, &expires, &topic, &message, &title, &priority, &tags, &click, &icon, &actions, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentDeleted, &sender, &userID, &contentType, &encoding, &published); err != nil {\n\t\t\treturn fmt.Errorf(\"scanning message: %w\", err)\n\t\t}\n\t\tmid = toUTF8(mid)\n\t\tsequenceID = toUTF8(sequenceID)\n\t\tevent = toUTF8(event)\n\t\ttopic = toUTF8(topic)\n\t\tmessage = toUTF8(message)\n\t\ttitle = toUTF8(title)\n\t\ttags = toUTF8(tags)\n\t\tclick = toUTF8(click)\n\t\ticon = toUTF8(icon)\n\t\tactions = toUTF8(actions)\n\t\tattachmentName = toUTF8(attachmentName)\n\t\tattachmentType = toUTF8(attachmentType)\n\t\tattachmentURL = toUTF8(attachmentURL)\n\t\tsender = toUTF8(sender)\n\t\tuserID = toUTF8(userID)\n\t\tcontentType = toUTF8(contentType)\n\t\tencoding = toUTF8(encoding)\n\t\tattachmentDeletedBool := attachmentDeleted != 0\n\t\tpublishedBool := published != 0\n\t\tif _, err := stmt.Exec(mid, sequenceID, msgTime, event, expires, topic, message, title, priority, tags, click, icon, actions, attachmentName, attachmentType, attachmentSize, attachmentExpires, attachmentURL, attachmentDeletedBool, sender, userID, contentType, encoding, publishedBool); err != nil {\n\t\t\treturn fmt.Errorf(\"inserting message: %w\", err)\n\t\t}\n\t\tcount++\n\t\tbatchCount++\n\t\tif batchCount >= batchSize {\n\t\t\tstmt.Close()\n\t\t\tif err := tx.Commit(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"committing message batch: %w\", err)\n\t\t\t}\n\t\t\tfmt.Printf(\"  ... %d messages\\n\", count)\n\t\t\ttx, err = pgDB.Begin()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tstmt, err = tx.Prepare(insertQuery)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tbatchCount = 0\n\t\t}\n\t}\n\tif batchCount > 0 {\n\t\tstmt.Close()\n\t\tif err := tx.Commit(); err != nil {\n\t\t\treturn fmt.Errorf(\"committing final message batch: %w\", err)\n\t\t}\n\t}\n\tfmt.Printf(\"  Imported %d messages\\n\", count)\n\n\tvar statsValue int64\n\terr = sqlDB.QueryRow(`SELECT value FROM stats WHERE key = 'messages'`).Scan(&statsValue)\n\tif err == nil {\n\t\tif _, err := pgDB.Exec(`UPDATE message_stats SET value = $1 WHERE key = 'messages'`, statsValue); err != nil {\n\t\t\treturn fmt.Errorf(\"updating message stats: %w\", err)\n\t\t}\n\t\tfmt.Printf(\"  Updated message stats (count: %d)\\n\", statsValue)\n\t}\n\n\treturn nil\n}\n\n// Web push import\n\nfunc importWebPush(sqliteFile string, pgDB *sql.DB) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\tfmt.Printf(\"Skipping web push import: %s\\n\", err)\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\tfmt.Printf(\"Importing web push subscriptions from %s ...\\n\", sqliteFile)\n\n\trows, err := sqlDB.Query(`SELECT id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at FROM subscription`)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"querying subscriptions: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\ttx, err := pgDB.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\n\tcount := 0\n\tfor rows.Next() {\n\t\tvar id, endpoint, keyAuth, keyP256dh, userID, subscriberIP string\n\t\tvar updatedAt, warnedAt int64\n\t\tif err := rows.Scan(&id, &endpoint, &keyAuth, &keyP256dh, &userID, &subscriberIP, &updatedAt, &warnedAt); err != nil {\n\t\t\treturn fmt.Errorf(\"scanning subscription: %w\", err)\n\t\t}\n\t\tif _, err := stmt.Exec(id, endpoint, keyAuth, keyP256dh, userID, subscriberIP, updatedAt, warnedAt); err != nil {\n\t\t\treturn fmt.Errorf(\"inserting subscription: %w\", err)\n\t\t}\n\t\tcount++\n\t}\n\tstmt.Close()\n\tif err := tx.Commit(); err != nil {\n\t\treturn fmt.Errorf(\"committing subscriptions: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d subscriptions\\n\", count)\n\n\ttopicRows, err := sqlDB.Query(`SELECT subscription_id, topic FROM subscription_topic`)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"querying subscription topics: %w\", err)\n\t}\n\tdefer topicRows.Close()\n\n\ttx, err = pgDB.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err = tx.Prepare(`INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2) ON CONFLICT (subscription_id, topic) DO NOTHING`)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\n\ttopicCount := 0\n\tfor topicRows.Next() {\n\t\tvar subscriptionID, topic string\n\t\tif err := topicRows.Scan(&subscriptionID, &topic); err != nil {\n\t\t\treturn fmt.Errorf(\"scanning subscription topic: %w\", err)\n\t\t}\n\t\tif _, err := stmt.Exec(subscriptionID, topic); err != nil {\n\t\t\treturn fmt.Errorf(\"inserting subscription topic: %w\", err)\n\t\t}\n\t\ttopicCount++\n\t}\n\tstmt.Close()\n\tif err := tx.Commit(); err != nil {\n\t\treturn fmt.Errorf(\"committing subscription topics: %w\", err)\n\t}\n\tfmt.Printf(\"  Imported %d subscription topics\\n\", topicCount)\n\n\treturn nil\n}\n\nfunc toUTF8(s string) string {\n\ts = strings.ToValidUTF8(s, \"\\uFFFD\")\n\ts = strings.ReplaceAll(s, \"\\x00\", \"\")\n\treturn s\n}\n\n// Verification\n\nfunc verifyUsers(sqliteFile string, pgDB *sql.DB, failed *bool) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\n\tverifyCount(sqlDB, pgDB, \"tier\", `SELECT COUNT(*) FROM tier`, `SELECT COUNT(*) FROM tier`, failed)\n\tverifyContent(sqlDB, pgDB, \"tier\",\n\t\t`SELECT id, code, name FROM tier ORDER BY id`,\n\t\t`SELECT id, code, name FROM tier ORDER BY id COLLATE \"C\"`,\n\t\tfailed)\n\n\tverifyCount(sqlDB, pgDB, \"user\", `SELECT COUNT(*) FROM user`, `SELECT COUNT(*) FROM \"user\"`, failed)\n\tverifyContent(sqlDB, pgDB, \"user\",\n\t\t`SELECT id, user, role, sync_topic FROM user ORDER BY id`,\n\t\t`SELECT id, user_name, role, sync_topic FROM \"user\" ORDER BY id COLLATE \"C\"`,\n\t\tfailed)\n\n\tverifyCount(sqlDB, pgDB, \"user_access\", `SELECT COUNT(*) FROM user_access a JOIN user u ON u.id = a.user_id`, `SELECT COUNT(*) FROM user_access`, failed)\n\tverifyContent(sqlDB, pgDB, \"user_access\",\n\t\t`SELECT a.user_id, a.topic FROM user_access a JOIN user u ON u.id = a.user_id ORDER BY a.user_id, a.topic`,\n\t\t`SELECT user_id, topic FROM user_access ORDER BY user_id COLLATE \"C\", topic COLLATE \"C\"`,\n\t\tfailed)\n\n\tverifyCount(sqlDB, pgDB, \"user_token\", `SELECT COUNT(*) FROM user_token t JOIN user u ON u.id = t.user_id`, `SELECT COUNT(*) FROM user_token`, failed)\n\tverifyContent(sqlDB, pgDB, \"user_token\",\n\t\t`SELECT t.user_id, t.token, t.label FROM user_token t JOIN user u ON u.id = t.user_id ORDER BY t.user_id, t.token`,\n\t\t`SELECT user_id, token, label FROM user_token ORDER BY user_id COLLATE \"C\", token COLLATE \"C\"`,\n\t\tfailed)\n\n\tverifyCount(sqlDB, pgDB, \"user_phone\", `SELECT COUNT(*) FROM user_phone p JOIN user u ON u.id = p.user_id`, `SELECT COUNT(*) FROM user_phone`, failed)\n\tverifyContent(sqlDB, pgDB, \"user_phone\",\n\t\t`SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id ORDER BY p.user_id, p.phone_number`,\n\t\t`SELECT user_id, phone_number FROM user_phone ORDER BY user_id COLLATE \"C\", phone_number COLLATE \"C\"`,\n\t\tfailed)\n\n\treturn nil\n}\n\nfunc verifyMessages(sqliteFile string, pgDB *sql.DB, failed *bool) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\n\tverifyCount(sqlDB, pgDB, \"messages\", `SELECT COUNT(*) FROM messages`, `SELECT COUNT(*) FROM message`, failed)\n\tverifySampledMessages(sqlDB, pgDB, failed)\n\treturn nil\n}\n\nfunc verifyWebPush(sqliteFile string, pgDB *sql.DB, failed *bool) error {\n\tsqlDB, err := openSQLite(sqliteFile)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer sqlDB.Close()\n\n\tverifyCount(sqlDB, pgDB, \"subscription\", `SELECT COUNT(*) FROM subscription`, `SELECT COUNT(*) FROM webpush_subscription`, failed)\n\tverifyContent(sqlDB, pgDB, \"subscription\",\n\t\t`SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription ORDER BY id`,\n\t\t`SELECT id, endpoint, key_auth, key_p256dh, user_id FROM webpush_subscription ORDER BY id COLLATE \"C\"`,\n\t\tfailed)\n\n\tverifyCount(sqlDB, pgDB, \"subscription_topic\", `SELECT COUNT(*) FROM subscription_topic`, `SELECT COUNT(*) FROM webpush_subscription_topic`, failed)\n\tverifyContent(sqlDB, pgDB, \"subscription_topic\",\n\t\t`SELECT subscription_id, topic FROM subscription_topic ORDER BY subscription_id, topic`,\n\t\t`SELECT subscription_id, topic FROM webpush_subscription_topic ORDER BY subscription_id COLLATE \"C\", topic COLLATE \"C\"`,\n\t\tfailed)\n\n\treturn nil\n}\n\nfunc verifyCount(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) {\n\tvar sqliteCount, pgCount int64\n\tif err := sqlDB.QueryRow(sqliteQuery).Scan(&sqliteCount); err != nil {\n\t\tfmt.Printf(\"  %-25s count ERROR reading SQLite: %s\\n\", table, err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tif err := pgDB.QueryRow(pgQuery).Scan(&pgCount); err != nil {\n\t\tfmt.Printf(\"  %-25s count ERROR reading PostgreSQL: %s\\n\", table, err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tif sqliteCount == pgCount {\n\t\tfmt.Printf(\"  %-25s count OK (%d rows)\\n\", table, pgCount)\n\t} else {\n\t\tfmt.Printf(\"  %-25s count MISMATCH: SQLite=%d, PostgreSQL=%d\\n\", table, sqliteCount, pgCount)\n\t\t*failed = true\n\t}\n}\n\nfunc verifyContent(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) {\n\tsqliteRows, err := sqlDB.Query(sqliteQuery)\n\tif err != nil {\n\t\tfmt.Printf(\"  %-25s content ERROR reading SQLite: %s\\n\", table, err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tdefer sqliteRows.Close()\n\n\tpgRows, err := pgDB.Query(pgQuery)\n\tif err != nil {\n\t\tfmt.Printf(\"  %-25s content ERROR reading PostgreSQL: %s\\n\", table, err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tdefer pgRows.Close()\n\n\tcols, err := sqliteRows.Columns()\n\tif err != nil {\n\t\tfmt.Printf(\"  %-25s content ERROR reading columns: %s\\n\", table, err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tnumCols := len(cols)\n\n\trowNum := 0\n\tmismatches := 0\n\tfor sqliteRows.Next() {\n\t\trowNum++\n\t\tif !pgRows.Next() {\n\t\t\tfmt.Printf(\"  %-25s content MISMATCH: PostgreSQL has fewer rows (at row %d)\\n\", table, rowNum)\n\t\t\t*failed = true\n\t\t\treturn\n\t\t}\n\t\tsqliteVals := makeStringSlice(numCols)\n\t\tpgVals := makeStringSlice(numCols)\n\t\tif err := sqliteRows.Scan(sqliteVals...); err != nil {\n\t\t\tfmt.Printf(\"  %-25s content ERROR scanning SQLite row %d: %s\\n\", table, rowNum, err)\n\t\t\t*failed = true\n\t\t\treturn\n\t\t}\n\t\tif err := pgRows.Scan(pgVals...); err != nil {\n\t\t\tfmt.Printf(\"  %-25s content ERROR scanning PostgreSQL row %d: %s\\n\", table, rowNum, err)\n\t\t\t*failed = true\n\t\t\treturn\n\t\t}\n\t\tfor i := 0; i < numCols; i++ {\n\t\t\tsv := *(sqliteVals[i].(*sql.NullString))\n\t\t\tpv := *(pgVals[i].(*sql.NullString))\n\t\t\tif sv != pv {\n\t\t\t\tmismatches++\n\t\t\t\tif mismatches <= 3 {\n\t\t\t\t\tfmt.Printf(\"  %-25s content MISMATCH at row %d, col %s: SQLite=%q, PostgreSQL=%q\\n\", table, rowNum, cols[i], sv.String, pv.String)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif pgRows.Next() {\n\t\tfmt.Printf(\"  %-25s content MISMATCH: PostgreSQL has more rows than SQLite\\n\", table)\n\t\t*failed = true\n\t\treturn\n\t}\n\tif mismatches > 0 {\n\t\tif mismatches > 3 {\n\t\t\tfmt.Printf(\"  %-25s content ... and %d more mismatches\\n\", table, mismatches-3)\n\t\t}\n\t\t*failed = true\n\t} else {\n\t\tfmt.Printf(\"  %-25s content OK\\n\", table)\n\t}\n}\n\nfunc verifySampledMessages(sqlDB, pgDB *sql.DB, failed *bool) {\n\trows, err := sqlDB.Query(`SELECT mid, topic, time, message, title, tags, priority FROM messages ORDER BY mid`)\n\tif err != nil {\n\t\tfmt.Printf(\"  %-25s content ERROR reading SQLite: %s\\n\", \"messages (sampled)\", err)\n\t\t*failed = true\n\t\treturn\n\t}\n\tdefer rows.Close()\n\n\trowNum := 0\n\tchecked := 0\n\tmismatches := 0\n\tfor rows.Next() {\n\t\trowNum++\n\t\tvar mid, topic, message, title, tags string\n\t\tvar msgTime int64\n\t\tvar priority int\n\t\tif err := rows.Scan(&mid, &topic, &msgTime, &message, &title, &tags, &priority); err != nil {\n\t\t\tfmt.Printf(\"  %-25s content ERROR scanning SQLite row %d: %s\\n\", \"messages (sampled)\", rowNum, err)\n\t\t\t*failed = true\n\t\t\treturn\n\t\t}\n\t\tif rowNum%100 != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tchecked++\n\t\tvar pgTopic, pgMessage, pgTitle, pgTags string\n\t\tvar pgTime int64\n\t\tvar pgPriority int\n\t\terr := pgDB.QueryRow(`SELECT topic, time, message, title, tags, priority FROM message WHERE mid = $1`, mid).\n\t\t\tScan(&pgTopic, &pgTime, &pgMessage, &pgTitle, &pgTags, &pgPriority)\n\t\tif err == sql.ErrNoRows {\n\t\t\tmismatches++\n\t\t\tif mismatches <= 3 {\n\t\t\t\tfmt.Printf(\"  %-25s content MISMATCH: mid=%s not found in PostgreSQL\\n\", \"messages (sampled)\", mid)\n\t\t\t}\n\t\t\tcontinue\n\t\t} else if err != nil {\n\t\t\tfmt.Printf(\"  %-25s content ERROR querying PostgreSQL for mid=%s: %s\\n\", \"messages (sampled)\", mid, err)\n\t\t\t*failed = true\n\t\t\treturn\n\t\t}\n\t\ttopic = toUTF8(topic)\n\t\tmessage = toUTF8(message)\n\t\ttitle = toUTF8(title)\n\t\ttags = toUTF8(tags)\n\t\tif topic != pgTopic || msgTime != pgTime || message != pgMessage || title != pgTitle || tags != pgTags || priority != pgPriority {\n\t\t\tmismatches++\n\t\t\tif mismatches <= 3 {\n\t\t\t\tfmt.Printf(\"  %-25s content MISMATCH at mid=%s\\n\", \"messages (sampled)\", mid)\n\t\t\t}\n\t\t}\n\t}\n\tif mismatches > 0 {\n\t\tif mismatches > 3 {\n\t\t\tfmt.Printf(\"  %-25s content ... and %d more mismatches\\n\", \"messages (sampled)\", mismatches-3)\n\t\t}\n\t\t*failed = true\n\t} else {\n\t\tfmt.Printf(\"  %-25s content OK (%d samples checked)\\n\", \"messages (sampled)\", checked)\n\t}\n}\n\nfunc makeStringSlice(n int) []any {\n\tvals := make([]any, n)\n\tfor i := range vals {\n\t\tvals[i] = &sql.NullString{}\n\t}\n\treturn vals\n}\n"
  },
  {
    "path": "tools/shrink-png.sh",
    "content": "#!/bin/bash\n#\n# Shrinks PNG files to a max height of 1200px\n# Usage: ./shrink-png.sh file1.png file2.png ...\n#\n\nMAX_HEIGHT=1200\n\nif [ $# -eq 0 ]; then\n    echo \"Usage: $0 file1.png file2.png ...\"\n    exit 1\nfi\n\nfor file in \"$@\"; do\n    if [ ! -f \"$file\" ]; then\n        echo \"File not found: $file\"\n        continue\n    fi\n    \n    height=$(identify -format \"%h\" \"$file\")\n    if [ \"$height\" -gt \"$MAX_HEIGHT\" ]; then\n        echo \"Shrinking $file (${height}px -> ${MAX_HEIGHT}px)\"\n        convert \"$file\" -resize \"x${MAX_HEIGHT}\" \"$file\"\n    else\n        echo \"Skipping $file (${height}px <= ${MAX_HEIGHT}px)\"\n    fi\ndone\n"
  },
  {
    "path": "user/manager.go",
    "content": "// Package user deals with authentication and authorization against topics\npackage user\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/netip\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/payments\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\ttierIDPrefix                    = \"ti_\"\n\ttierIDLength                    = 8\n\tsyncTopicPrefix                 = \"st_\"\n\tsyncTopicLength                 = 16\n\tuserIDPrefix                    = \"u_\"\n\tuserIDLength                    = 12\n\tuserAuthIntentionalSlowDownHash = \"$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy\" // Cost should match DefaultUserPasswordBcryptCost\n\tuserHardDeleteAfterDuration     = 7 * 24 * time.Hour\n\ttokenPrefix                     = \"tk_\"\n\ttokenLength                     = 32\n\ttokenMaxCount                   = 60 // Only keep this many tokens in the table per user\n\ttag                             = \"user_manager\"\n)\n\n// Default constants that may be overridden by configs\nconst (\n\tDefaultUserStatsQueueWriterInterval = 33 * time.Second\n\tDefaultUserPasswordBcryptCost       = 10\n)\n\nvar (\n\terrNoTokenProvided    = errors.New(\"no token provided\")\n\terrTopicOwnedByOthers = errors.New(\"topic owned by others\")\n\terrNoRows             = errors.New(\"no rows found\")\n)\n\n// Manager handles user authentication, authorization, and management\ntype Manager struct {\n\tconfig     *Config\n\tdb         *db.DB\n\tqueries    queries\n\tstatsQueue map[string]*Stats       // \"Queue\" to asynchronously write user stats to the database (UserID -> Stats)\n\ttokenQueue map[string]*TokenUpdate // \"Queue\" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)\n\tmu         sync.Mutex\n}\n\nvar _ Auther = (*Manager)(nil)\n\nfunc newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {\n\tif config.BcryptCost <= 0 {\n\t\tconfig.BcryptCost = DefaultUserPasswordBcryptCost\n\t}\n\tif config.QueueWriterInterval.Seconds() <= 0 {\n\t\tconfig.QueueWriterInterval = DefaultUserStatsQueueWriterInterval\n\t}\n\tmanager := &Manager{\n\t\tconfig:     config,\n\t\tdb:         d,\n\t\tstatsQueue: make(map[string]*Stats),\n\t\ttokenQueue: make(map[string]*TokenUpdate),\n\t\tqueries:    queries,\n\t}\n\tif err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {\n\t\treturn nil, err\n\t}\n\tgo manager.asyncQueueWriter(manager.config.QueueWriterInterval)\n\treturn manager, nil\n}\n\n// Authenticate checks username and password and returns a User if correct, and the user has not been\n// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or\n// the password is correct or incorrect.\nfunc (a *Manager) Authenticate(username, password string) (*User, error) {\n\tif username == Everyone {\n\t\treturn nil, ErrUnauthenticated\n\t}\n\tuser, err := a.User(username)\n\tif err != nil {\n\t\tlog.Tag(tag).Field(\"user_name\", username).Err(err).Trace(\"Authentication of user failed (1)\")\n\t\tbcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte(\"intentional slow-down to avoid timing attacks\"))\n\t\treturn nil, ErrUnauthenticated\n\t} else if user.Deleted {\n\t\tlog.Tag(tag).Field(\"user_name\", username).Trace(\"Authentication of user failed (2): user marked deleted\")\n\t\tbcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte(\"intentional slow-down to avoid timing attacks\"))\n\t\treturn nil, ErrUnauthenticated\n\t} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {\n\t\tlog.Tag(tag).Field(\"user_name\", username).Err(err).Trace(\"Authentication of user failed (3)\")\n\t\treturn nil, ErrUnauthenticated\n\t}\n\treturn user, nil\n}\n\n// AuthenticateToken checks if the token exists and returns the associated User if it does.\n// The method sets the User.Token value to the token that was used for authentication.\nfunc (a *Manager) AuthenticateToken(token string) (*User, error) {\n\tif len(token) != tokenLength {\n\t\treturn nil, ErrUnauthenticated\n\t}\n\tuser, err := a.userByToken(token)\n\tif err != nil {\n\t\tlog.Tag(tag).Field(\"token\", token).Err(err).Trace(\"Authentication of token failed\")\n\t\treturn nil, ErrUnauthenticated\n\t}\n\tuser.Token = token\n\treturn user, nil\n}\n\n// AddUser adds a user with the given username, password and role\nfunc (a *Manager) AddUser(username, password string, role Role, hashed bool) error {\n\thash, err := a.maybeHashPassword(password, hashed)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.addUserTx(tx, username, hash, role, false)\n\t})\n}\n\n// addUserTx adds a user with the given username, password hash and role to the database\nfunc (a *Manager) addUserTx(tx *sql.Tx, username, hash string, role Role, provisioned bool) error {\n\tif !AllowedUsername(username) || !AllowedRole(role) {\n\t\treturn ErrInvalidArgument\n\t}\n\tuserID := util.RandomStringPrefix(userIDPrefix, userIDLength)\n\tsyncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)\n\tnow := time.Now().Unix()\n\tif _, err := tx.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil {\n\t\tif isUniqueConstraintError(err) {\n\t\t\treturn ErrUserExists\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// RemoveUser deletes the user with the given username. The function returns nil on success, even\n// if the user did not exist in the first place.\nfunc (a *Manager) RemoveUser(username string) error {\n\tif err := a.CanChangeUser(username); err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.removeUserTx(tx, username)\n\t})\n}\n\n// removeUserTx deletes the user with the given username\nfunc (a *Manager) removeUserTx(tx *sql.Tx, username string) error {\n\tif !AllowedUsername(username) {\n\t\treturn ErrInvalidArgument\n\t}\n\t// Rows in user_access, user_token, etc. are deleted via foreign keys\n\tif _, err := tx.Exec(a.queries.deleteUser, username); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents\n// successful auth via Authenticate. A background process will delete the user at a later date.\nfunc (a *Manager) MarkUserRemoved(user *User) error {\n\tif !AllowedUsername(user.Name) {\n\t\treturn ErrInvalidArgument\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif err := a.resetUserAccessTx(tx, user.Name); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.deleteAllToken, user.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdeletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix()\n\t\tif _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, user.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// RemoveDeletedUsers deletes all users that have been marked deleted\nfunc (a *Manager) RemoveDeletedUsers() error {\n\tif _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ChangePassword changes a user's password\nfunc (a *Manager) ChangePassword(username, password string, hashed bool) error {\n\tif err := a.CanChangeUser(username); err != nil {\n\t\treturn err\n\t}\n\thash, err := a.maybeHashPassword(password, hashed)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.changePasswordHashTx(tx, username, hash)\n\t})\n}\n\n// changePasswordHashTx changes a user's password hash in the database\nfunc (a *Manager) changePasswordHashTx(tx *sql.Tx, username, hash string) error {\n\tif _, err := tx.Exec(a.queries.updateUserPass, hash, username); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,\n// all existing access control entries (Grant) are removed, since they are no longer needed.\nfunc (a *Manager) ChangeRole(username string, role Role) error {\n\tif err := a.CanChangeUser(username); err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.changeRoleTx(tx, username, role)\n\t})\n}\n\n// changeRoleTx changes a user's role\nfunc (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {\n\tif !AllowedUsername(username) || !AllowedRole(role) {\n\t\treturn ErrInvalidArgument\n\t}\n\tif _, err := tx.Exec(a.queries.updateUserRole, string(role), username); err != nil {\n\t\treturn err\n\t}\n\t// If changing to admin, remove all access entries\n\tif role == RoleAdmin {\n\t\tif err := a.resetUserAccessTx(tx, username); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// CanChangeUser checks if the user with the given username can be changed.\n// This is used to prevent changes to provisioned users, which are defined in the config file.\nfunc (a *Manager) CanChangeUser(username string) error {\n\tuser, err := a.User(username)\n\tif err != nil {\n\t\treturn err\n\t} else if user.Provisioned {\n\t\treturn ErrProvisionedUserChange\n\t}\n\treturn nil\n}\n\n// changeProvisionedTx changes the provisioned status of a user\nfunc (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {\n\tif _, err := tx.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ChangeSettings persists the user settings\nfunc (a *Manager) ChangeSettings(userID string, prefs *Prefs) error {\n\tb, err := json.Marshal(prefs)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err := a.db.Exec(a.queries.updateUserPrefs, string(b), userID); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages,\n// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere.\nfunc (a *Manager) ChangeTier(username, tier string) error {\n\tif !AllowedUsername(username) {\n\t\treturn ErrInvalidArgument\n\t}\n\tt, err := a.Tier(tier)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif err := a.checkReservationsLimitTx(tx, username, t.ReservationLimit); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.updateUserTier, tier, username); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ResetTier removes the tier from the given user\nfunc (a *Manager) ResetTier(username string) error {\n\tif !AllowedUsername(username) && username != Everyone && username != \"\" {\n\t\treturn ErrInvalidArgument\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif err := a.checkReservationsLimitTx(tx, username, 0); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.deleteUserTier, username); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (a *Manager) checkReservationsLimitTx(tx *sql.Tx, username string, reservationsLimit int64) error {\n\tu, err := a.userTx(tx, username)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit {\n\t\treservations, err := a.reservationsTx(tx, username)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t} else if int64(len(reservations)) > reservationsLimit {\n\t\t\treturn ErrTooManyReservations\n\t\t}\n\t}\n\treturn nil\n}\n\n// ResetStats resets all user stats in the user database. This touches all users.\nfunc (a *Manager) ResetStats() error {\n\ta.mu.Lock() // Includes database query to avoid races!\n\tdefer a.mu.Unlock()\n\tif _, err := a.db.Exec(a.queries.updateUserStatsResetAll); err != nil {\n\t\treturn err\n\t}\n\ta.statsQueue = make(map[string]*Stats)\n\treturn nil\n}\n\n// EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in\n// batches at a regular interval\nfunc (a *Manager) EnqueueUserStats(userID string, stats *Stats) {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\ta.statsQueue[userID] = stats\n}\n\nfunc (a *Manager) asyncQueueWriter(interval time.Duration) {\n\tticker := time.NewTicker(interval)\n\tfor range ticker.C {\n\t\tif err := a.writeUserStatsQueue(); err != nil {\n\t\t\tlog.Tag(tag).Err(err).Warn(\"Writing user stats queue failed\")\n\t\t}\n\t\tif err := a.writeTokenUpdateQueue(); err != nil {\n\t\t\tlog.Tag(tag).Err(err).Warn(\"Writing token update queue failed\")\n\t\t}\n\t}\n}\n\nfunc (a *Manager) writeUserStatsQueue() error {\n\ta.mu.Lock()\n\tif len(a.statsQueue) == 0 {\n\t\ta.mu.Unlock()\n\t\tlog.Tag(tag).Trace(\"No user stats updates to commit\")\n\t\treturn nil\n\t}\n\tstatsQueue := a.statsQueue\n\ta.statsQueue = make(map[string]*Stats)\n\ta.mu.Unlock()\n\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tlog.Tag(tag).Debug(\"Writing user stats queue for %d user(s)\", len(statsQueue))\n\t\tfor userID, update := range statsQueue {\n\t\t\tlog.\n\t\t\t\tTag(tag).\n\t\t\t\tFields(log.Context{\n\t\t\t\t\t\"user_id\":        userID,\n\t\t\t\t\t\"messages_count\": update.Messages,\n\t\t\t\t\t\"emails_count\":   update.Emails,\n\t\t\t\t\t\"calls_count\":    update.Calls,\n\t\t\t\t}).\n\t\t\t\tTrace(\"Updating stats for user %s\", userID)\n\t\t\tif _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// User returns the user with the given username if it exists, or ErrUserNotFound otherwise\nfunc (a *Manager) User(username string) (*User, error) {\n\treturn a.userTx(a.db, username)\n}\n\nfunc (a *Manager) userTx(tx db.Querier, username string) (*User, error) {\n\trows, err := tx.Query(a.queries.selectUserByName, username)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.readUser(rows)\n}\n\n// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise\nfunc (a *Manager) UserByID(id string) (*User, error) {\n\trows, err := a.db.Query(a.queries.selectUserByID, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.readUser(rows)\n}\n\n// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise\nfunc (a *Manager) userByToken(token string) (*User, error) {\n\trows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.readUser(rows)\n}\n\n// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise\nfunc (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {\n\trows, err := a.db.Query(a.queries.selectUserByStripeCustomerID, customerID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.readUser(rows)\n}\n\n// Users returns a list of users. It loads all users in a single query\n// rather than one query per user to avoid N+1 performance issues.\nfunc (a *Manager) Users() ([]*User, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectUsers)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.readUsers(rows)\n}\n\n// UsersCount returns the number of users in the database\nfunc (a *Manager) UsersCount() (int64, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectUserCount)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn 0, errNoRows\n\t}\n\tvar count int64\n\tif err := rows.Scan(&count); err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\nfunc (a *Manager) readUser(rows *sql.Rows) (*User, error) {\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn nil, ErrUserNotFound\n\t}\n\tuser, err := a.scanUser(rows)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn user, nil\n}\n\nfunc (a *Manager) readUsers(rows *sql.Rows) ([]*User, error) {\n\tdefer rows.Close()\n\tusers := make([]*User, 0)\n\tfor rows.Next() {\n\t\tuser, err := a.scanUser(rows)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tusers = append(users, user)\n\t}\n\treturn users, nil\n}\n\nfunc (a *Manager) scanUser(rows *sql.Rows) (*User, error) {\n\tvar id, username, hash, role, prefs, syncTopic string\n\tvar provisioned bool\n\tvar stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString\n\tvar messages, emails, calls int64\n\tvar messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64\n\tif err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {\n\t\treturn nil, err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\tuser := &User{\n\t\tID:          id,\n\t\tName:        username,\n\t\tHash:        hash,\n\t\tRole:        Role(role),\n\t\tPrefs:       &Prefs{},\n\t\tSyncTopic:   syncTopic,\n\t\tProvisioned: provisioned,\n\t\tStats: &Stats{\n\t\t\tMessages: messages,\n\t\t\tEmails:   emails,\n\t\t\tCalls:    calls,\n\t\t},\n\t\tBilling: &Billing{\n\t\t\tStripeCustomerID:            stripeCustomerID.String,                                            // May be empty\n\t\t\tStripeSubscriptionID:        stripeSubscriptionID.String,                                        // May be empty\n\t\t\tStripeSubscriptionStatus:    payments.SubscriptionStatus(stripeSubscriptionStatus.String),       // May be empty\n\t\t\tStripeSubscriptionInterval:  payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty\n\t\t\tStripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0),                    // May be zero\n\t\t\tStripeSubscriptionCancelAt:  time.Unix(stripeSubscriptionCancelAt.Int64, 0),                     // May be zero\n\t\t},\n\t\tDeleted: deleted.Valid,\n\t}\n\tif err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil {\n\t\treturn nil, err\n\t}\n\tif tierCode.Valid {\n\t\t// See readTier() when this is changed!\n\t\tuser.Tier = &Tier{\n\t\t\tID:                       tierID.String,\n\t\t\tCode:                     tierCode.String,\n\t\t\tName:                     tierName.String,\n\t\t\tMessageLimit:             messagesLimit.Int64,\n\t\t\tMessageExpiryDuration:    time.Duration(messagesExpiryDuration.Int64) * time.Second,\n\t\t\tEmailLimit:               emailsLimit.Int64,\n\t\t\tCallLimit:                callsLimit.Int64,\n\t\t\tReservationLimit:         reservationsLimit.Int64,\n\t\t\tAttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,\n\t\t\tAttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,\n\t\t\tAttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,\n\t\t\tAttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,\n\t\t\tStripeMonthlyPriceID:     stripeMonthlyPriceID.String, // May be empty\n\t\t\tStripeYearlyPriceID:      stripeYearlyPriceID.String,  // May be empty\n\t\t}\n\t}\n\treturn user, nil\n}\n\nfunc (a *Manager) maybeHashPassword(password string, hashed bool) (string, error) {\n\tif hashed {\n\t\tif err := ValidPasswordHash(password, a.config.BcryptCost); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn password, nil\n\t}\n\treturn hashPassword(password, a.config.BcryptCost)\n}\n\n// Authorize returns nil if the given user has access to the given topic using the desired\n// permission. The user param may be nil to signal an anonymous user.\nfunc (a *Manager) Authorize(user *User, topic string, perm Permission) error {\n\tif user != nil && user.Role == RoleAdmin {\n\t\treturn nil // Admin can do everything\n\t}\n\tusername := Everyone\n\tif user != nil {\n\t\tusername = user.Name\n\t}\n\t// Select the read/write permissions for this user/topic combo.\n\tread, write, found, err := a.authorizeTopicAccess(username, topic)\n\tif err != nil {\n\t\treturn err\n\t} else if !found {\n\t\treturn a.resolvePerms(a.config.DefaultAccess, perm)\n\t}\n\treturn a.resolvePerms(NewPermission(read, write), perm)\n}\n\nfunc (a *Manager) resolvePerms(base, perm Permission) error {\n\tif perm == PermissionRead && base.IsRead() {\n\t\treturn nil\n\t} else if perm == PermissionWrite && base.IsWrite() {\n\t\treturn nil\n\t}\n\treturn ErrUnauthorized\n}\n\n// AllowAccess adds or updates an entry in the access control list for a specific user. It controls\n// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry\n// owner may either be a user (username), or the system (empty).\nfunc (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.allowAccessTx(tx, username, topicPattern, permission, false)\n\t})\n}\n\nfunc (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {\n\tif !AllowedUsername(username) && username != Everyone {\n\t\treturn ErrInvalidArgument\n\t} else if !AllowedTopicPattern(topicPattern) {\n\t\treturn ErrInvalidArgument\n\t}\n\t_, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), \"\", \"\", provisioned)\n\treturn err\n}\n\n// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is\n// empty) for an entire user. The parameter topicPattern may include wildcards (*).\nfunc (a *Manager) ResetAccess(username string, topicPattern string) error {\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\treturn a.resetAccessTx(tx, username, topicPattern)\n\t})\n}\n\nfunc (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {\n\tif !AllowedUsername(username) && username != Everyone && username != \"\" {\n\t\treturn ErrInvalidArgument\n\t} else if !AllowedTopicPattern(topicPattern) && topicPattern != \"\" {\n\t\treturn ErrInvalidArgument\n\t}\n\tif username == \"\" && topicPattern == \"\" {\n\t\t_, err := tx.Exec(a.queries.deleteAllAccess)\n\t\treturn err\n\t} else if topicPattern == \"\" {\n\t\treturn a.resetUserAccessTx(tx, username)\n\t}\n\treturn a.resetTopicAccessTx(tx, username, topicPattern)\n}\n\n// DefaultAccess returns the default read/write access if no access control entry matches\nfunc (a *Manager) DefaultAccess() Permission {\n\treturn a.config.DefaultAccess\n}\n\n// AllowReservation tests if a user may create an access control entry for the given topic.\n// If there are any ACL entries that are not owned by the user, an error is returned.\nfunc (a *Manager) AllowReservation(username string, topic string) error {\n\tif (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) {\n\t\treturn ErrInvalidArgument\n\t}\n\totherCount, err := a.otherAccessCount(username, topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif otherCount > 0 {\n\t\treturn errTopicOwnedByOthers\n\t}\n\treturn nil\n}\n\n// authorizeTopicAccess returns the read/write permissions for the given username and topic.\n// The found return value indicates whether an ACL entry was found at all.\n//\n// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.\n// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. \"test*\" > \"*\"\n// - It also prioritizes write permissions over read permissions\nfunc (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)\n\tif err != nil {\n\t\treturn false, false, false, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn false, false, false, nil\n\t}\n\tif err := rows.Scan(&read, &write); err != nil {\n\t\treturn false, false, false, err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn false, false, false, err\n\t}\n\treturn read, write, true, nil\n}\n\n// AllGrants returns all user-specific access control entries, mapped to their respective user IDs\nfunc (a *Manager) AllGrants() (map[string][]Grant, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectUserAllAccess)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tgrants := make(map[string][]Grant, 0)\n\tfor rows.Next() {\n\t\tvar userID, topic string\n\t\tvar read, write, provisioned bool\n\t\tif err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {\n\t\t\treturn nil, err\n\t\t} else if err := rows.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif _, ok := grants[userID]; !ok {\n\t\t\tgrants[userID] = make([]Grant, 0)\n\t\t}\n\t\tgrants[userID] = append(grants[userID], Grant{\n\t\t\tTopicPattern: fromSQLWildcard(topic),\n\t\t\tPermission:   NewPermission(read, write),\n\t\t\tProvisioned:  provisioned,\n\t\t})\n\t}\n\treturn grants, nil\n}\n\n// Grants returns all user-specific access control entries\nfunc (a *Manager) Grants(username string) ([]Grant, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectUserAccess, username)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tgrants := make([]Grant, 0)\n\tfor rows.Next() {\n\t\tvar topic string\n\t\tvar read, write, provisioned bool\n\t\tif err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {\n\t\t\treturn nil, err\n\t\t} else if err := rows.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tgrants = append(grants, Grant{\n\t\t\tTopicPattern: fromSQLWildcard(topic),\n\t\t\tPermission:   NewPermission(read, write),\n\t\t\tProvisioned:  provisioned,\n\t\t})\n\t}\n\treturn grants, nil\n}\n\n// AddReservation creates two access control entries for the given topic: one with full read/write\n// access for the given user, and one for Everyone with the given permission. Both entries are\n// created atomically in a single transaction. If limit is > 0, the reservation count is checked\n// inside the transaction and ErrTooManyReservations is returned if the limit would be exceeded.\nfunc (a *Manager) AddReservation(username string, topic string, everyone Permission, limit int64) error {\n\tif !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {\n\t\treturn ErrInvalidArgument\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif limit > 0 {\n\t\t\thasReservation, err := a.hasReservationTx(tx, username, topic)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif !hasReservation {\n\t\t\t\tcount, err := a.reservationsCountTx(tx, username)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif count >= limit {\n\t\t\t\t\treturn ErrTooManyReservations\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), true, true, username, username, false); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.upsertUserAccess, Everyone, toSQLWildcard(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// RemoveReservations deletes the access control entries associated with the given username/topic,\n// as well as all entries with Everyone/topic. All deletions are performed atomically in a single\n// transaction.\nfunc (a *Manager) RemoveReservations(username string, topics ...string) error {\n\tif !AllowedUsername(username) || username == Everyone || len(topics) == 0 {\n\t\treturn ErrInvalidArgument\n\t}\n\tfor _, topic := range topics {\n\t\tif !AllowedTopic(topic) {\n\t\t\treturn ErrInvalidArgument\n\t\t}\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tfor _, topic := range topics {\n\t\t\tif err := a.removeReservationAccessTx(tx, username, topic); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// Reservations returns all user-owned topics, and the associated everyone-access\nfunc (a *Manager) Reservations(username string) ([]Reservation, error) {\n\treturn a.reservationsTx(a.db.ReadOnly(), username)\n}\n\nfunc (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {\n\trows, err := tx.Query(a.queries.selectUserReservations, Everyone, username)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treservations := make([]Reservation, 0)\n\tfor rows.Next() {\n\t\tvar topic string\n\t\tvar ownerRead, ownerWrite bool\n\t\tvar everyoneRead, everyoneWrite sql.NullBool\n\t\tif err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil {\n\t\t\treturn nil, err\n\t\t} else if err := rows.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treservations = append(reservations, Reservation{\n\t\t\tTopic:    fromSQLWildcard(topic),\n\t\t\tOwner:    NewPermission(ownerRead, ownerWrite),\n\t\t\tEveryone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool),\n\t\t})\n\t}\n\treturn reservations, nil\n}\n\n// HasReservation returns true if the given topic access is owned by the user\nfunc (a *Manager) HasReservation(username, topic string) (bool, error) {\n\treturn a.hasReservationTx(a.db, username, topic)\n}\n\nfunc (a *Manager) hasReservationTx(tx db.Querier, username, topic string) (bool, error) {\n\trows, err := tx.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic))\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn false, errNoRows\n\t}\n\tvar count int64\n\tif err := rows.Scan(&count); err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\n// ReservationsCount returns the number of reservations owned by this user\nfunc (a *Manager) ReservationsCount(username string) (int64, error) {\n\treturn a.reservationsCountTx(a.db, username)\n}\n\nfunc (a *Manager) reservationsCountTx(tx db.Querier, username string) (int64, error) {\n\trows, err := tx.Query(a.queries.selectUserReservationsCount, username)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn 0, errNoRows\n\t}\n\tvar count int64\n\tif err := rows.Scan(&count); err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\n// ReservationOwner returns user ID of the user that owns this topic, or an empty string if it's not owned by anyone\nfunc (a *Manager) ReservationOwner(topic string) (string, error) {\n\trows, err := a.db.Query(a.queries.selectUserReservationsOwner, escapeUnderscore(topic))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn \"\", nil\n\t}\n\tvar ownerUserID string\n\tif err := rows.Scan(&ownerUserID); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn ownerUserID, nil\n}\n\n// RemoveExcessReservations removes reservations that exceed the given limit for the user.\n// It returns the list of topics whose reservations were removed. The read and removal are\n// performed atomically in a single transaction to avoid issues with stale replica data.\nfunc (a *Manager) RemoveExcessReservations(username string, limit int64) ([]string, error) {\n\treturn db.QueryTx(a.db, func(tx *sql.Tx) ([]string, error) {\n\t\treservations, err := a.reservationsTx(tx, username)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif int64(len(reservations)) <= limit {\n\t\t\treturn []string{}, nil\n\t\t}\n\t\tremovedTopics := make([]string, 0)\n\t\tfor i := int64(len(reservations)) - 1; i >= limit; i-- {\n\t\t\ttopic := reservations[i].Topic\n\t\t\tif err := a.removeReservationAccessTx(tx, username, topic); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tremovedTopics = append(removedTopics, topic)\n\t\t}\n\t\treturn removedTopics, nil\n\t})\n}\n\n// otherAccessCount returns the number of access entries for the given topic that are not owned by the user\nfunc (a *Manager) otherAccessCount(username, topic string) (int, error) {\n\trows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer rows.Close()\n\tif !rows.Next() {\n\t\treturn 0, errNoRows\n\t}\n\tvar count int\n\tif err := rows.Scan(&count); err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\nfunc (a *Manager) removeReservationAccessTx(tx *sql.Tx, username, topic string) error {\n\tif err := a.resetTopicAccessTx(tx, username, topic); err != nil {\n\t\treturn err\n\t}\n\treturn a.resetTopicAccessTx(tx, Everyone, topic)\n}\n\nfunc (a *Manager) resetUserAccessTx(tx *sql.Tx, username string) error {\n\tif !AllowedUsername(username) && username != Everyone {\n\t\treturn ErrInvalidArgument\n\t}\n\t_, err := tx.Exec(a.queries.deleteUserAccess, username, username)\n\treturn err\n}\n\nfunc (a *Manager) resetTopicAccessTx(tx *sql.Tx, username, topicPattern string) error {\n\tif !AllowedUsername(username) && username != Everyone && username != \"\" {\n\t\treturn ErrInvalidArgument\n\t} else if !AllowedTopicPattern(topicPattern) && topicPattern != \"\" {\n\t\treturn ErrInvalidArgument\n\t}\n\t_, err := tx.Exec(a.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern))\n\treturn err\n}\n\n// CreateToken generates a random token for the given user and returns it. The token expires\n// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the\n// given user, if there are too many of them.\nfunc (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {\n\treturn db.QueryTx(a.db, func(tx *sql.Tx) (*Token, error) {\n\t\treturn a.createTokenTx(tx, userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned)\n\t})\n}\n\n// createTokenTx creates a new token and prunes excess tokens if the count exceeds maxTokenCount.\n// If maxTokenCount is 0, no pruning is performed.\nfunc (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) {\n\tif _, err := tx.Exec(a.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil {\n\t\treturn nil, err\n\t}\n\tif maxTokenCount > 0 {\n\t\tvar tokenCount int\n\t\tif err := tx.QueryRow(a.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif tokenCount > maxTokenCount {\n\t\t\t// This pruning logic is done in two queries for efficiency. The SELECT above is a lookup\n\t\t\t// on two indices, whereas the query below is a full table scan.\n\t\t\tif _, err := tx.Exec(a.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn &Token{\n\t\tValue:       token,\n\t\tLabel:       label,\n\t\tLastAccess:  lastAccess,\n\t\tLastOrigin:  lastOrigin,\n\t\tExpires:     expires,\n\t\tProvisioned: provisioned,\n\t}, nil\n}\n\n// ChangeToken updates a token's label and/or expiry date\nfunc (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) {\n\tif token == \"\" {\n\t\treturn nil, errNoTokenProvided\n\t}\n\tif err := a.canChangeToken(userID, token); err != nil {\n\t\treturn nil, err\n\t}\n\tt, err := a.Token(userID, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif label != nil {\n\t\tt.Label = *label\n\t}\n\tif expires != nil {\n\t\tt.Expires = *expires\n\t}\n\tif _, err := a.db.Exec(a.queries.updateToken, t.Label, t.Expires.Unix(), userID, token); err != nil {\n\t\treturn nil, err\n\t}\n\treturn t, nil\n}\n\n// RemoveToken deletes the token defined in User.Token\nfunc (a *Manager) RemoveToken(userID, token string) error {\n\tif err := a.canChangeToken(userID, token); err != nil {\n\t\treturn err\n\t}\n\tif token == \"\" {\n\t\treturn errNoTokenProvided\n\t}\n\tif _, err := a.db.Exec(a.queries.deleteToken, userID, token); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// canChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed.\nfunc (a *Manager) canChangeToken(userID, token string) error {\n\tt, err := a.Token(userID, token)\n\tif err != nil {\n\t\treturn err\n\t} else if t.Provisioned {\n\t\treturn ErrProvisionedTokenChange\n\t}\n\treturn nil\n}\n\n// Token returns a specific token for a user\nfunc (a *Manager) Token(userID, token string) (*Token, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectToken, userID, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treturn a.readToken(rows)\n}\n\n// Tokens returns all existing tokens for the user with the given user ID\nfunc (a *Manager) Tokens(userID string) ([]*Token, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\ttokens := make([]*Token, 0)\n\tfor {\n\t\ttoken, err := a.readToken(rows)\n\t\tif errors.Is(err, ErrTokenNotFound) {\n\t\t\tbreak\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttokens = append(tokens, token)\n\t}\n\treturn tokens, nil\n}\n\nfunc (a *Manager) allProvisionedTokens() ([]*Token, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectAllProvisionedTokens)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\ttokens := make([]*Token, 0)\n\tfor {\n\t\ttoken, err := a.readToken(rows)\n\t\tif errors.Is(err, ErrTokenNotFound) {\n\t\t\tbreak\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttokens = append(tokens, token)\n\t}\n\treturn tokens, nil\n}\n\n// RemoveExpiredTokens deletes all expired tokens from the database\nfunc (a *Manager) RemoveExpiredTokens() error {\n\tif _, err := a.db.Exec(a.queries.deleteExpiredTokens, time.Now().Unix()); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// EnqueueTokenUpdate adds the token update to a queue which writes out token access times\n// in batches at a regular interval\nfunc (a *Manager) EnqueueTokenUpdate(tokenID string, update *TokenUpdate) {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\ta.tokenQueue[tokenID] = update\n}\n\nfunc (a *Manager) writeTokenUpdateQueue() error {\n\ta.mu.Lock()\n\tif len(a.tokenQueue) == 0 {\n\t\ta.mu.Unlock()\n\t\tlog.Tag(tag).Trace(\"No token updates to commit\")\n\t\treturn nil\n\t}\n\ttokenQueue := a.tokenQueue\n\ta.tokenQueue = make(map[string]*TokenUpdate)\n\ta.mu.Unlock()\n\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tlog.Tag(tag).Debug(\"Writing token update queue for %d token(s)\", len(tokenQueue))\n\t\tfor tokenID, update := range tokenQueue {\n\t\t\tlog.Tag(tag).Trace(\"Updating token %s with last access time %v\", tokenID, update.LastAccess.Unix())\n\t\t\tif err := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (a *Manager) updateTokenLastAccessTx(tx *sql.Tx, token string, lastAccess int64, lastOrigin string) error {\n\tif _, err := tx.Exec(a.queries.updateTokenLastAccess, lastAccess, lastOrigin, token); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (a *Manager) readToken(rows *sql.Rows) (*Token, error) {\n\tvar token, label, lastOrigin string\n\tvar lastAccess, expires int64\n\tvar provisioned bool\n\tif !rows.Next() {\n\t\treturn nil, ErrTokenNotFound\n\t}\n\tif err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil {\n\t\treturn nil, err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\tlastOriginIP, err := netip.ParseAddr(lastOrigin)\n\tif err != nil {\n\t\tlastOriginIP = netip.IPv4Unspecified()\n\t}\n\treturn &Token{\n\t\tValue:       token,\n\t\tLabel:       label,\n\t\tLastAccess:  time.Unix(lastAccess, 0),\n\t\tLastOrigin:  lastOriginIP,\n\t\tExpires:     time.Unix(expires, 0),\n\t\tProvisioned: provisioned,\n\t}, nil\n}\n\n// AddTier creates a new tier in the database\nfunc (a *Manager) AddTier(tier *Tier) error {\n\tif tier.ID == \"\" {\n\t\ttier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)\n\t}\n\tif _, err := a.db.Exec(a.queries.insertTier, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// UpdateTier updates a tier's properties in the database\nfunc (a *Manager) UpdateTier(tier *Tier) error {\n\tif _, err := a.db.Exec(a.queries.updateTier, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// RemoveTier deletes the tier with the given code\nfunc (a *Manager) RemoveTier(code string) error {\n\tif !AllowedTier(code) {\n\t\treturn ErrInvalidArgument\n\t}\n\t// This fails if any user has this tier\n\tif _, err := a.db.Exec(a.queries.deleteTier, code); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Tiers returns a list of all Tier structs\nfunc (a *Manager) Tiers() ([]*Tier, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectTiers)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\ttiers := make([]*Tier, 0)\n\tfor {\n\t\ttier, err := a.readTier(rows)\n\t\tif errors.Is(err, ErrTierNotFound) {\n\t\t\tbreak\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttiers = append(tiers, tier)\n\t}\n\treturn tiers, nil\n}\n\n// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist\nfunc (a *Manager) Tier(code string) (*Tier, error) {\n\trows, err := a.db.Query(a.queries.selectTierByCode, code)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treturn a.readTier(rows)\n}\n\n// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist\nfunc (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {\n\trows, err := a.db.Query(a.queries.selectTierByPriceID, priceID, priceID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treturn a.readTier(rows)\n}\n\nfunc (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {\n\tvar id, code, name string\n\tvar stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString\n\tvar messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64\n\tif !rows.Next() {\n\t\treturn nil, ErrTierNotFound\n\t}\n\tif err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {\n\t\treturn nil, err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\t// When changed, note readUser() as well\n\treturn &Tier{\n\t\tID:                       id,\n\t\tCode:                     code,\n\t\tName:                     name,\n\t\tMessageLimit:             messagesLimit.Int64,\n\t\tMessageExpiryDuration:    time.Duration(messagesExpiryDuration.Int64) * time.Second,\n\t\tEmailLimit:               emailsLimit.Int64,\n\t\tCallLimit:                callsLimit.Int64,\n\t\tReservationLimit:         reservationsLimit.Int64,\n\t\tAttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,\n\t\tAttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,\n\t\tAttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,\n\t\tAttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,\n\t\tStripeMonthlyPriceID:     stripeMonthlyPriceID.String, // May be empty\n\t\tStripeYearlyPriceID:      stripeYearlyPriceID.String,  // May be empty\n\t}, nil\n}\n\n// PhoneNumbers returns all phone numbers for the user with the given user ID\nfunc (a *Manager) PhoneNumbers(userID string) ([]string, error) {\n\trows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tphoneNumbers := make([]string, 0)\n\tfor {\n\t\tphoneNumber, err := a.readPhoneNumber(rows)\n\t\tif errors.Is(err, ErrPhoneNumberNotFound) {\n\t\t\tbreak\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tphoneNumbers = append(phoneNumbers, phoneNumber)\n\t}\n\treturn phoneNumbers, nil\n}\n\n// AddPhoneNumber adds a phone number to the user with the given user ID\nfunc (a *Manager) AddPhoneNumber(userID, phoneNumber string) error {\n\tif _, err := a.db.Exec(a.queries.insertPhoneNumber, userID, phoneNumber); err != nil {\n\t\tif isUniqueConstraintError(err) {\n\t\t\treturn ErrPhoneNumberExists\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// RemovePhoneNumber deletes a phone number from the user with the given user ID\nfunc (a *Manager) RemovePhoneNumber(userID, phoneNumber string) error {\n\t_, err := a.db.Exec(a.queries.deletePhoneNumber, userID, phoneNumber)\n\treturn err\n}\n\nfunc (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {\n\tvar phoneNumber string\n\tif !rows.Next() {\n\t\treturn \"\", ErrPhoneNumberNotFound\n\t}\n\tif err := rows.Scan(&phoneNumber); err != nil {\n\t\treturn \"\", err\n\t} else if err := rows.Err(); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn phoneNumber, nil\n}\n\n// ChangeBilling updates a user's billing fields\nfunc (a *Manager) ChangeBilling(username string, billing *Billing) error {\n\tif _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config.\nfunc (a *Manager) maybeProvisionUsersAccessAndTokens() error {\n\tif !a.config.ProvisionEnabled {\n\t\treturn nil\n\t}\n\t// If there is nothing to provision, remove any previously provisioned items using\n\t// cheap targeted queries, avoiding the expensive Users() call that loads all users.\n\tif len(a.config.Users) == 0 && len(a.config.Access) == 0 && len(a.config.Tokens) == 0 {\n\t\treturn a.removeAllProvisioned()\n\t}\n\t// If there are provisioned users, do it the slow way\n\texistingUsers, err := a.Users()\n\tif err != nil {\n\t\treturn err\n\t}\n\tprovisionUsernames := util.Map(a.config.Users, func(u *User) string {\n\t\treturn u.Name\n\t})\n\texistingTokens, err := a.allProvisionedTokens()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to provision users: %v\", err)\n\t\t}\n\t\tif err := a.maybeProvisionGrants(tx); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to provision grants: %v\", err)\n\t\t}\n\t\tif err := a.maybeProvisionTokens(tx, provisionUsernames, existingTokens); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to provision tokens: %v\", err)\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// removeAllProvisioned removes all provisioned users, access entries, and tokens. This is the fast path\n// for when there is nothing to provision, avoiding the expensive Users() call.\nfunc (a *Manager) removeAllProvisioned() error {\n\treturn db.ExecTx(a.db, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(a.queries.deleteUserAccessProvisioned); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.deleteAllProvisionedTokens); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(a.queries.deleteUsersProvisioned); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them.\n// It also removes users that are provisioned, but not in the config anymore.\nfunc (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error {\n\t// Remove users that are provisioned, but not in the config anymore\n\tfor _, user := range existingUsers {\n\t\tif user.Name == Everyone {\n\t\t\tcontinue\n\t\t} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {\n\t\t\tif err := a.removeUserTx(tx, user.Name); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to remove provisioned user %s: %v\", user.Name, err)\n\t\t\t}\n\t\t}\n\t}\n\t// Add or update provisioned users\n\tfor _, user := range a.config.Users {\n\t\tif user.Name == Everyone {\n\t\t\tcontinue\n\t\t}\n\t\texistingUser, exists := util.Find(existingUsers, func(u *User) bool {\n\t\t\treturn u.Name == user.Name\n\t\t})\n\t\tif !exists {\n\t\t\tif err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) {\n\t\t\t\treturn fmt.Errorf(\"failed to add provisioned user %s: %v\", user.Name, err)\n\t\t\t}\n\t\t} else {\n\t\t\tif !existingUser.Provisioned {\n\t\t\t\tif err := a.changeProvisionedTx(tx, user.Name, true); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to change provisioned status for user %s: %v\", user.Name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif existingUser.Hash != user.Hash {\n\t\t\t\tif err := a.changePasswordHashTx(tx, user.Name, user.Hash); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to change password for provisioned user %s: %v\", user.Name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif existingUser.Role != user.Role {\n\t\t\t\tif err := a.changeRoleTx(tx, user.Name, user.Role); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to change role for provisioned user %s: %v\", user.Name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// maybeProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config.\n//\n// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last\n// access time) or do not have dependent resources (such as grants or tokens).\nfunc (a *Manager) maybeProvisionGrants(tx *sql.Tx) error {\n\t// Remove all provisioned grants\n\tif _, err := tx.Exec(a.queries.deleteUserAccessProvisioned); err != nil {\n\t\treturn err\n\t}\n\t// (Re-)add provisioned grants\n\tfor username, grants := range a.config.Access {\n\t\tuser, exists := util.Find(a.config.Users, func(u *User) bool {\n\t\t\treturn u.Name == username\n\t\t})\n\t\tif !exists && username != Everyone {\n\t\t\treturn fmt.Errorf(\"user %s is not a provisioned user, refusing to add ACL entry\", username)\n\t\t} else if user != nil && user.Role == RoleAdmin {\n\t\t\treturn fmt.Errorf(\"adding access control entries is not allowed for admin roles for user %s\", username)\n\t\t}\n\t\tfor _, grant := range grants {\n\t\t\tif err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to reset access for user %s and topic %s: %v\", username, grant.TopicPattern, err)\n\t\t\t}\n\t\t\tif err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string, existingTokens []*Token) error {\n\t// Remove tokens that are provisioned, but not in the config anymore\n\tvar provisionTokens []string\n\tfor _, userTokens := range a.config.Tokens {\n\t\tfor _, token := range userTokens {\n\t\t\tprovisionTokens = append(provisionTokens, token.Value)\n\t\t}\n\t}\n\tfor _, existingToken := range existingTokens {\n\t\tif !slices.Contains(provisionTokens, existingToken.Value) {\n\t\t\tif _, err := tx.Exec(a.queries.deleteProvisionedToken, existingToken.Value); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to remove provisioned token %s: %v\", existingToken.Value, err)\n\t\t\t}\n\t\t}\n\t}\n\t// (Re-)add provisioned tokens\n\tfor username, tokens := range a.config.Tokens {\n\t\tif !slices.Contains(provisionUsernames, username) && username != Everyone {\n\t\t\treturn fmt.Errorf(\"user %s is not a provisioned user, refusing to add tokens\", username)\n\t\t}\n\t\tvar userID string\n\t\tif err := tx.QueryRow(a.queries.selectUserIDFromUsername, username).Scan(&userID); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to find provisioned user %s for provisioned tokens: %v\", username, err)\n\t\t}\n\t\tfor _, token := range tokens {\n\t\t\tif _, err := a.createTokenTx(tx, userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// Close closes the underlying database\nfunc (a *Manager) Close() error {\n\treturn a.db.Close()\n}\n\n// isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL\nfunc isUniqueConstraintError(err error) bool {\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"UNIQUE constraint failed\") || strings.Contains(errStr, \"23505\")\n}\n"
  },
  {
    "path": "user/manager_postgres.go",
    "content": "package user\n\nimport (\n\t\"heckel.io/ntfy/v2/db\"\n)\n\n// PostgreSQL queries\nconst (\n\t// User queries\n\tpostgresSelectUsersQuery = `\n\t\tSELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM \"user\" u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tORDER BY\n\t\t\tCASE u.role\n\t\t\t\tWHEN 'admin' THEN 1\n\t\t\t\tWHEN 'anonymous' THEN 3\n\t\t\t\tELSE 2\n\t\t\tEND, u.user_name\n\t`\n\tpostgresSelectUserByIDQuery = `\n\t\tSELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM \"user\" u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE u.id = $1\n\t`\n\tpostgresSelectUserByNameQuery = `\n\t\tSELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM \"user\" u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE user_name = $1\n\t`\n\tpostgresSelectUserByTokenQuery = `\n\t\tSELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM \"user\" u\n\t\tJOIN user_token tk on u.id = tk.user_id\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE tk.token = $1 AND (tk.expires = 0 OR tk.expires >= $2)\n\t`\n\tpostgresSelectUserByStripeCustomerIDQuery = `\n\t\tSELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM \"user\" u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE u.stripe_customer_id = $1\n\t`\n\tpostgresSelectUsernamesQuery = `\n\t\tSELECT user_name\n\t\tFROM \"user\"\n\t\tORDER BY\n\t\t\tCASE role\n\t\t\t\tWHEN 'admin' THEN 1\n\t\t\t\tWHEN 'anonymous' THEN 3\n\t\t\t\tELSE 2\n\t\t\tEND, user_name\n\t`\n\tpostgresSelectUserCountQuery          = `SELECT COUNT(*) FROM \"user\"`\n\tpostgresSelectUserIDFromUsernameQuery = `SELECT id FROM \"user\" WHERE user_name = $1`\n\tpostgresInsertUserQuery               = `INSERT INTO \"user\" (id, user_name, pass, role, sync_topic, provisioned, created) VALUES ($1, $2, $3, $4, $5, $6, $7)`\n\tpostgresUpdateUserPassQuery           = `UPDATE \"user\" SET pass = $1 WHERE user_name = $2`\n\tpostgresUpdateUserRoleQuery           = `UPDATE \"user\" SET role = $1 WHERE user_name = $2`\n\tpostgresUpdateUserProvisionedQuery    = `UPDATE \"user\" SET provisioned = $1 WHERE user_name = $2`\n\tpostgresUpdateUserPrefsQuery          = `UPDATE \"user\" SET prefs = $1 WHERE id = $2`\n\tpostgresUpdateUserStatsQuery          = `UPDATE \"user\" SET stats_messages = $1, stats_emails = $2, stats_calls = $3 WHERE id = $4`\n\tpostgresUpdateUserStatsResetAllQuery  = `UPDATE \"user\" SET stats_messages = 0, stats_emails = 0, stats_calls = 0`\n\tpostgresUpdateUserTierQuery           = `UPDATE \"user\" SET tier_id = (SELECT id FROM tier WHERE code = $1) WHERE user_name = $2`\n\tpostgresUpdateUserDeletedQuery        = `UPDATE \"user\" SET deleted = $1 WHERE id = $2`\n\tpostgresDeleteUserQuery               = `DELETE FROM \"user\" WHERE user_name = $1`\n\tpostgresDeleteUserTierQuery           = `UPDATE \"user\" SET tier_id = null WHERE user_name = $1`\n\tpostgresDeleteUsersMarkedQuery        = `DELETE FROM \"user\" WHERE deleted < $1`\n\tpostgresDeleteUsersProvisionedQuery   = `DELETE FROM \"user\" WHERE provisioned = true`\n\n\t// Access queries\n\tpostgresSelectTopicPermsQuery = `\n\t\tSELECT read, write\n\t\tFROM user_access a\n\t\tJOIN \"user\" u ON u.id = a.user_id\n\t\tWHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\\'\n\t\tORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC\n\t`\n\tpostgresSelectUserAllAccessQuery = `\n\t\tSELECT user_id, topic, read, write, provisioned\n\t\tFROM user_access\n\t\tORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic\n\t`\n\tpostgresSelectUserAccessQuery = `\n\t\tSELECT topic, read, write, provisioned\n\t\tFROM user_access\n\t\tWHERE user_id = (SELECT id FROM \"user\" WHERE user_name = $1)\n\t\tORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic\n\t`\n\tpostgresSelectUserReservationsQuery = `\n\t\tSELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write\n\t\tFROM user_access a_user\n\t\tLEFT JOIN  user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM \"user\" WHERE user_name = $1)\n\t\tWHERE a_user.user_id = a_user.owner_user_id\n\t\t  AND a_user.owner_user_id = (SELECT id FROM \"user\" WHERE user_name = $2)\n\t\tORDER BY a_user.topic\n\t`\n\tpostgresSelectUserReservationsCountQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE user_id = owner_user_id\n\t\t  AND owner_user_id = (SELECT id FROM \"user\" WHERE user_name = $1)\n\t`\n\tpostgresSelectUserReservationsOwnerQuery = `\n\t\tSELECT owner_user_id\n\t\tFROM user_access\n\t\tWHERE topic = $1\n\t\t  AND user_id = owner_user_id\n\t`\n\tpostgresSelectUserHasReservationQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE user_id = owner_user_id\n\t\t  AND owner_user_id = (SELECT id FROM \"user\" WHERE user_name = $1)\n\t\t  AND topic = $2\n\t`\n\tpostgresSelectOtherAccessCountQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE (topic = $1 OR $2 LIKE topic ESCAPE '\\')\n\t\t  AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM \"user\" WHERE user_name = $3))\n\t`\n\tpostgresUpsertUserAccessQuery = `\n\t\tINSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)\n\t\tVALUES (\n\t\t\t(SELECT id FROM \"user\" WHERE user_name = $1),\n\t\t\t$2,\n\t\t\t$3,\n\t\t\t$4,\n\t\t\tCASE WHEN $5 = '' THEN NULL ELSE (SELECT id FROM \"user\" WHERE user_name = $6) END,\n\t\t\t$7\n\t\t)\n\t\tON CONFLICT (user_id, topic)\n\t\tDO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned\n\t`\n\tpostgresDeleteUserAccessQuery = `\n\t\tDELETE FROM user_access\n\t\tWHERE user_id = (SELECT id FROM \"user\" WHERE user_name = $1)\n\t\t   OR owner_user_id = (SELECT id FROM \"user\" WHERE user_name = $2)\n\t`\n\tpostgresDeleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = true`\n\tpostgresDeleteTopicAccessQuery           = `\n\t\tDELETE FROM user_access\n\t   \tWHERE (user_id = (SELECT id FROM \"user\" WHERE user_name = $1) OR owner_user_id = (SELECT id FROM \"user\" WHERE user_name = $2))\n\t   \t  AND topic = $3\n  \t`\n\tpostgresDeleteAllAccessQuery = `DELETE FROM user_access`\n\n\t// Token queries\n\tpostgresSelectTokenQuery                = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1 AND token = $2`\n\tpostgresSelectTokensQuery               = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1`\n\tpostgresSelectTokenCountQuery           = `SELECT COUNT(*) FROM user_token WHERE user_id = $1`\n\tpostgresSelectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = true`\n\tpostgresUpsertTokenQuery                = `\n\t\tINSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\tON CONFLICT (user_id, token)\n\t\tDO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned\n\t`\n\tpostgresUpdateTokenQuery                = `UPDATE user_token SET label = $1, expires = $2 WHERE user_id = $3 AND token = $4`\n\tpostgresUpdateTokenLastAccessQuery      = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3`\n\tpostgresDeleteTokenQuery                = `DELETE FROM user_token WHERE user_id = $1 AND token = $2`\n\tpostgresDeleteProvisionedTokenQuery     = `DELETE FROM user_token WHERE token = $1`\n\tpostgresDeleteAllProvisionedTokensQuery = `DELETE FROM user_token WHERE provisioned = true`\n\tpostgresDeleteAllTokenQuery             = `DELETE FROM user_token WHERE user_id = $1`\n\tpostgresDeleteExpiredTokensQuery        = `DELETE FROM user_token WHERE expires > 0 AND expires < $1`\n\tpostgresDeleteExcessTokensQuery         = `\n\t\tDELETE FROM user_token\n\t\tWHERE user_id = $1\n\t\t  AND (user_id, token) NOT IN (\n\t\t\tSELECT user_id, token\n\t\t\tFROM user_token\n\t\t\tWHERE user_id = $2\n\t\t\tORDER BY expires DESC\n\t\t\tLIMIT $3\n\t\t)\n\t`\n\n\t// Tier queries\n\tpostgresInsertTierQuery = `\n\t\tINSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n\t`\n\tpostgresUpdateTierQuery = `\n\t\tUPDATE tier\n\t\tSET name = $1, messages_limit = $2, messages_expiry_duration = $3, emails_limit = $4, calls_limit = $5, reservations_limit = $6, attachment_file_size_limit = $7, attachment_total_size_limit = $8, attachment_expiry_duration = $9, attachment_bandwidth_limit = $10, stripe_monthly_price_id = $11, stripe_yearly_price_id = $12\n\t\tWHERE code = $13\n\t`\n\tpostgresSelectTiersQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t`\n\tpostgresSelectTierByCodeQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t\tWHERE code = $1\n\t`\n\tpostgresSelectTierByPriceIDQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t\tWHERE (stripe_monthly_price_id = $1 OR stripe_yearly_price_id = $2)\n\t`\n\tpostgresDeleteTierQuery = `DELETE FROM tier WHERE code = $1`\n\n\t// Phone queries\n\tpostgresSelectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = $1`\n\tpostgresInsertPhoneNumberQuery  = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)`\n\tpostgresDeletePhoneNumberQuery  = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`\n\n\t// Billing queries\n\tpostgresUpdateBillingQuery = `\n\t\tUPDATE \"user\"\n\t\tSET stripe_customer_id = $1, stripe_subscription_id = $2, stripe_subscription_status = $3, stripe_subscription_interval = $4, stripe_subscription_paid_until = $5, stripe_subscription_cancel_at = $6\n\t\tWHERE user_name = $7\n\t`\n)\n\n// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.\nvar postgresQueries = queries{\n\tselectUserByID:               postgresSelectUserByIDQuery,\n\tselectUserByName:             postgresSelectUserByNameQuery,\n\tselectUserByToken:            postgresSelectUserByTokenQuery,\n\tselectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,\n\tselectUsernames:              postgresSelectUsernamesQuery,\n\tselectUsers:                  postgresSelectUsersQuery,\n\tselectUserCount:              postgresSelectUserCountQuery,\n\tselectUserIDFromUsername:     postgresSelectUserIDFromUsernameQuery,\n\tinsertUser:                   postgresInsertUserQuery,\n\tupdateUserPass:               postgresUpdateUserPassQuery,\n\tupdateUserRole:               postgresUpdateUserRoleQuery,\n\tupdateUserProvisioned:        postgresUpdateUserProvisionedQuery,\n\tupdateUserPrefs:              postgresUpdateUserPrefsQuery,\n\tupdateUserStats:              postgresUpdateUserStatsQuery,\n\tupdateUserStatsResetAll:      postgresUpdateUserStatsResetAllQuery,\n\tupdateUserTier:               postgresUpdateUserTierQuery,\n\tupdateUserDeleted:            postgresUpdateUserDeletedQuery,\n\tdeleteUser:                   postgresDeleteUserQuery,\n\tdeleteUserTier:               postgresDeleteUserTierQuery,\n\tdeleteUsersMarked:            postgresDeleteUsersMarkedQuery,\n\tdeleteUsersProvisioned:       postgresDeleteUsersProvisionedQuery,\n\tselectTopicPerms:             postgresSelectTopicPermsQuery,\n\tselectUserAllAccess:          postgresSelectUserAllAccessQuery,\n\tselectUserAccess:             postgresSelectUserAccessQuery,\n\tselectUserReservations:       postgresSelectUserReservationsQuery,\n\tselectUserReservationsCount:  postgresSelectUserReservationsCountQuery,\n\tselectUserReservationsOwner:  postgresSelectUserReservationsOwnerQuery,\n\tselectUserHasReservation:     postgresSelectUserHasReservationQuery,\n\tselectOtherAccessCount:       postgresSelectOtherAccessCountQuery,\n\tupsertUserAccess:             postgresUpsertUserAccessQuery,\n\tdeleteUserAccess:             postgresDeleteUserAccessQuery,\n\tdeleteUserAccessProvisioned:  postgresDeleteUserAccessProvisionedQuery,\n\tdeleteTopicAccess:            postgresDeleteTopicAccessQuery,\n\tdeleteAllAccess:              postgresDeleteAllAccessQuery,\n\tselectToken:                  postgresSelectTokenQuery,\n\tselectTokens:                 postgresSelectTokensQuery,\n\tselectTokenCount:             postgresSelectTokenCountQuery,\n\tselectAllProvisionedTokens:   postgresSelectAllProvisionedTokensQuery,\n\tupsertToken:                  postgresUpsertTokenQuery,\n\tupdateToken:                  postgresUpdateTokenQuery,\n\tupdateTokenLastAccess:        postgresUpdateTokenLastAccessQuery,\n\tdeleteToken:                  postgresDeleteTokenQuery,\n\tdeleteProvisionedToken:       postgresDeleteProvisionedTokenQuery,\n\tdeleteAllProvisionedTokens:   postgresDeleteAllProvisionedTokensQuery,\n\tdeleteAllToken:               postgresDeleteAllTokenQuery,\n\tdeleteExpiredTokens:          postgresDeleteExpiredTokensQuery,\n\tdeleteExcessTokens:           postgresDeleteExcessTokensQuery,\n\tinsertTier:                   postgresInsertTierQuery,\n\tselectTiers:                  postgresSelectTiersQuery,\n\tselectTierByCode:             postgresSelectTierByCodeQuery,\n\tselectTierByPriceID:          postgresSelectTierByPriceIDQuery,\n\tupdateTier:                   postgresUpdateTierQuery,\n\tdeleteTier:                   postgresDeleteTierQuery,\n\tselectPhoneNumbers:           postgresSelectPhoneNumbersQuery,\n\tinsertPhoneNumber:            postgresInsertPhoneNumberQuery,\n\tdeletePhoneNumber:            postgresDeletePhoneNumberQuery,\n\tupdateBilling:                postgresUpdateBillingQuery,\n}\n\n// NewPostgresManager creates a new Manager backed by a PostgreSQL database\nfunc NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {\n\tif err := setupPostgres(d.Primary()); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newManager(d, postgresQueries, config)\n}\n"
  },
  {
    "path": "user/manager_postgres_schema.go",
    "content": "package user\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n)\n\n// Initial PostgreSQL schema\nconst (\n\tpostgresCreateTablesQueries = `\n\t\tCREATE TABLE IF NOT EXISTS tier (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcode TEXT NOT NULL,\n\t\t\tname TEXT NOT NULL,\n\t\t\tmessages_limit BIGINT NOT NULL,\n\t\t\tmessages_expiry_duration BIGINT NOT NULL,\n\t\t\temails_limit BIGINT NOT NULL,\n\t\t\tcalls_limit BIGINT NOT NULL,\n\t\t\treservations_limit BIGINT NOT NULL,\n\t\t\tattachment_file_size_limit BIGINT NOT NULL,\n\t\t\tattachment_total_size_limit BIGINT NOT NULL,\n\t\t\tattachment_expiry_duration BIGINT NOT NULL,\n\t\t\tattachment_bandwidth_limit BIGINT NOT NULL,\n\t\t\tstripe_monthly_price_id TEXT,\n\t\t\tstripe_yearly_price_id TEXT,\n\t\t\tUNIQUE(code),\n\t\t\tUNIQUE(stripe_monthly_price_id),\n\t\t\tUNIQUE(stripe_yearly_price_id)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS \"user\" (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT REFERENCES tier(id),\n\t\t\tuser_name TEXT NOT NULL UNIQUE,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),\n\t\t\tprefs JSONB NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tstats_messages BIGINT NOT NULL DEFAULT 0,\n\t\t\tstats_emails BIGINT NOT NULL DEFAULT 0,\n\t\t\tstats_calls BIGINT NOT NULL DEFAULT 0,\n\t\t\tstripe_customer_id TEXT UNIQUE,\n\t\t\tstripe_subscription_id TEXT UNIQUE,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_interval TEXT,\n\t\t\tstripe_subscription_paid_until BIGINT,\n\t\t\tstripe_subscription_cancel_at BIGINT,\n\t\t\tcreated BIGINT NOT NULL,\n\t\t\tdeleted BIGINT\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_access (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread BOOLEAN NOT NULL,\n\t\t\twrite BOOLEAN NOT NULL,\n\t\t\towner_user_id TEXT REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tPRIMARY KEY (user_id, topic)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\ttoken TEXT NOT NULL UNIQUE,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access BIGINT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires BIGINT NOT NULL,\n\t\t\tprovisioned BOOLEAN NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_phone (\n\t\t\tuser_id TEXT NOT NULL REFERENCES \"user\"(id) ON DELETE CASCADE,\n\t\t\tphone_number TEXT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, phone_number)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO \"user\" (id, user_name, pass, role, sync_topic, provisioned, created)\n\t\tVALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)\n\t\tON CONFLICT (id) DO NOTHING;\n\t`\n)\n\n// Schema table management queries for Postgres\nconst (\n\tpostgresCurrentSchemaVersion     = 6\n\tpostgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`\n\tpostgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`\n)\n\nfunc setupPostgres(db *sql.DB) error {\n\tvar schemaVersion int\n\terr := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)\n\tif err != nil {\n\t\treturn setupNewPostgres(db)\n\t}\n\tif schemaVersion > postgresCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, postgresCurrentSchemaVersion)\n\t}\n\t// Note: PostgreSQL migrations will be added when needed\n\treturn nil\n}\n\nfunc setupNewPostgres(db *sql.DB) error {\n\tif _, err := db.Exec(postgresCreateTablesQueries); err != nil {\n\t\treturn err\n\t}\n\tif _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "user/manager_sqlite.go",
    "content": "package user\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t_ \"github.com/mattn/go-sqlite3\" // SQLite driver\n\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\t// User queries\n\tsqliteSelectUsersQuery = `\n\t\tSELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM user u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tORDER BY\n\t\t\tCASE u.role\n\t\t\t\tWHEN 'admin' THEN 1\n\t\t\t\tWHEN 'anonymous' THEN 3\n\t\t\t\tELSE 2\n\t\t\tEND, u.user\n\t`\n\tsqliteSelectUserByIDQuery = `\n\t\tSELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM user u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE u.id = ?\n\t`\n\tsqliteSelectUserByNameQuery = `\n\t\tSELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM user u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE user = ?\n\t`\n\tsqliteSelectUserByTokenQuery = `\n\t\tSELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM user u\n\t\tJOIN user_token tk on u.id = tk.user_id\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)\n\t`\n\tsqliteSelectUserByStripeCustomerIDQuery = `\n\t\tSELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id\n\t\tFROM user u\n\t\tLEFT JOIN tier t on t.id = u.tier_id\n\t\tWHERE u.stripe_customer_id = ?\n\t`\n\tsqliteSelectUsernamesQuery = `\n\t\tSELECT user\n\t\tFROM user\n\t\tORDER BY\n\t\t\tCASE role\n\t\t\t\tWHEN 'admin' THEN 1\n\t\t\t\tWHEN 'anonymous' THEN 3\n\t\t\t\tELSE 2\n\t\t\tEND, user\n\t`\n\tsqliteSelectUserCountQuery          = `SELECT COUNT(*) FROM user`\n\tsqliteSelectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?`\n\tsqliteInsertUserQuery               = `INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES (?, ?, ?, ?, ?, ?, ?)`\n\tsqliteUpdateUserPassQuery           = `UPDATE user SET pass = ? WHERE user = ?`\n\tsqliteUpdateUserRoleQuery           = `UPDATE user SET role = ? WHERE user = ?`\n\tsqliteUpdateUserProvisionedQuery    = `UPDATE user SET provisioned = ? WHERE user = ?`\n\tsqliteUpdateUserPrefsQuery          = `UPDATE user SET prefs = ? WHERE id = ?`\n\tsqliteUpdateUserStatsQuery          = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`\n\tsqliteUpdateUserStatsResetAllQuery  = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`\n\tsqliteUpdateUserTierQuery           = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?`\n\tsqliteUpdateUserDeletedQuery        = `UPDATE user SET deleted = ? WHERE id = ?`\n\tsqliteDeleteUserQuery               = `DELETE FROM user WHERE user = ?`\n\tsqliteDeleteUserTierQuery           = `UPDATE user SET tier_id = null WHERE user = ?`\n\tsqliteDeleteUsersMarkedQuery        = `DELETE FROM user WHERE deleted < ?`\n\tsqliteDeleteUsersProvisionedQuery   = `DELETE FROM user WHERE provisioned = 1`\n\n\t// Access queries\n\tsqliteSelectTopicPermsQuery = `\n\t\tSELECT read, write\n\t\tFROM user_access a\n\t\tJOIN user u ON u.id = a.user_id\n\t\tWHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\\'\n\t\tORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC\n\t`\n\tsqliteSelectUserAllAccessQuery = `\n\t\tSELECT user_id, topic, read, write, provisioned\n\t\tFROM user_access\n\t\tORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic\n\t`\n\tsqliteSelectUserAccessQuery = `\n\t\tSELECT topic, read, write, provisioned\n\t\tFROM user_access\n\t\tWHERE user_id = (SELECT id FROM user WHERE user = ?)\n\t\tORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic\n\t`\n\tsqliteSelectUserReservationsQuery = `\n\t\tSELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write\n\t\tFROM user_access a_user\n\t\tLEFT JOIN  user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)\n\t\tWHERE a_user.user_id = a_user.owner_user_id\n\t\t  AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)\n\t\tORDER BY a_user.topic\n\t`\n\tsqliteSelectUserReservationsCountQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE user_id = owner_user_id\n\t\t  AND owner_user_id = (SELECT id FROM user WHERE user = ?)\n\t`\n\tsqliteSelectUserReservationsOwnerQuery = `\n\t\tSELECT owner_user_id\n\t\tFROM user_access\n\t\tWHERE topic = ?\n\t\t  AND user_id = owner_user_id\n\t`\n\tsqliteSelectUserHasReservationQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE user_id = owner_user_id\n\t\t  AND owner_user_id = (SELECT id FROM user WHERE user = ?)\n\t\t  AND topic = ?\n\t`\n\tsqliteSelectOtherAccessCountQuery = `\n\t\tSELECT COUNT(*)\n\t\tFROM user_access\n\t\tWHERE (topic = ? OR ? LIKE topic ESCAPE '\\')\n\t\t  AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))\n\t`\n\tsqliteUpsertUserAccessQuery = `\n\t\tINSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)\n\t\tVALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)\n\t\tON CONFLICT (user_id, topic)\n\t\tDO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned\n\t`\n\tsqliteDeleteUserAccessQuery = `\n\t\tDELETE FROM user_access\n\t\tWHERE user_id = (SELECT id FROM user WHERE user = ?)\n\t\t   OR owner_user_id = (SELECT id FROM user WHERE user = ?)\n\t`\n\tsqliteDeleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1`\n\tsqliteDeleteTopicAccessQuery           = `\n\t\tDELETE FROM user_access\n\t   \tWHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))\n\t   \t  AND topic = ?\n  \t`\n\tsqliteDeleteAllAccessQuery = `DELETE FROM user_access`\n\n\t// Token queries\n\tsqliteSelectTokenQuery                = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?`\n\tsqliteSelectTokensQuery               = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?`\n\tsqliteSelectTokenCountQuery           = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`\n\tsqliteSelectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1`\n\tsqliteUpsertTokenQuery                = `\n\t\tINSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (user_id, token)\n\t\tDO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned\n\t`\n\tsqliteUpdateTokenQuery                = `UPDATE user_token SET label = ?, expires = ? WHERE user_id = ? AND token = ?`\n\tsqliteUpdateTokenLastAccessQuery      = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`\n\tsqliteDeleteTokenQuery                = `DELETE FROM user_token WHERE user_id = ? AND token = ?`\n\tsqliteDeleteProvisionedTokenQuery     = `DELETE FROM user_token WHERE token = ?`\n\tsqliteDeleteAllProvisionedTokensQuery = `DELETE FROM user_token WHERE provisioned = 1`\n\tsqliteDeleteAllTokenQuery             = `DELETE FROM user_token WHERE user_id = ?`\n\tsqliteDeleteExpiredTokensQuery        = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`\n\tsqliteDeleteExcessTokensQuery         = `\n\t\tDELETE FROM user_token\n\t\tWHERE user_id = ?\n\t\t  AND (user_id, token) NOT IN (\n\t\t\tSELECT user_id, token\n\t\t\tFROM user_token\n\t\t\tWHERE user_id = ?\n\t\t\tORDER BY expires DESC\n\t\t\tLIMIT ?\n\t\t)\n\t`\n\n\t// Tier queries\n\tsqliteInsertTierQuery = `\n\t\tINSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`\n\tsqliteUpdateTierQuery = `\n\t\tUPDATE tier\n\t\tSET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?\n\t\tWHERE code = ?\n\t`\n\tsqliteSelectTiersQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t`\n\tsqliteSelectTierByCodeQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t\tWHERE code = ?\n\t`\n\tsqliteSelectTierByPriceIDQuery = `\n\t\tSELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id\n\t\tFROM tier\n\t\tWHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)\n\t`\n\tsqliteDeleteTierQuery = `DELETE FROM tier WHERE code = ?`\n\n\t// Phone queries\n\tsqliteSelectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?`\n\tsqliteInsertPhoneNumberQuery  = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`\n\tsqliteDeletePhoneNumberQuery  = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`\n\n\t// Billing queries\n\tsqliteUpdateBillingQuery = `\n\t\tUPDATE user\n\t\tSET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?\n\t\tWHERE user = ?\n\t`\n)\n\nvar sqliteQueries = queries{\n\tselectUserByID:               sqliteSelectUserByIDQuery,\n\tselectUserByName:             sqliteSelectUserByNameQuery,\n\tselectUserByToken:            sqliteSelectUserByTokenQuery,\n\tselectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,\n\tselectUsernames:              sqliteSelectUsernamesQuery,\n\tselectUsers:                  sqliteSelectUsersQuery,\n\tselectUserCount:              sqliteSelectUserCountQuery,\n\tselectUserIDFromUsername:     sqliteSelectUserIDFromUsernameQuery,\n\tinsertUser:                   sqliteInsertUserQuery,\n\tupdateUserPass:               sqliteUpdateUserPassQuery,\n\tupdateUserRole:               sqliteUpdateUserRoleQuery,\n\tupdateUserProvisioned:        sqliteUpdateUserProvisionedQuery,\n\tupdateUserPrefs:              sqliteUpdateUserPrefsQuery,\n\tupdateUserStats:              sqliteUpdateUserStatsQuery,\n\tupdateUserStatsResetAll:      sqliteUpdateUserStatsResetAllQuery,\n\tupdateUserTier:               sqliteUpdateUserTierQuery,\n\tupdateUserDeleted:            sqliteUpdateUserDeletedQuery,\n\tdeleteUser:                   sqliteDeleteUserQuery,\n\tdeleteUserTier:               sqliteDeleteUserTierQuery,\n\tdeleteUsersMarked:            sqliteDeleteUsersMarkedQuery,\n\tdeleteUsersProvisioned:       sqliteDeleteUsersProvisionedQuery,\n\tselectTopicPerms:             sqliteSelectTopicPermsQuery,\n\tselectUserAllAccess:          sqliteSelectUserAllAccessQuery,\n\tselectUserAccess:             sqliteSelectUserAccessQuery,\n\tselectUserReservations:       sqliteSelectUserReservationsQuery,\n\tselectUserReservationsCount:  sqliteSelectUserReservationsCountQuery,\n\tselectUserReservationsOwner:  sqliteSelectUserReservationsOwnerQuery,\n\tselectUserHasReservation:     sqliteSelectUserHasReservationQuery,\n\tselectOtherAccessCount:       sqliteSelectOtherAccessCountQuery,\n\tupsertUserAccess:             sqliteUpsertUserAccessQuery,\n\tdeleteUserAccess:             sqliteDeleteUserAccessQuery,\n\tdeleteUserAccessProvisioned:  sqliteDeleteUserAccessProvisionedQuery,\n\tdeleteTopicAccess:            sqliteDeleteTopicAccessQuery,\n\tdeleteAllAccess:              sqliteDeleteAllAccessQuery,\n\tselectToken:                  sqliteSelectTokenQuery,\n\tselectTokens:                 sqliteSelectTokensQuery,\n\tselectTokenCount:             sqliteSelectTokenCountQuery,\n\tselectAllProvisionedTokens:   sqliteSelectAllProvisionedTokensQuery,\n\tupsertToken:                  sqliteUpsertTokenQuery,\n\tupdateToken:                  sqliteUpdateTokenQuery,\n\tupdateTokenLastAccess:        sqliteUpdateTokenLastAccessQuery,\n\tdeleteToken:                  sqliteDeleteTokenQuery,\n\tdeleteProvisionedToken:       sqliteDeleteProvisionedTokenQuery,\n\tdeleteAllProvisionedTokens:   sqliteDeleteAllProvisionedTokensQuery,\n\tdeleteAllToken:               sqliteDeleteAllTokenQuery,\n\tdeleteExpiredTokens:          sqliteDeleteExpiredTokensQuery,\n\tdeleteExcessTokens:           sqliteDeleteExcessTokensQuery,\n\tinsertTier:                   sqliteInsertTierQuery,\n\tselectTiers:                  sqliteSelectTiersQuery,\n\tselectTierByCode:             sqliteSelectTierByCodeQuery,\n\tselectTierByPriceID:          sqliteSelectTierByPriceIDQuery,\n\tupdateTier:                   sqliteUpdateTierQuery,\n\tdeleteTier:                   sqliteDeleteTierQuery,\n\tselectPhoneNumbers:           sqliteSelectPhoneNumbersQuery,\n\tinsertPhoneNumber:            sqliteInsertPhoneNumberQuery,\n\tdeletePhoneNumber:            sqliteDeletePhoneNumberQuery,\n\tupdateBilling:                sqliteUpdateBillingQuery,\n}\n\n// NewSQLiteManager creates a new Manager backed by a SQLite database\nfunc NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager, error) {\n\tparentDir := filepath.Dir(filename)\n\tif !util.FileExists(parentDir) {\n\t\treturn nil, fmt.Errorf(\"user database directory %s does not exist or is not accessible\", parentDir)\n\t}\n\td, err := sql.Open(\"sqlite3\", filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := setupSQLite(d); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := runSQLiteStartupQueries(d, startupQueries); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newManager(db.New(&db.Host{DB: d}, nil), sqliteQueries, config)\n}\n"
  },
  {
    "path": "user/manager_sqlite_schema.go",
    "content": "package user\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\n// Initial SQLite schema\nconst (\n\tsqliteCreateTablesQueries = `\n\t\tCREATE TABLE IF NOT EXISTS tier (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcode TEXT NOT NULL,\n\t\t\tname TEXT NOT NULL,\n\t\t\tmessages_limit INT NOT NULL,\n\t\t\tmessages_expiry_duration INT NOT NULL,\n\t\t\temails_limit INT NOT NULL,\n\t\t\tcalls_limit INT NOT NULL,\n\t\t\treservations_limit INT NOT NULL,\n\t\t\tattachment_file_size_limit INT NOT NULL,\n\t\t\tattachment_total_size_limit INT NOT NULL,\n\t\t\tattachment_expiry_duration INT NOT NULL,\n\t\t\tattachment_bandwidth_limit INT NOT NULL,\n\t\t\tstripe_monthly_price_id TEXT,\n\t\t\tstripe_yearly_price_id TEXT\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_tier_code ON tier (code);\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);\n\t\tCREATE TABLE IF NOT EXISTS user (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT,\n\t\t\tuser TEXT NOT NULL,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,\n\t\t\tprefs JSON NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tprovisioned INT NOT NULL,\n\t\t\tstats_messages INT NOT NULL DEFAULT (0),\n\t\t\tstats_emails INT NOT NULL DEFAULT (0),\n\t\t\tstats_calls INT NOT NULL DEFAULT (0),\n\t\t\tstripe_customer_id TEXT,\n\t\t\tstripe_subscription_id TEXT,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_interval TEXT,\n\t\t\tstripe_subscription_paid_until INT,\n\t\t\tstripe_subscription_cancel_at INT,\n\t\t\tcreated INT NOT NULL,\n\t\t\tdeleted INT,\n\t\t    FOREIGN KEY (tier_id) REFERENCES tier (id)\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_user ON user (user);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);\n\t\tCREATE TABLE IF NOT EXISTS user_access (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread INT NOT NULL,\n\t\t\twrite INT NOT NULL,\n\t\t\towner_user_id INT,\n\t\t\tprovisioned INT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, topic),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,\n\t\t    FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttoken TEXT NOT NULL,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access INT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires INT NOT NULL,\n\t\t\tprovisioned INT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_user_token ON user_token (token);\n\t\tCREATE TABLE IF NOT EXISTS user_phone (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tphone_number TEXT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, phone_number),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)\n\t\tVALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())\n\t\tON CONFLICT (id) DO NOTHING;\n\t`\n)\n\nconst (\n\tsqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`\n)\n\n// Schema version table management for SQLite\nconst (\n\tsqliteCurrentSchemaVersion     = 6\n\tsqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`\n\tsqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`\n\tsqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`\n)\n\n// Schema migrations for SQLite\nconst (\n\t// 1 -> 2 (complex migration!)\n\tsqliteMigrate1To2CreateTablesQueries = `\n\t\tALTER TABLE user RENAME TO user_old;\n\t\tCREATE TABLE IF NOT EXISTS tier (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcode TEXT NOT NULL,\n\t\t\tname TEXT NOT NULL,\n\t\t\tmessages_limit INT NOT NULL,\n\t\t\tmessages_expiry_duration INT NOT NULL,\n\t\t\temails_limit INT NOT NULL,\n\t\t\treservations_limit INT NOT NULL,\n\t\t\tattachment_file_size_limit INT NOT NULL,\n\t\t\tattachment_total_size_limit INT NOT NULL,\n\t\t\tattachment_expiry_duration INT NOT NULL,\n\t\t\tattachment_bandwidth_limit INT NOT NULL,\n\t\t\tstripe_price_id TEXT\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_tier_code ON tier (code);\n\t\tCREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);\n\t\tCREATE TABLE IF NOT EXISTS user (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT,\n\t\t\tuser TEXT NOT NULL,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,\n\t\t\tprefs JSON NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tstats_messages INT NOT NULL DEFAULT (0),\n\t\t\tstats_emails INT NOT NULL DEFAULT (0),\n\t\t\tstripe_customer_id TEXT,\n\t\t\tstripe_subscription_id TEXT,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_paid_until INT,\n\t\t\tstripe_subscription_cancel_at INT,\n\t\t\tcreated INT NOT NULL,\n\t\t\tdeleted INT,\n\t\t    FOREIGN KEY (tier_id) REFERENCES tier (id)\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_user ON user (user);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);\n\t\tCREATE TABLE IF NOT EXISTS user_access (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread INT NOT NULL,\n\t\t\twrite INT NOT NULL,\n\t\t\towner_user_id INT,\n\t\t\tPRIMARY KEY (user_id, topic),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,\n\t\t    FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttoken TEXT NOT NULL,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access INT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires INT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO user (id, user, pass, role, sync_topic, created)\n\t\tVALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())\n\t\tON CONFLICT (id) DO NOTHING;\n\t`\n\tsqliteMigrate1To2SelectAllOldUsernamesNoTxQuery = `SELECT user FROM user_old`\n\tsqliteMigrate1To2InsertUserNoTxQuery            = `\n\t\tINSERT INTO user (id, user, pass, role, sync_topic, created)\n\t\tSELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ?\n\t`\n\tsqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery = `\n\t\tINSERT INTO user_access (user_id, topic, read, write)\n\t\tSELECT u.id, a.topic, a.read, a.write\n\t\tFROM user u\n\t \tJOIN access a ON u.user = a.user;\n\n\t\tDROP TABLE access;\n\t\tDROP TABLE user_old;\n\t`\n\n\t// 2 -> 3\n\tsqliteMigrate2To3UpdateQueries = `\n\t\tALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;\n\t\tALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;\n\t\tALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;\n\t\tDROP INDEX IF EXISTS idx_tier_price_id;\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);\n\t`\n\n\t// 3 -> 4\n\tsqliteMigrate3To4UpdateQueries = `\n\t\tALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);\n\t\tALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);\n\t\tCREATE TABLE IF NOT EXISTS user_phone (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tphone_number TEXT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, phone_number),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t`\n\n\t// 4 -> 5\n\tsqliteMigrate4To5UpdateQueries = `\n\t\tUPDATE user_access SET topic = REPLACE(topic, '_', '\\_');\n\t`\n\n\t// 5 -> 6\n\tsqliteMigrate5To6UpdateQueries = `\n\t\tPRAGMA foreign_keys=off;\n\n\t\t-- Alter user table: Add provisioned column\n\t\tALTER TABLE user RENAME TO user_old;\n\t\tCREATE TABLE IF NOT EXISTS user (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT,\n\t\t\tuser TEXT NOT NULL,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,\n\t\t\tprefs JSON NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tprovisioned INT NOT NULL,\n\t\t\tstats_messages INT NOT NULL DEFAULT (0),\n\t\t\tstats_emails INT NOT NULL DEFAULT (0),\n\t\t\tstats_calls INT NOT NULL DEFAULT (0),\n\t\t\tstripe_customer_id TEXT,\n\t\t\tstripe_subscription_id TEXT,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_interval TEXT,\n\t\t\tstripe_subscription_paid_until INT,\n\t\t\tstripe_subscription_cancel_at INT,\n\t\t\tcreated INT NOT NULL,\n\t\t\tdeleted INT,\n\t\t    FOREIGN KEY (tier_id) REFERENCES tier (id)\n\t\t);\n\t\tINSERT INTO user\n\t\tSELECT\n\t\t    id,\n\t\t    tier_id,\n\t\t    user,\n\t\t    pass,\n\t\t    role,\n\t\t    prefs,\n\t\t    sync_topic,\n\t\t    0, -- provisioned\n\t\t    stats_messages,\n\t\t    stats_emails,\n\t\t    stats_calls,\n\t\t    stripe_customer_id,\n\t\t    stripe_subscription_id,\n\t\t    stripe_subscription_status,\n\t\t    stripe_subscription_interval,\n\t\t    stripe_subscription_paid_until,\n\t\t    stripe_subscription_cancel_at,\n\t\t    created,\n\t\t    deleted\n\t\tFROM user_old;\n\t\tDROP TABLE user_old;\n\n\t\t-- Alter user_access table: Add provisioned column\n\t\tALTER TABLE user_access RENAME TO user_access_old;\n\t\tCREATE TABLE user_access (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread INT NOT NULL,\n\t\t\twrite INT NOT NULL,\n\t\t\towner_user_id INT,\n\t\t\tprovisioned INTEGER NOT NULL,\n\t\t\tPRIMARY KEY (user_id, topic),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tINSERT INTO user_access SELECT *, 0 FROM user_access_old;\n\t\tDROP TABLE user_access_old;\n\n\t\t-- Alter user_token table: Add provisioned column\n\t\tALTER TABLE user_token RENAME TO user_token_old;\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttoken TEXT NOT NULL,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access INT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires INT NOT NULL,\n\t\t\tprovisioned INT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tINSERT INTO user_token SELECT *, 0 FROM user_token_old;\n\t\tDROP TABLE user_token_old;\n\n\t\t-- Recreate indices\n\t\tCREATE UNIQUE INDEX idx_user ON user (user);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);\n\t\tCREATE UNIQUE INDEX idx_user_token ON user_token (token);\n\n\t\t-- Re-enable foreign keys\n\t\tPRAGMA foreign_keys=on;\n\t`\n)\n\nvar (\n\tsqliteMigrations = map[int]func(db *sql.DB) error{\n\t\t1: sqliteMigrateFrom1,\n\t\t2: sqliteMigrateFrom2,\n\t\t3: sqliteMigrateFrom3,\n\t\t4: sqliteMigrateFrom4,\n\t\t5: sqliteMigrateFrom5,\n\t}\n)\n\nfunc setupSQLite(db *sql.DB) error {\n\tvar schemaVersion int\n\tif err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {\n\t\treturn setupNewSQLite(db)\n\t}\n\tif schemaVersion == sqliteCurrentSchemaVersion {\n\t\treturn nil\n\t} else if schemaVersion > sqliteCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, sqliteCurrentSchemaVersion)\n\t}\n\tfor i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {\n\t\tfn, ok := sqliteMigrations[i]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"cannot find migration step from schema version %d to %d\", i, i+1)\n\t\t} else if err := fn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc setupNewSQLite(sqlDB *sql.DB) error {\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteCreateTablesQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {\n\tif _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {\n\t\treturn err\n\t}\n\tif startupQueries != \"\" {\n\t\tif _, err := db.Exec(startupQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc sqliteMigrateFrom1(sqlDB *sql.DB) error {\n\tlog.Tag(tag).Info(\"Migrating user database schema: from 1 to 2\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\t// Rename user -> user_old, and create new tables\n\t\tif _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Insert users from user_old into new user table, with ID and sync_topic\n\t\trows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\t\tusernames := make([]string, 0)\n\t\tfor rows.Next() {\n\t\t\tvar username string\n\t\t\tif err := rows.Scan(&username); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tusernames = append(usernames, username)\n\t\t}\n\t\tif err := rows.Close(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, username := range usernames {\n\t\t\tuserID := util.RandomStringPrefix(userIDPrefix, userIDLength)\n\t\t\tsyncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)\n\t\t\tif _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\t// Migrate old \"access\" table to \"user_access\" and drop \"access\" and \"user_old\"\n\t\tif _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom2(sqlDB *sql.DB) error {\n\tlog.Tag(tag).Info(\"Migrating user database schema: from 2 to 3\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom3(sqlDB *sql.DB) error {\n\tlog.Tag(tag).Info(\"Migrating user database schema: from 3 to 4\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom4(sqlDB *sql.DB) error {\n\tlog.Tag(tag).Info(\"Migrating user database schema: from 4 to 5\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc sqliteMigrateFrom5(sqlDB *sql.DB) error {\n\tlog.Tag(tag).Info(\"Migrating user database schema: from 5 to 6\")\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "user/manager_test.go",
    "content": "package user\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/db/pg\"\n\tdbtest \"heckel.io/ntfy/v2/db/test\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources\n\n// newManagerFunc creates a Manager with the given config. Calling it multiple\n// times within the same test returns a new Manager pointing at the same\n// underlying data (same SQLite file / same PostgreSQL schema), enabling\n// close-and-reopen tests.\ntype newManagerFunc func(config *Config) *Manager\n\nfunc forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc)) {\n\tt.Run(\"sqlite\", func(t *testing.T) {\n\t\tdir := t.TempDir()\n\t\tf(t, func(config *Config) *Manager {\n\t\t\ta, err := NewSQLiteManager(filepath.Join(dir, \"user.db\"), \"\", config)\n\t\t\trequire.Nil(t, err)\n\t\t\treturn a\n\t\t})\n\t})\n\tt.Run(\"postgres\", func(t *testing.T) {\n\t\tschemaDSN := dbtest.CreateTestPostgresSchema(t)\n\t\tf(t, func(config *Config) *Manager {\n\t\t\thost, err := pg.Open(schemaDSN)\n\t\t\trequire.Nil(t, err)\n\t\t\ta, err := NewPostgresManager(db.New(host, nil), config)\n\t\t\trequire.Nil(t, err)\n\t\t\treturn a\n\t\t})\n\t})\n}\n\nfunc TestManager_FullScenario_Default_DenyAll(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleAdmin, false))\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AddUser(\"john\", \"john\", RoleUser, false))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"mytopic\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"readme\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"writeme\", PermissionWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"everyonewrite\", PermissionDenyAll)) // How unfair!\n\t\trequire.Nil(t, a.AllowAccess(\"john\", \"*\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(\"john\", \"mytopic*\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"john\", \"mytopic_ro*\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(\"john\", \"mytopic_deny*\", PermissionDenyAll))\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"announcements\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"everyonewrite\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"up*\", PermissionWrite)) // Everyone can write to /up*\n\n\t\tphil, err := a.Authenticate(\"phil\", \"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", phil.Name)\n\t\trequire.True(t, strings.HasPrefix(phil.Hash, \"$2a$04$\"))\n\t\trequire.Equal(t, RoleAdmin, phil.Role)\n\n\t\tphilGrants, err := a.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{}, philGrants)\n\n\t\tben, err := a.Authenticate(\"ben\", \"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"ben\", ben.Name)\n\t\trequire.True(t, strings.HasPrefix(ben.Hash, \"$2a$04$\"))\n\t\trequire.Equal(t, RoleUser, ben.Role)\n\n\t\tbenGrants, err := a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{\n\t\t\t{\"everyonewrite\", PermissionDenyAll, false},\n\t\t\t{\"mytopic\", PermissionReadWrite, false},\n\t\t\t{\"writeme\", PermissionWrite, false},\n\t\t\t{\"readme\", PermissionRead, false},\n\t\t}, benGrants)\n\n\t\tjohn, err := a.Authenticate(\"john\", \"john\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"john\", john.Name)\n\t\trequire.True(t, strings.HasPrefix(john.Hash, \"$2a$04$\"))\n\t\trequire.Equal(t, RoleUser, john.Role)\n\n\t\tjohnGrants, err := a.Grants(\"john\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{\n\t\t\t{\"mytopic_deny*\", PermissionDenyAll, false},\n\t\t\t{\"mytopic_ro*\", PermissionRead, false},\n\t\t\t{\"mytopic*\", PermissionReadWrite, false},\n\t\t\t{\"*\", PermissionRead, false},\n\t\t}, johnGrants)\n\n\t\tnotben, err := a.Authenticate(\"ben\", \"this is wrong\")\n\t\trequire.Nil(t, notben)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\t// Admin can do everything\n\t\trequire.Nil(t, a.Authorize(phil, \"sometopic\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(phil, \"mytopic\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(phil, \"readme\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(phil, \"writeme\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(phil, \"announcements\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(phil, \"everyonewrite\", PermissionWrite))\n\n\t\t// User cannot do everything\n\t\trequire.Nil(t, a.Authorize(ben, \"mytopic\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(ben, \"mytopic\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"readme\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"readme\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"writeme\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"writeme\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(ben, \"writeme\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"everyonewrite\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"everyonewrite\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(ben, \"announcements\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"announcements\", PermissionWrite))\n\n\t\t// User john should have\n\t\t//  \"deny\" to mytopic_deny*,\n\t\t//    \"ro\" to mytopic_ro*,\n\t\t//    \"rw\" to mytopic*,\n\t\t//    \"ro\" to the rest\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(john, \"mytopic_deny_case\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(john, \"mytopic_deny_case\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(john, \"mytopic_ro_test_case\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(john, \"mytopic_ro_test_case\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(john, \"mytopic_case1\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(john, \"mytopic_case1\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(john, \"readme\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(john, \"writeme\", PermissionWrite))\n\n\t\t// Everyone else can do barely anything\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"sometopicnotinthelist\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"sometopicnotinthelist\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"mytopic\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"mytopic\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"readme\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"readme\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"writeme\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"writeme\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"announcements\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(nil, \"announcements\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"everyonewrite\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"everyonewrite\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(nil, \"up1234\", PermissionWrite)) // Wildcard permission\n\t\trequire.Nil(t, a.Authorize(nil, \"up5678\", PermissionWrite))\n\t})\n}\n\nfunc TestManager_Access_Order_LengthWriteRead(t *testing.T) {\n\t// This test validates issue #914 / #917, i.e. that write permissions are prioritized over read permissions,\n\t// and longer ACL rules are prioritized as well.\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"test*\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"*\", PermissionRead))\n\n\t\tben, err := a.Authenticate(\"ben\", \"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, a.Authorize(ben, \"any-topic-can-be-read\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"this-too\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"test123\", PermissionWrite))\n\t})\n}\n\nfunc TestManager_AddUser_Invalid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Equal(t, ErrInvalidArgument, a.AddUser(\"  invalid  \", \"pass\", RoleAdmin, false))\n\t\trequire.Equal(t, ErrInvalidArgument, a.AddUser(\"validuser\", \"pass\", \"invalid-role\", false))\n\t})\n}\n\nfunc TestManager_AddUser_Timing(t *testing.T) {\n\ta := newTestManagerFromFile(t, filepath.Join(t.TempDir(), \"user.db\"), \"\", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)\n\tstart := time.Now().UnixMilli()\n\trequire.Nil(t, a.AddUser(\"user\", \"pass\", RoleAdmin, false))\n\trequire.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)\n}\n\nfunc TestManager_AddUser_And_Query(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"user\", \"pass\", RoleAdmin, false))\n\t\trequire.Nil(t, a.ChangeBilling(\"user\", &Billing{\n\t\t\tStripeCustomerID:            \"acct_123\",\n\t\t\tStripeSubscriptionID:        \"sub_123\",\n\t\t\tStripeSubscriptionStatus:    \"active\",\n\t\t\tStripeSubscriptionInterval:  \"month\",\n\t\t\tStripeSubscriptionPaidUntil: time.Now().Add(time.Hour),\n\t\t\tStripeSubscriptionCancelAt:  time.Unix(0, 0),\n\t\t}))\n\n\t\tu, err := a.User(\"user\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"user\", u.Name)\n\n\t\tu2, err := a.UserByID(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, u.Name, u2.Name)\n\n\t\tu3, err := a.UserByStripeCustomer(\"acct_123\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, u.ID, u3.ID)\n\t})\n}\n\nfunc TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\t// Create user, add reservations and token\n\t\trequire.Nil(t, a.AddUser(\"user\", \"pass\", RoleAdmin, false))\n\t\trequire.Nil(t, a.AddReservation(\"user\", \"mytopic\", PermissionRead, 0))\n\n\t\tu, err := a.User(\"user\")\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, u.Deleted)\n\n\t\ttoken, err := a.CreateToken(u.ID, \"\", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\n\t\tu, err = a.Authenticate(\"user\", \"pass\")\n\t\trequire.Nil(t, err)\n\n\t\t_, err = a.AuthenticateToken(token.Value)\n\t\trequire.Nil(t, err)\n\n\t\treservations, err := a.Reservations(\"user\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(reservations))\n\n\t\t// Mark deleted: cannot auth anymore, and all reservations are gone\n\t\trequire.Nil(t, a.MarkUserRemoved(u))\n\n\t\t_, err = a.Authenticate(\"user\", \"pass\")\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\t_, err = a.AuthenticateToken(token.Value)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\treservations, err = a.Reservations(\"user\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(reservations))\n\n\t\t// Make sure user is still there\n\t\tu, err = a.User(\"user\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, u.Deleted)\n\n\t\t// Backdate the deleted timestamp so RemoveDeletedUsers will prune the user\n\t\t_, err = testDB(a).Exec(a.queries.updateUserDeleted, time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, a.RemoveDeletedUsers())\n\n\t\t_, err = a.User(\"user\")\n\t\trequire.Equal(t, ErrUserNotFound, err)\n\t})\n}\n\nfunc TestManager_CreateToken_Only_Lower(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\t// Create user, add reservations and token\n\t\trequire.Nil(t, a.AddUser(\"user\", \"pass\", RoleAdmin, false))\n\t\tu, err := a.User(\"user\")\n\t\trequire.Nil(t, err)\n\n\t\ttoken, err := a.CreateToken(u.ID, \"\", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, token.Value, strings.ToLower(token.Value))\n\t})\n}\n\nfunc TestManager_UserManagement(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleAdmin, false))\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"mytopic\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"readme\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"writeme\", PermissionWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"everyonewrite\", PermissionDenyAll)) // How unfair!\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"announcements\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"everyonewrite\", PermissionReadWrite))\n\n\t\t// Query user details\n\t\tphil, err := a.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", phil.Name)\n\t\trequire.True(t, strings.HasPrefix(phil.Hash, \"$2a$04$\")) // Min cost for testing\n\t\trequire.Equal(t, RoleAdmin, phil.Role)\n\n\t\tphilGrants, err := a.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{}, philGrants)\n\n\t\tben, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"ben\", ben.Name)\n\t\trequire.True(t, strings.HasPrefix(ben.Hash, \"$2a$04$\")) // Min cost for testing\n\t\trequire.Equal(t, RoleUser, ben.Role)\n\n\t\tbenGrants, err := a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{\n\t\t\t{\"everyonewrite\", PermissionDenyAll, false},\n\t\t\t{\"mytopic\", PermissionReadWrite, false},\n\t\t\t{\"writeme\", PermissionWrite, false},\n\t\t\t{\"readme\", PermissionRead, false},\n\t\t}, benGrants)\n\n\t\teveryone, err := a.User(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"*\", everyone.Name)\n\t\trequire.Equal(t, \"\", everyone.Hash)\n\t\trequire.Equal(t, RoleAnonymous, everyone.Role)\n\n\t\teveryoneGrants, err := a.Grants(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, []Grant{\n\t\t\t{\"everyonewrite\", PermissionReadWrite, false},\n\t\t\t{\"announcements\", PermissionRead, false},\n\t\t}, everyoneGrants)\n\n\t\t// Ben: Before revoking\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"mytopic\", PermissionReadWrite)) // Overwrite!\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"readme\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"writeme\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(ben, \"mytopic\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"mytopic\", PermissionWrite))\n\t\trequire.Nil(t, a.Authorize(ben, \"readme\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(ben, \"writeme\", PermissionWrite))\n\n\t\t// Revoke access for \"ben\" to \"mytopic\", then check again\n\t\trequire.Nil(t, a.ResetAccess(\"ben\", \"mytopic\"))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"mytopic\", PermissionWrite)) // Revoked\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"mytopic\", PermissionRead))  // Revoked\n\t\trequire.Nil(t, a.Authorize(ben, \"readme\", PermissionRead))                      // Unchanged\n\t\trequire.Nil(t, a.Authorize(ben, \"writeme\", PermissionWrite))                    // Unchanged\n\n\t\t// Revoke rest of the access\n\t\trequire.Nil(t, a.ResetAccess(\"ben\", \"\"))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"readme\", PermissionRead))    // Revoked\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(ben, \"wrtiteme\", PermissionWrite)) // Revoked\n\n\t\t// User list\n\t\tusers, err := a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 3, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"ben\", users[1].Name)\n\t\trequire.Equal(t, \"*\", users[2].Name)\n\n\t\t// Remove user\n\t\trequire.Nil(t, a.RemoveUser(\"ben\"))\n\t\t_, err = a.User(\"ben\")\n\t\trequire.Equal(t, ErrUserNotFound, err)\n\n\t\tusers, err = a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(users))\n\t\trequire.Equal(t, \"phil\", users[0].Name)\n\t\trequire.Equal(t, \"*\", users[1].Name)\n\t})\n}\n\nfunc TestManager_ChangePassword(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleAdmin, false))\n\t\trequire.Nil(t, a.AddUser(\"jane\", \"$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG\", RoleUser, true))\n\n\t\t_, err := a.Authenticate(\"phil\", \"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t_, err = a.Authenticate(\"jane\", \"jane\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, a.ChangePassword(\"phil\", \"newpass\", false))\n\t\t_, err = a.Authenticate(\"phil\", \"phil\")\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\t\t_, err = a.Authenticate(\"phil\", \"newpass\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, a.ChangePassword(\"jane\", \"$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS\", true))\n\t\t_, err = a.Authenticate(\"jane\", \"jane\")\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\t\t_, err = a.Authenticate(\"jane\", \"newpass\")\n\t\trequire.Nil(t, err)\n\t})\n}\n\nfunc TestManager_ChangeRole(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"mytopic\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"readme\", PermissionRead))\n\n\t\tben, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, RoleUser, ben.Role)\n\n\t\tbenGrants, err := a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(benGrants))\n\n\t\trequire.Nil(t, a.ChangeRole(\"ben\", RoleAdmin))\n\n\t\tben, err = a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, RoleAdmin, ben.Role)\n\n\t\tbenGrants, err = a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(benGrants))\n\t})\n}\n\nfunc TestManager_Reservations(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AddReservation(\"ben\", \"ztopic_\", PermissionDenyAll, 0))\n\t\trequire.Nil(t, a.AddReservation(\"ben\", \"readme\", PermissionRead, 0))\n\t\trequire.Nil(t, a.AllowAccess(\"ben\", \"something-else\", PermissionRead))\n\n\t\treservations, err := a.Reservations(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(reservations))\n\t\trequire.Equal(t, Reservation{\n\t\t\tTopic:    \"readme\",\n\t\t\tOwner:    PermissionReadWrite,\n\t\t\tEveryone: PermissionRead,\n\t\t}, reservations[0])\n\t\trequire.Equal(t, Reservation{\n\t\t\tTopic:    \"ztopic_\",\n\t\t\tOwner:    PermissionReadWrite,\n\t\t\tEveryone: PermissionDenyAll,\n\t\t}, reservations[1])\n\n\t\tb, err := a.HasReservation(\"ben\", \"readme\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, b)\n\n\t\tb, err = a.HasReservation(\"ben\", \"ztopic_\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, b)\n\n\t\tb, err = a.HasReservation(\"ben\", \"ztopicX\") // _ != X (used to be a SQL wildcard issue)\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, b)\n\n\t\tb, err = a.HasReservation(\"notben\", \"readme\")\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, b)\n\n\t\tb, err = a.HasReservation(\"ben\", \"something-else\")\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, b)\n\n\t\tcount, err := a.ReservationsCount(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(2), count)\n\n\t\tcount, err = a.ReservationsCount(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), count)\n\n\t\terr = a.AllowReservation(\"phil\", \"readme\")\n\t\trequire.Equal(t, errTopicOwnedByOthers, err)\n\n\t\terr = a.AllowReservation(\"phil\", \"ztopic_\")\n\t\trequire.Equal(t, errTopicOwnedByOthers, err)\n\n\t\terr = a.AllowReservation(\"phil\", \"ztopicX\")\n\t\trequire.Nil(t, err)\n\n\t\terr = a.AllowReservation(\"phil\", \"not-reserved\")\n\t\trequire.Nil(t, err)\n\n\t\t// Now remove them again\n\t\trequire.Nil(t, a.RemoveReservations(\"ben\", \"ztopic_\", \"readme\"))\n\n\t\tcount, err = a.ReservationsCount(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), count)\n\t})\n}\n\nfunc TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tCode:                     \"pro\",\n\t\t\tName:                     \"ntfy Pro\",\n\t\t\tStripeMonthlyPriceID:     \"price123\",\n\t\t\tMessageLimit:             5_000,\n\t\t\tMessageExpiryDuration:    3 * 24 * time.Hour,\n\t\t\tEmailLimit:               50,\n\t\t\tReservationLimit:         5,\n\t\t\tAttachmentFileSizeLimit:  52428800,\n\t\t\tAttachmentTotalSizeLimit: 524288000,\n\t\t\tAttachmentExpiryDuration: 24 * time.Hour,\n\t\t}))\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.ChangeTier(\"ben\", \"pro\"))\n\t\trequire.Nil(t, a.AddReservation(\"ben\", \"mytopic\", PermissionDenyAll, 0))\n\n\t\tben, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, RoleUser, ben.Role)\n\t\trequire.Equal(t, \"pro\", ben.Tier.Code)\n\t\trequire.Equal(t, int64(5000), ben.Tier.MessageLimit)\n\t\trequire.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration)\n\t\trequire.Equal(t, int64(50), ben.Tier.EmailLimit)\n\t\trequire.Equal(t, int64(5), ben.Tier.ReservationLimit)\n\t\trequire.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)\n\t\trequire.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)\n\t\trequire.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)\n\n\t\tbenGrants, err := a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(benGrants))\n\t\trequire.Equal(t, PermissionReadWrite, benGrants[0].Permission)\n\n\t\teveryoneGrants, err := a.Grants(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(everyoneGrants))\n\t\trequire.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission)\n\n\t\tbenReservations, err := a.Reservations(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(benReservations))\n\t\trequire.Equal(t, \"mytopic\", benReservations[0].Topic)\n\t\trequire.Equal(t, PermissionReadWrite, benReservations[0].Owner)\n\t\trequire.Equal(t, PermissionDenyAll, benReservations[0].Everyone)\n\n\t\t// Switch to admin, this should remove all grants and owned ACL entries\n\t\trequire.Nil(t, a.ChangeRole(\"ben\", RoleAdmin))\n\n\t\tbenGrants, err = a.Grants(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(benGrants))\n\n\t\teveryoneGrants, err = a.Grants(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(everyoneGrants))\n\t})\n}\n\nfunc TestManager_Token_Valid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\t// Create token for user\n\t\ttoken, err := a.CreateToken(u.ID, \"some label\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token.Value)\n\t\trequire.Equal(t, \"some label\", token.Label)\n\t\trequire.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())\n\n\t\tu2, err := a.AuthenticateToken(token.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, u.Name, u2.Name)\n\t\trequire.Equal(t, token.Value, u2.Token)\n\n\t\ttoken2, err := a.Token(u.ID, token.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, token.Value, token2.Value)\n\t\trequire.Equal(t, \"some label\", token2.Label)\n\n\t\ttokens, err := a.Tokens(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(tokens))\n\t\trequire.Equal(t, \"some label\", tokens[0].Label)\n\n\t\ttokens, err = a.Tokens(\"u_notauser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(tokens))\n\n\t\t// Remove token and auth again\n\t\trequire.Nil(t, a.RemoveToken(u2.ID, u2.Token))\n\t\tu3, err := a.AuthenticateToken(token.Value)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\t\trequire.Nil(t, u3)\n\n\t\ttokens, err = a.Tokens(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(tokens))\n\t})\n}\n\nfunc TestManager_Token_Invalid(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\tu, err := a.AuthenticateToken(strings.Repeat(\"x\", 32)) // 32 == token length\n\t\trequire.Nil(t, u)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\tu, err = a.AuthenticateToken(\"not long enough anyway\")\n\t\trequire.Nil(t, u)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\t})\n}\n\nfunc TestManager_Token_NotFound(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\t_, err := a.Token(\"u_bla\", \"notfound\")\n\t\trequire.Equal(t, ErrTokenNotFound, err)\n\t})\n}\n\nfunc TestManager_Token_Expire(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\t// Create tokens for user\n\t\ttoken1, err := a.CreateToken(u.ID, \"\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token1.Value)\n\t\trequire.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())\n\n\t\ttoken2, err := a.CreateToken(u.ID, \"\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token2.Value)\n\t\trequire.NotEqual(t, token1.Value, token2.Value)\n\t\trequire.True(t, time.Now().Add(71*time.Hour).Unix() < token2.Expires.Unix())\n\n\t\t// See that tokens work\n\t\t_, err = a.AuthenticateToken(token1.Value)\n\t\trequire.Nil(t, err)\n\n\t\t_, err = a.AuthenticateToken(token2.Value)\n\t\trequire.Nil(t, err)\n\n\t\t// Expire token1 via the API\n\t\t_, err = a.ChangeToken(u.ID, token1.Value, nil, util.Time(time.Unix(1, 0)))\n\t\trequire.Nil(t, err)\n\n\t\t// Now token1 shouldn't work anymore\n\t\t_, err = a.AuthenticateToken(token1.Value)\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\t// But the token row should still exist\n\t\ttokens, err := a.Tokens(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(tokens))\n\n\t\t// Expire tokens and check that token1 is gone\n\t\trequire.Nil(t, a.RemoveExpiredTokens())\n\n\t\ttokens, err = a.Tokens(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(tokens))\n\t\trequire.Equal(t, token2.Value, tokens[0].Value)\n\t})\n}\n\nfunc TestManager_Token_Extend(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\t// Try to extend token for user without token\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\t_, err = a.ChangeToken(u.ID, u.Token, util.String(\"some label\"), util.Time(time.Now().Add(time.Hour)))\n\t\trequire.Equal(t, errNoTokenProvided, err)\n\n\t\t// Create token for user\n\t\ttoken, err := a.CreateToken(u.ID, \"\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token.Value)\n\n\t\tuserWithToken, err := a.AuthenticateToken(token.Value)\n\t\trequire.Nil(t, err)\n\n\t\textendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String(\"changed label\"), util.Time(time.Now().Add(100*time.Hour)))\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, token.Value, extendedToken.Value)\n\t\trequire.Equal(t, \"changed label\", extendedToken.Label)\n\t\trequire.True(t, token.Expires.Unix() < extendedToken.Expires.Unix())\n\t\trequire.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix())\n\t})\n}\n\nfunc TestManager_Token_MaxCount_AutoDelete(t *testing.T) {\n\t// Tests that tokens are automatically deleted when the maximum number of tokens is reached\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\n\t\tben, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\tphil, err := a.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t// Create 2 tokens for phil\n\t\tphilTokens := make([]string, 0)\n\t\ttoken, err := a.CreateToken(phil.ID, \"\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token.Value)\n\t\tphilTokens = append(philTokens, token.Value)\n\n\t\ttoken, err = a.CreateToken(phil.ID, \"\", time.Unix(0, 0), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, token.Value)\n\t\tphilTokens = append(philTokens, token.Value)\n\n\t\t// Create 62 tokens for ben (only 60 allowed!)\n\t\tbaseTime := time.Now().Add(24 * time.Hour)\n\t\tbenTokens := make([]string, 0)\n\t\tfor i := 0; i < 62; i++ { //\n\t\t\ttoken, err := a.CreateToken(ben.ID, \"\", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)\n\t\t\trequire.Nil(t, err)\n\t\t\trequire.NotEmpty(t, token.Value)\n\t\t\tbenTokens = append(benTokens, token.Value)\n\n\t\t\t// Manually modify expiry date to avoid sorting issues (this is a hack)\n\t\t\t_, err = a.ChangeToken(ben.ID, token.Value, nil, util.Time(baseTime.Add(time.Duration(i)*time.Minute)))\n\t\t\trequire.Nil(t, err)\n\t\t}\n\n\t\t// Ben: The first 2 tokens should have been wiped and should not work anymore!\n\t\t_, err = a.AuthenticateToken(benTokens[0])\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\t_, err = a.AuthenticateToken(benTokens[1])\n\t\trequire.Equal(t, ErrUnauthenticated, err)\n\n\t\t// Ben: The other tokens should still work\n\t\tfor i := 2; i < 62; i++ {\n\t\t\tuserWithToken, err := a.AuthenticateToken(benTokens[i])\n\t\t\trequire.Nil(t, err, \"token[%d]=%s failed\", i, benTokens[i])\n\t\t\trequire.Equal(t, \"ben\", userWithToken.Name)\n\t\t\trequire.Equal(t, benTokens[i], userWithToken.Token)\n\t\t}\n\n\t\t// Phil: All tokens should still work\n\t\tfor i := 0; i < 2; i++ {\n\t\t\tuserWithToken, err := a.AuthenticateToken(philTokens[i])\n\t\t\trequire.Nil(t, err, \"token[%d]=%s failed\", i, philTokens[i])\n\t\t\trequire.Equal(t, \"phil\", userWithToken.Name)\n\t\t\trequire.Equal(t, philTokens[i], userWithToken.Token)\n\t\t}\n\n\t\tbenTokensList, err := a.Tokens(ben.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 60, len(benTokensList))\n\n\t\tphilTokensList, err := a.Tokens(phil.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(philTokensList))\n\t})\n}\n\nfunc TestManager_EnqueueStats_ResetStats(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:       PermissionReadWrite,\n\t\t\tBcryptCost:          bcrypt.MinCost,\n\t\t\tQueueWriterInterval: 1500 * time.Millisecond,\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\t// Baseline: No messages or emails\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), u.Stats.Messages)\n\t\trequire.Equal(t, int64(0), u.Stats.Emails)\n\t\ta.EnqueueUserStats(u.ID, &Stats{\n\t\t\tMessages: 11,\n\t\t\tEmails:   2,\n\t\t})\n\n\t\t// Still no change, because it's queued asynchronously\n\t\tu, err = a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), u.Stats.Messages)\n\t\trequire.Equal(t, int64(0), u.Stats.Emails)\n\n\t\t// After 2 seconds they should be persisted\n\t\ttime.Sleep(2 * time.Second)\n\n\t\tu, err = a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(11), u.Stats.Messages)\n\t\trequire.Equal(t, int64(2), u.Stats.Emails)\n\n\t\t// Now reset stats (enqueued stats will be thrown out)\n\t\ta.EnqueueUserStats(u.ID, &Stats{\n\t\t\tMessages: 99,\n\t\t\tEmails:   23,\n\t\t})\n\t\trequire.Nil(t, a.ResetStats())\n\n\t\tu, err = a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), u.Stats.Messages)\n\t\trequire.Equal(t, int64(0), u.Stats.Emails)\n\t})\n}\n\nfunc TestManager_EnqueueTokenUpdate(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:       PermissionReadWrite,\n\t\t\tBcryptCost:          bcrypt.MinCost,\n\t\t\tQueueWriterInterval: 500 * time.Millisecond,\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\t// Create user and token\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\ttoken, err := a.CreateToken(u.ID, \"\", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)\n\t\trequire.Nil(t, err)\n\n\t\t// Queue token update\n\t\ta.EnqueueTokenUpdate(token.Value, &TokenUpdate{\n\t\t\tLastAccess: time.Unix(111, 0).UTC(),\n\t\t\tLastOrigin: netip.MustParseAddr(\"1.2.3.3\"),\n\t\t})\n\n\t\t// Token has not changed yet.\n\t\ttoken2, err := a.Token(u.ID, token.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix())\n\t\trequire.Equal(t, token.LastOrigin, token2.LastOrigin)\n\n\t\t// After a second or so they should be persisted\n\t\ttime.Sleep(time.Second)\n\n\t\ttoken3, err := a.Token(u.ID, token.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix())\n\t\trequire.Equal(t, netip.MustParseAddr(\"1.2.3.3\"), token3.LastOrigin)\n\t})\n}\n\nfunc TestManager_ChangeSettings(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:       PermissionReadWrite,\n\t\t\tBcryptCost:          bcrypt.MinCost,\n\t\t\tQueueWriterInterval: 1500 * time.Millisecond,\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\n\t\t// No settings\n\t\tu, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, u.Prefs.Subscriptions)\n\t\trequire.Nil(t, u.Prefs.Notification)\n\t\trequire.Nil(t, u.Prefs.Language)\n\n\t\t// Save with new settings\n\t\tprefs := &Prefs{\n\t\t\tLanguage: util.String(\"de\"),\n\t\t\tNotification: &NotificationPrefs{\n\t\t\t\tSound:       util.String(\"ding\"),\n\t\t\t\tMinPriority: util.Int(2),\n\t\t\t},\n\t\t\tSubscriptions: []*Subscription{\n\t\t\t\t{\n\t\t\t\t\tBaseURL:     \"https://ntfy.sh\",\n\t\t\t\t\tTopic:       \"mytopic\",\n\t\t\t\t\tDisplayName: util.String(\"My Topic\"),\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\trequire.Nil(t, a.ChangeSettings(u.ID, prefs))\n\n\t\t// Read again\n\t\tu, err = a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, util.String(\"de\"), u.Prefs.Language)\n\t\trequire.Equal(t, util.String(\"ding\"), u.Prefs.Notification.Sound)\n\t\trequire.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority)\n\t\trequire.Nil(t, u.Prefs.Notification.DeleteAfter)\n\t\trequire.Equal(t, \"https://ntfy.sh\", u.Prefs.Subscriptions[0].BaseURL)\n\t\trequire.Equal(t, \"mytopic\", u.Prefs.Subscriptions[0].Topic)\n\t\trequire.Equal(t, util.String(\"My Topic\"), u.Prefs.Subscriptions[0].DisplayName)\n\t})\n}\n\nfunc TestManager_Tier_Create_Update_List_Delete(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tCode:                     \"supporter\",\n\t\t\tName:                     \"Supporter\",\n\t\t\tMessageLimit:             1,\n\t\t\tMessageExpiryDuration:    time.Second,\n\t\t\tEmailLimit:               1,\n\t\t\tReservationLimit:         1,\n\t\t\tAttachmentFileSizeLimit:  1,\n\t\t\tAttachmentTotalSizeLimit: 1,\n\t\t\tAttachmentExpiryDuration: time.Second,\n\t\t\tAttachmentBandwidthLimit: 1,\n\t\t\tStripeMonthlyPriceID:     \"price_1\",\n\t\t}))\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tCode:                     \"pro\",\n\t\t\tName:                     \"Pro\",\n\t\t\tMessageLimit:             123,\n\t\t\tMessageExpiryDuration:    86400 * time.Second,\n\t\t\tEmailLimit:               32,\n\t\t\tReservationLimit:         2,\n\t\t\tAttachmentFileSizeLimit:  1231231,\n\t\t\tAttachmentTotalSizeLimit: 123123,\n\t\t\tAttachmentExpiryDuration: 10800 * time.Second,\n\t\t\tAttachmentBandwidthLimit: 21474836480,\n\t\t\tStripeMonthlyPriceID:     \"price_2\",\n\t\t}))\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\t\trequire.Nil(t, a.ChangeTier(\"phil\", \"pro\"))\n\n\t\tti, err := a.Tier(\"pro\")\n\t\trequire.Nil(t, err)\n\n\t\tu, err := a.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t// These are populated by different SQL queries\n\t\trequire.Equal(t, ti, u.Tier)\n\n\t\t// Fields\n\t\trequire.True(t, strings.HasPrefix(ti.ID, \"ti_\"))\n\t\trequire.Equal(t, \"pro\", ti.Code)\n\t\trequire.Equal(t, \"Pro\", ti.Name)\n\t\trequire.Equal(t, int64(123), ti.MessageLimit)\n\t\trequire.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)\n\t\trequire.Equal(t, int64(32), ti.EmailLimit)\n\t\trequire.Equal(t, int64(2), ti.ReservationLimit)\n\t\trequire.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)\n\t\trequire.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)\n\t\trequire.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)\n\t\trequire.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)\n\t\trequire.Equal(t, \"price_2\", ti.StripeMonthlyPriceID)\n\n\t\t// Update tier\n\t\tti.EmailLimit = 999999\n\t\trequire.Nil(t, a.UpdateTier(ti))\n\n\t\t// List tiers\n\t\ttiers, err := a.Tiers()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(tiers))\n\n\t\tti = tiers[0]\n\t\trequire.Equal(t, \"supporter\", ti.Code)\n\t\trequire.Equal(t, \"Supporter\", ti.Name)\n\t\trequire.Equal(t, int64(1), ti.MessageLimit)\n\t\trequire.Equal(t, time.Second, ti.MessageExpiryDuration)\n\t\trequire.Equal(t, int64(1), ti.EmailLimit)\n\t\trequire.Equal(t, int64(1), ti.ReservationLimit)\n\t\trequire.Equal(t, int64(1), ti.AttachmentFileSizeLimit)\n\t\trequire.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)\n\t\trequire.Equal(t, time.Second, ti.AttachmentExpiryDuration)\n\t\trequire.Equal(t, int64(1), ti.AttachmentBandwidthLimit)\n\t\trequire.Equal(t, \"price_1\", ti.StripeMonthlyPriceID)\n\n\t\tti = tiers[1]\n\t\trequire.Equal(t, \"pro\", ti.Code)\n\t\trequire.Equal(t, \"Pro\", ti.Name)\n\t\trequire.Equal(t, int64(123), ti.MessageLimit)\n\t\trequire.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)\n\t\trequire.Equal(t, int64(999999), ti.EmailLimit) // Updatedd!\n\t\trequire.Equal(t, int64(2), ti.ReservationLimit)\n\t\trequire.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)\n\t\trequire.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)\n\t\trequire.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)\n\t\trequire.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)\n\t\trequire.Equal(t, \"price_2\", ti.StripeMonthlyPriceID)\n\n\t\tti, err = a.TierByStripePrice(\"price_1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"supporter\", ti.Code)\n\t\trequire.Equal(t, \"Supporter\", ti.Name)\n\t\trequire.Equal(t, int64(1), ti.MessageLimit)\n\t\trequire.Equal(t, time.Second, ti.MessageExpiryDuration)\n\t\trequire.Equal(t, int64(1), ti.EmailLimit)\n\t\trequire.Equal(t, int64(1), ti.ReservationLimit)\n\t\trequire.Equal(t, int64(1), ti.AttachmentFileSizeLimit)\n\t\trequire.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)\n\t\trequire.Equal(t, time.Second, ti.AttachmentExpiryDuration)\n\t\trequire.Equal(t, int64(1), ti.AttachmentBandwidthLimit)\n\t\trequire.Equal(t, \"price_1\", ti.StripeMonthlyPriceID)\n\n\t\t// Cannot remove tier, since user has this tier\n\t\trequire.Error(t, a.RemoveTier(\"pro\"))\n\n\t\t// CAN remove this tier\n\t\trequire.Nil(t, a.RemoveTier(\"supporter\"))\n\n\t\ttiers, err = a.Tiers()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(tiers))\n\t\trequire.Equal(t, \"pro\", tiers[0].Code)\n\t\trequire.Equal(t, \"pro\", tiers[0].Code)\n\t})\n}\n\nfunc TestAccount_Tier_Create_With_ID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tID:   \"ti_123\",\n\t\t\tCode: \"pro\",\n\t\t}))\n\n\t\tti, err := a.Tier(\"pro\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"ti_123\", ti.ID)\n\t})\n}\n\nfunc TestManager_Tier_Change_And_Reset(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\t// Create tier and user\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tCode:             \"supporter\",\n\t\t\tName:             \"Supporter\",\n\t\t\tReservationLimit: 3,\n\t\t}))\n\t\trequire.Nil(t, a.AddTier(&Tier{\n\t\t\tCode:             \"pro\",\n\t\t\tName:             \"Pro\",\n\t\t\tReservationLimit: 4,\n\t\t}))\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\t\trequire.Nil(t, a.ChangeTier(\"phil\", \"pro\"))\n\n\t\t// Add 10 reservations (pro tier allows that)\n\t\tfor i := 0; i < 4; i++ {\n\t\t\trequire.Nil(t, a.AddReservation(\"phil\", fmt.Sprintf(\"topic%d\", i), PermissionWrite, 0))\n\t\t}\n\n\t\t// Downgrading will not work (too many reservations)\n\t\trequire.Equal(t, ErrTooManyReservations, a.ChangeTier(\"phil\", \"supporter\"))\n\n\t\t// Downgrade after removing a reservation\n\t\trequire.Nil(t, a.RemoveReservations(\"phil\", \"topic0\"))\n\t\trequire.Nil(t, a.ChangeTier(\"phil\", \"supporter\"))\n\n\t\t// Resetting will not work (too many reservations)\n\t\trequire.Equal(t, ErrTooManyReservations, a.ResetTier(\"phil\"))\n\n\t\t// Resetting after removing all reservations\n\t\trequire.Nil(t, a.RemoveReservations(\"phil\", \"topic1\", \"topic2\", \"topic3\"))\n\t\trequire.Nil(t, a.ResetTier(\"phil\"))\n\t})\n}\n\nfunc TestUser_PhoneNumberAddListRemove(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\t\tphil, err := a.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, a.AddPhoneNumber(phil.ID, \"+1234567890\"))\n\n\t\tphoneNumbers, err := a.PhoneNumbers(phil.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(phoneNumbers))\n\t\trequire.Equal(t, \"+1234567890\", phoneNumbers[0])\n\n\t\trequire.Nil(t, a.RemovePhoneNumber(phil.ID, \"+1234567890\"))\n\t\tphoneNumbers, err = a.PhoneNumbers(phil.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(phoneNumbers))\n\n\t\t// Paranoia check: We do NOT want to keep phone numbers in there\n\t\trows, err := testDB(a).Query(`SELECT * FROM user_phone`)\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, rows.Next())\n\t\trequire.Nil(t, rows.Close())\n\t})\n}\n\nfunc TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\n\t\trequire.Nil(t, a.AddUser(\"phil\", \"phil\", RoleUser, false))\n\t\trequire.Nil(t, a.AddUser(\"ben\", \"ben\", RoleUser, false))\n\t\tphil, err := a.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\tben, err := a.User(\"ben\")\n\t\trequire.Nil(t, err)\n\t\trequire.Nil(t, a.AddPhoneNumber(phil.ID, \"+1234567890\"))\n\t\trequire.Nil(t, a.AddPhoneNumber(ben.ID, \"+1234567890\"))\n\t})\n}\n\nfunc TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"*_\", PermissionRead))\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"__*_\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"allowed_\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"__allowed_\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"_allowed_\", PermissionRead)) // The \"%\" in \"%\\_\" matches the first \"_\"\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"notallowed\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"_notallowed\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"__notallowed\", PermissionRead))\n\t})\n}\n\nfunc TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\ta := newTestManager(t, newManager, PermissionDenyAll)\n\t\trequire.Nil(t, a.AllowAccess(Everyone, \"mytopic_\", PermissionReadWrite))\n\t\trequire.Nil(t, a.Authorize(nil, \"mytopic_\", PermissionRead))\n\t\trequire.Nil(t, a.Authorize(nil, \"mytopic_\", PermissionWrite))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"mytopicX\", PermissionRead))\n\t\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"mytopicX\", PermissionWrite))\n\t})\n}\n\nfunc TestManager_WithProvisionedUsers(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:    PermissionReadWrite,\n\t\t\tProvisionEnabled: true,\n\t\t\tUsers: []*User{\n\t\t\t\t{Name: \"philuser\", Hash: \"$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleUser},\n\t\t\t\t{Name: \"philadmin\", Hash: \"$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleAdmin},\n\t\t\t},\n\t\t\tAccess: map[string][]*Grant{\n\t\t\t\t\"philuser\": {\n\t\t\t\t\t{TopicPattern: \"stats\", Permission: PermissionReadWrite},\n\t\t\t\t\t{TopicPattern: \"secret\", Permission: PermissionRead},\n\t\t\t\t},\n\t\t\t},\n\t\t\tTokens: map[string][]*Token{\n\t\t\t\t\"philuser\": {\n\t\t\t\t\t{Value: \"tk_op56p8lz5bf3cxkz9je99v9oc37lo\", Label: \"Alerts token\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Manually add user\n\t\trequire.Nil(t, a.AddUser(\"philmanual\", \"manual\", RoleUser, false))\n\n\t\t// Check that the provisioned users are there\n\t\tusers, err := a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 4)\n\t\trequire.Equal(t, \"philadmin\", users[0].Name)\n\t\trequire.Equal(t, RoleAdmin, users[0].Role)\n\t\trequire.Equal(t, \"philmanual\", users[1].Name)\n\t\trequire.Equal(t, RoleUser, users[1].Role)\n\t\trequire.Equal(t, \"philuser\", users[2].Name)\n\t\trequire.Equal(t, RoleUser, users[2].Role)\n\t\trequire.Equal(t, \"*\", users[3].Name)\n\t\tprovisionedUserID := users[2].ID // \"philuser\" is the provisioned user\n\n\t\tgrants, err := a.Grants(\"philuser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(grants))\n\t\trequire.Equal(t, \"secret\", grants[0].TopicPattern)\n\t\trequire.Equal(t, PermissionRead, grants[0].Permission)\n\t\trequire.Equal(t, \"stats\", grants[1].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[1].Permission)\n\n\t\ttokens, err := a.Tokens(provisionedUserID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(tokens))\n\t\trequire.Equal(t, \"tk_op56p8lz5bf3cxkz9je99v9oc37lo\", tokens[0].Value)\n\t\trequire.Equal(t, \"Alerts token\", tokens[0].Label)\n\t\trequire.True(t, tokens[0].Provisioned)\n\n\t\t// Update the token last access time and origin (so we can check that it is persisted)\n\t\tlastAccessTime := time.Now().Add(time.Hour)\n\t\tlastOrigin := netip.MustParseAddr(\"1.1.9.9\")\n\t\ta.EnqueueTokenUpdate(tokens[0].Value, &TokenUpdate{LastAccess: lastAccessTime, LastOrigin: lastOrigin})\n\t\terr = a.writeTokenUpdateQueue()\n\t\trequire.Nil(t, err)\n\n\t\t// Re-open the DB (second app start)\n\t\trequire.Nil(t, a.Close())\n\t\tconf.Users = []*User{\n\t\t\t{Name: \"philuser\", Hash: \"$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleUser},\n\t\t}\n\t\tconf.Access = map[string][]*Grant{\n\t\t\t\"philuser\": {\n\t\t\t\t{TopicPattern: \"stats12\", Permission: PermissionReadWrite},\n\t\t\t\t{TopicPattern: \"secret12\", Permission: PermissionRead},\n\t\t\t},\n\t\t}\n\t\tconf.Tokens = map[string][]*Token{\n\t\t\t\"philuser\": {\n\t\t\t\t{Value: \"tk_op56p8lz5bf3cxkz9je99v9oc37lo\", Label: \"Alerts token updated\"},\n\t\t\t\t{Value: \"tk_u48wqendnkx9er21pqqcadlytbutx\", Label: \"Another token\"},\n\t\t\t},\n\t\t}\n\t\ta = newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Check that the provisioned users are there\n\t\tusers, err = a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 3)\n\t\trequire.Equal(t, \"philmanual\", users[0].Name)\n\t\trequire.Equal(t, \"philuser\", users[1].Name)\n\t\trequire.Equal(t, RoleUser, users[1].Role)\n\t\trequire.Equal(t, RoleUser, users[0].Role)\n\t\trequire.Equal(t, \"*\", users[2].Name)\n\n\t\tgrants, err = a.Grants(\"philuser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(grants))\n\t\trequire.Equal(t, \"secret12\", grants[0].TopicPattern)\n\t\trequire.Equal(t, PermissionRead, grants[0].Permission)\n\t\trequire.Equal(t, \"stats12\", grants[1].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[1].Permission)\n\n\t\ttokens, err = a.Tokens(provisionedUserID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(tokens))\n\t\trequire.Equal(t, \"tk_op56p8lz5bf3cxkz9je99v9oc37lo\", tokens[0].Value)\n\t\trequire.Equal(t, \"Alerts token updated\", tokens[0].Label)\n\t\trequire.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix())\n\t\trequire.Equal(t, lastOrigin, tokens[0].LastOrigin)\n\t\trequire.True(t, tokens[0].Provisioned)\n\t\trequire.Equal(t, \"tk_u48wqendnkx9er21pqqcadlytbutx\", tokens[1].Value)\n\t\trequire.Equal(t, \"Another token\", tokens[1].Label)\n\n\t\t// Try changing provisioned user's password\n\t\trequire.Error(t, a.ChangePassword(\"philuser\", \"new-pass\", false))\n\n\t\t// Re-open the DB again (third app start)\n\t\trequire.Nil(t, a.Close())\n\t\tconf.Users = []*User{}\n\t\tconf.Access = map[string][]*Grant{}\n\t\tconf.Tokens = map[string][]*Token{}\n\t\ta = newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Check that the provisioned users are all gone\n\t\tusers, err = a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 2)\n\n\t\trequire.Equal(t, \"philmanual\", users[0].Name)\n\t\trequire.Equal(t, RoleUser, users[0].Role)\n\t\trequire.Equal(t, \"*\", users[1].Name)\n\n\t\tgrants, err = a.Grants(\"philuser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(grants))\n\n\t\ttokens, err = a.Tokens(provisionedUserID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 0, len(tokens))\n\n\t\t// Verify no provisioned data remains\n\t\tfor _, u := range users {\n\t\t\trequire.False(t, u.Provisioned)\n\t\t\tuserGrants, err := a.Grants(u.Name)\n\t\t\trequire.Nil(t, err)\n\t\t\tfor _, g := range userGrants {\n\t\t\t\trequire.False(t, g.Provisioned)\n\t\t\t}\n\t\t\tuserTokens, err := a.Tokens(u.ID)\n\t\t\trequire.Nil(t, err)\n\t\t\tfor _, tk := range userTokens {\n\t\t\t\trequire.False(t, tk.Provisioned)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:    PermissionReadWrite,\n\t\t\tProvisionEnabled: true,\n\t\t\tUsers: []*User{\n\t\t\t\t{Name: \"phil\", Hash: \"$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleUser},\n\t\t\t},\n\t\t\tTokens: map[string][]*Token{\n\t\t\t\t\"phil\": {\n\t\t\t\t\t{Value: \"tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", Label: \"Token A\"},\n\t\t\t\t\t{Value: \"tk_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\", Label: \"Token B\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\n\t\tusers, err := a.Users()\n\t\trequire.Nil(t, err)\n\t\tphilUserID := \"\"\n\t\tfor _, u := range users {\n\t\t\tif u.Name == \"phil\" {\n\t\t\t\tphilUserID = u.ID\n\t\t\t}\n\t\t}\n\t\trequire.NotEmpty(t, philUserID)\n\n\t\ttokens, err := a.Tokens(philUserID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(tokens))\n\n\t\t// Re-open the DB: user stays, but Token B is removed from config\n\t\trequire.Nil(t, a.Close())\n\t\tconf.Tokens = map[string][]*Token{\n\t\t\t\"phil\": {\n\t\t\t\t{Value: \"tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", Label: \"Token A\"},\n\t\t\t},\n\t\t}\n\t\ta = newTestManagerFromConfig(t, newManager, conf)\n\n\t\ttokens, err = a.Tokens(philUserID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(tokens))\n\t\trequire.Equal(t, \"tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", tokens[0].Value)\n\t})\n}\n\nfunc TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\tconf := &Config{\n\t\t\tDefaultAccess:    PermissionReadWrite,\n\t\t\tProvisionEnabled: true,\n\t\t\tUsers:            []*User{},\n\t\t\tAccess: map[string][]*Grant{\n\t\t\t\tEveryone: {\n\t\t\t\t\t{TopicPattern: \"food\", Permission: PermissionRead},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Manually add user\n\t\trequire.Nil(t, a.AddUser(\"philuser\", \"manual\", RoleUser, false))\n\t\trequire.Nil(t, a.AllowAccess(\"philuser\", \"stats\", PermissionReadWrite))\n\t\trequire.Nil(t, a.AllowAccess(\"philuser\", \"food\", PermissionReadWrite))\n\n\t\tusers, err := a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 2)\n\t\trequire.Equal(t, \"philuser\", users[0].Name)\n\t\trequire.Equal(t, RoleUser, users[0].Role)\n\t\trequire.False(t, users[0].Provisioned) // Manually added\n\n\t\tgrants, err := a.Grants(\"philuser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(grants))\n\t\trequire.Equal(t, \"stats\", grants[0].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[0].Permission)\n\t\trequire.False(t, grants[0].Provisioned) // Manually added\n\t\trequire.Equal(t, \"food\", grants[1].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[1].Permission)\n\t\trequire.False(t, grants[1].Provisioned) // Manually added\n\n\t\tgrants, err = a.Grants(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 1, len(grants))\n\t\trequire.Equal(t, \"food\", grants[0].TopicPattern)\n\t\trequire.Equal(t, PermissionRead, grants[0].Permission)\n\t\trequire.True(t, grants[0].Provisioned) // Provisioned entry\n\n\t\t// Re-open the DB (second app start)\n\t\trequire.Nil(t, a.Close())\n\t\tconf.Users = []*User{\n\t\t\t{Name: \"philuser\", Hash: \"$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleUser},\n\t\t}\n\t\tconf.Access = map[string][]*Grant{\n\t\t\t\"philuser\": {\n\t\t\t\t{TopicPattern: \"stats\", Permission: PermissionReadWrite},\n\t\t\t},\n\t\t}\n\t\ta = newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Check that the user was \"upgraded\" to a provisioned user\n\t\tusers, err = a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 2)\n\t\trequire.Equal(t, \"philuser\", users[0].Name)\n\t\trequire.Equal(t, RoleUser, users[0].Role)\n\t\trequire.Equal(t, \"$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", users[0].Hash)\n\t\trequire.True(t, users[0].Provisioned) // Updated to provisioned!\n\n\t\tgrants, err = a.Grants(\"philuser\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, len(grants))\n\t\trequire.Equal(t, \"stats\", grants[0].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[0].Permission)\n\t\trequire.True(t, grants[0].Provisioned) // Updated to provisioned!\n\t\trequire.Equal(t, \"food\", grants[1].TopicPattern)\n\t\trequire.Equal(t, PermissionReadWrite, grants[1].Permission)\n\t\trequire.False(t, grants[1].Provisioned) // Manually added grants stay!\n\n\t\tgrants, err = a.Grants(Everyone)\n\t\trequire.Nil(t, err)\n\t\trequire.Empty(t, grants)\n\t})\n}\n\nfunc TestManager_RemoveProvisionedOnEmptyConfig(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, newManager newManagerFunc) {\n\t\t// Start with provisioned users, access, and tokens\n\t\tconf := &Config{\n\t\t\tDefaultAccess:    PermissionReadWrite,\n\t\t\tProvisionEnabled: true,\n\t\t\tBcryptCost:       bcrypt.MinCost,\n\t\t\tUsers: []*User{\n\t\t\t\t{Name: \"provuser\", Hash: \"$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", Role: RoleUser},\n\t\t\t},\n\t\t\tAccess: map[string][]*Grant{\n\t\t\t\t\"provuser\": {\n\t\t\t\t\t{TopicPattern: \"stats\", Permission: PermissionReadWrite},\n\t\t\t\t},\n\t\t\t},\n\t\t\tTokens: map[string][]*Token{\n\t\t\t\t\"provuser\": {\n\t\t\t\t\t{Value: \"tk_op56p8lz5bf3cxkz9je99v9oc37lo\", Label: \"Provisioned token\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\ta := newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Also add a manual (non-provisioned) user\n\t\trequire.Nil(t, a.AddUser(\"manualuser\", \"manual\", RoleUser, false))\n\n\t\t// Verify initial state\n\t\tusers, err := a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 3) // provuser, manualuser, everyone\n\n\t\t// Re-open with empty provisioning config (simulates config change)\n\t\trequire.Nil(t, a.Close())\n\t\tconf.Users = nil\n\t\tconf.Access = nil\n\t\tconf.Tokens = nil\n\t\ta = newTestManagerFromConfig(t, newManager, conf)\n\n\t\t// Provisioned user should be removed, manual user should remain\n\t\tusers, err = a.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, users, 2)\n\t\trequire.Equal(t, \"manualuser\", users[0].Name)\n\t\trequire.False(t, users[0].Provisioned)\n\t\trequire.Equal(t, \"*\", users[1].Name) // everyone\n\t})\n}\n\nfunc TestToFromSQLWildcard(t *testing.T) {\n\trequire.Equal(t, \"up%\", toSQLWildcard(\"up*\"))\n\trequire.Equal(t, \"up\\\\_%\", toSQLWildcard(\"up_*\"))\n\trequire.Equal(t, \"foo\", toSQLWildcard(\"foo\"))\n\n\trequire.Equal(t, \"up*\", fromSQLWildcard(\"up%\"))\n\trequire.Equal(t, \"up_*\", fromSQLWildcard(\"up\\\\_%\"))\n\trequire.Equal(t, \"foo\", fromSQLWildcard(\"foo\"))\n\n\trequire.Equal(t, \"up*\", fromSQLWildcard(toSQLWildcard(\"up*\")))\n\trequire.Equal(t, \"up_*\", fromSQLWildcard(toSQLWildcard(\"up_*\")))\n\trequire.Equal(t, \"foo\", fromSQLWildcard(toSQLWildcard(\"foo\")))\n}\n\nfunc TestMigrationFrom1(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"user.db\")\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\n\t// Create \"version 1\" schema\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tCREATE TABLE IF NOT EXISTS user (\n\t\t\tuser TEXT NOT NULL PRIMARY KEY,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT NOT NULL\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS access (\n\t\t\tuser TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread INT NOT NULL,\n\t\t\twrite INT NOT NULL,\n\t\t\tPRIMARY KEY (topic, user)\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO schemaVersion (id, version) VALUES (1, 1);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Insert a bunch of users and ACL entries\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tINSERT INTO user (user, pass, role) VALUES ('ben', '$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy', 'user');\n\t\tINSERT INTO user (user, pass, role) VALUES ('phil', '$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C', 'admin');\n\t\tINSERT INTO access (user, topic, read, write) VALUES ('ben', 'stats', 1, 1);\n\t\tINSERT INTO access (user, topic, read, write) VALUES ('ben', 'secret', 1, 0);\n\t\tINSERT INTO access (user, topic, read, write) VALUES ('*', 'stats', 1, 0);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Create manager to trigger migration\n\ta := newTestManagerFromFile(t, filename, \"\", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)\n\tcheckSchemaVersion(t, testDB(a))\n\n\tusers, err := a.Users()\n\trequire.Nil(t, err)\n\trequire.Equal(t, 3, len(users))\n\tphil, ben, everyone := users[0], users[1], users[2]\n\n\tphilGrants, err := a.Grants(\"phil\")\n\trequire.Nil(t, err)\n\n\tbenGrants, err := a.Grants(\"ben\")\n\trequire.Nil(t, err)\n\n\teveryoneGrants, err := a.Grants(Everyone)\n\trequire.Nil(t, err)\n\n\trequire.True(t, strings.HasPrefix(phil.ID, \"u_\"))\n\trequire.Equal(t, \"phil\", phil.Name)\n\trequire.Equal(t, RoleAdmin, phil.Role)\n\trequire.Equal(t, syncTopicLength, len(phil.SyncTopic))\n\trequire.Equal(t, 0, len(philGrants))\n\n\trequire.True(t, strings.HasPrefix(ben.ID, \"u_\"))\n\trequire.NotEqual(t, phil.ID, ben.ID)\n\trequire.Equal(t, \"ben\", ben.Name)\n\trequire.Equal(t, RoleUser, ben.Role)\n\trequire.Equal(t, syncTopicLength, len(ben.SyncTopic))\n\trequire.NotEqual(t, ben.SyncTopic, phil.SyncTopic)\n\trequire.Equal(t, 2, len(benGrants))\n\trequire.Equal(t, \"secret\", benGrants[0].TopicPattern)\n\trequire.Equal(t, PermissionRead, benGrants[0].Permission)\n\trequire.Equal(t, \"stats\", benGrants[1].TopicPattern)\n\trequire.Equal(t, PermissionReadWrite, benGrants[1].Permission)\n\n\trequire.Equal(t, \"u_everyone\", everyone.ID)\n\trequire.Equal(t, Everyone, everyone.Name)\n\trequire.Equal(t, RoleAnonymous, everyone.Role)\n\trequire.Equal(t, 1, len(everyoneGrants))\n\trequire.Equal(t, \"stats\", everyoneGrants[0].TopicPattern)\n\trequire.Equal(t, PermissionRead, everyoneGrants[0].Permission)\n}\n\nfunc TestMigrationFrom4(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"user.db\")\n\tdb, err := sql.Open(\"sqlite3\", filename)\n\trequire.Nil(t, err)\n\n\t// Create \"version 4\" schema\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tCREATE TABLE IF NOT EXISTS tier (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tcode TEXT NOT NULL,\n\t\t\tname TEXT NOT NULL,\n\t\t\tmessages_limit INT NOT NULL,\n\t\t\tmessages_expiry_duration INT NOT NULL,\n\t\t\temails_limit INT NOT NULL,\n\t\t\tcalls_limit INT NOT NULL,\n\t\t\treservations_limit INT NOT NULL,\n\t\t\tattachment_file_size_limit INT NOT NULL,\n\t\t\tattachment_total_size_limit INT NOT NULL,\n\t\t\tattachment_expiry_duration INT NOT NULL,\n\t\t\tattachment_bandwidth_limit INT NOT NULL,\n\t\t\tstripe_monthly_price_id TEXT,\n\t\t\tstripe_yearly_price_id TEXT\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_tier_code ON tier (code);\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);\n\t\tCREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);\n\t\tCREATE TABLE IF NOT EXISTS user (\n\t\t    id TEXT PRIMARY KEY,\n\t\t\ttier_id TEXT,\n\t\t\tuser TEXT NOT NULL,\n\t\t\tpass TEXT NOT NULL,\n\t\t\trole TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,\n\t\t\tprefs JSON NOT NULL DEFAULT '{}',\n\t\t\tsync_topic TEXT NOT NULL,\n\t\t\tstats_messages INT NOT NULL DEFAULT (0),\n\t\t\tstats_emails INT NOT NULL DEFAULT (0),\n\t\t\tstats_calls INT NOT NULL DEFAULT (0),\n\t\t\tstripe_customer_id TEXT,\n\t\t\tstripe_subscription_id TEXT,\n\t\t\tstripe_subscription_status TEXT,\n\t\t\tstripe_subscription_interval TEXT,\n\t\t\tstripe_subscription_paid_until INT,\n\t\t\tstripe_subscription_cancel_at INT,\n\t\t\tcreated INT NOT NULL,\n\t\t\tdeleted INT,\n\t\t    FOREIGN KEY (tier_id) REFERENCES tier (id)\n\t\t);\n\t\tCREATE UNIQUE INDEX idx_user ON user (user);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);\n\t\tCREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);\n\t\tCREATE TABLE IF NOT EXISTS user_access (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tread INT NOT NULL,\n\t\t\twrite INT NOT NULL,\n\t\t\towner_user_id INT,\n\t\t\tPRIMARY KEY (user_id, topic),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,\n\t\t    FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_token (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\ttoken TEXT NOT NULL,\n\t\t\tlabel TEXT NOT NULL,\n\t\t\tlast_access INT NOT NULL,\n\t\t\tlast_origin TEXT NOT NULL,\n\t\t\texpires INT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, token),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS user_phone (\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tphone_number TEXT NOT NULL,\n\t\t\tPRIMARY KEY (user_id, phone_number),\n\t\t\tFOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t\tINSERT INTO user (id, user, pass, role, sync_topic, created)\n\t\tVALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())\n\t\tON CONFLICT (id) DO NOTHING;\n\t\tINSERT INTO schemaVersion (id, version) VALUES (1, 4);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Insert a few ACL entries\n\t_, err = db.Exec(`\n\t\tBEGIN;\n\t\tINSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);\n\t\tINSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);\n\t\tINSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);\n\t\tCOMMIT;\n\t`)\n\trequire.Nil(t, err)\n\n\t// Create manager to trigger migration\n\ta := newTestManagerFromFile(t, filename, \"\", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)\n\tcheckSchemaVersion(t, testDB(a))\n\n\t// Add another\n\trequire.Nil(t, a.AllowAccess(Everyone, \"left_*\", PermissionReadWrite))\n\n\t// Check \"external view\" of grants\n\teveryoneGrants, err := a.Grants(Everyone)\n\trequire.Nil(t, err)\n\n\trequire.Equal(t, 4, len(everyoneGrants))\n\trequire.Equal(t, \"mytopic_\", everyoneGrants[0].TopicPattern)\n\trequire.Equal(t, \"down_*\", everyoneGrants[1].TopicPattern)\n\trequire.Equal(t, \"left_*\", everyoneGrants[2].TopicPattern)\n\trequire.Equal(t, \"up*\", everyoneGrants[3].TopicPattern)\n\n\t// Check they are stored correctly in the database\n\trows, err := db.Query(`SELECT topic FROM user_access WHERE user_id = 'u_everyone' ORDER BY topic`)\n\trequire.Nil(t, err)\n\ttopicPatterns := make([]string, 0)\n\tfor rows.Next() {\n\t\tvar topicPattern string\n\t\trequire.Nil(t, rows.Scan(&topicPattern))\n\t\ttopicPatterns = append(topicPatterns, topicPattern)\n\t}\n\trequire.Nil(t, rows.Close())\n\trequire.Equal(t, 4, len(topicPatterns))\n\trequire.Equal(t, \"down\\\\_%\", topicPatterns[0])\n\trequire.Equal(t, \"left\\\\_%\", topicPatterns[1])\n\trequire.Equal(t, \"mytopic\\\\_\", topicPatterns[2])\n\trequire.Equal(t, \"up%\", topicPatterns[3])\n\n\t// Check that ACL works as excepted\n\trequire.Nil(t, a.Authorize(nil, \"down_123\", PermissionRead))\n\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"downX123\", PermissionRead))\n\n\trequire.Nil(t, a.Authorize(nil, \"left_abc\", PermissionRead))\n\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"leftX123\", PermissionRead))\n\n\trequire.Nil(t, a.Authorize(nil, \"mytopic_\", PermissionRead))\n\trequire.Equal(t, ErrUnauthorized, a.Authorize(nil, \"mytopicX\", PermissionRead))\n\n\trequire.Nil(t, a.Authorize(nil, \"up123\", PermissionRead))\n\trequire.Nil(t, a.Authorize(nil, \"up\", PermissionRead)) // % matches 0 or more characters\n}\n\nfunc checkSchemaVersion(t *testing.T, d *db.DB) {\n\trows, err := d.Query(`SELECT version FROM schemaVersion`)\n\trequire.Nil(t, err)\n\trequire.True(t, rows.Next())\n\n\tvar schemaVersion int\n\trequire.Nil(t, rows.Scan(&schemaVersion))\n\trequire.Equal(t, sqliteCurrentSchemaVersion, schemaVersion)\n\trequire.Nil(t, rows.Close())\n}\n\nfunc newTestManager(t *testing.T, newManager newManagerFunc, defaultAccess Permission) *Manager {\n\ta := newManager(&Config{\n\t\tDefaultAccess:       defaultAccess,\n\t\tBcryptCost:          bcrypt.MinCost,\n\t\tQueueWriterInterval: DefaultUserStatsQueueWriterInterval,\n\t})\n\tt.Cleanup(func() { a.Close() })\n\treturn a\n}\n\nfunc newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {\n\ta, err := NewSQLiteManager(filename, startupQueries, &Config{\n\t\tDefaultAccess:       defaultAccess,\n\t\tBcryptCost:          bcryptCost,\n\t\tQueueWriterInterval: statsWriterInterval,\n\t})\n\trequire.Nil(t, err)\n\treturn a\n}\n\nfunc newTestManagerFromConfig(t *testing.T, newManager newManagerFunc, conf *Config) *Manager {\n\ta := newManager(conf)\n\tt.Cleanup(func() { a.Close() })\n\treturn a\n}\n\nfunc testDB(a *Manager) *db.DB {\n\treturn a.db\n}\n\nfunc forEachStoreBackend(t *testing.T, f func(t *testing.T, manager *Manager)) {\n\tt.Run(\"sqlite\", func(t *testing.T) {\n\t\tmanager, err := NewSQLiteManager(filepath.Join(t.TempDir(), \"user.db\"), \"\", &Config{})\n\t\trequire.Nil(t, err)\n\t\tt.Cleanup(func() { manager.Close() })\n\t\tf(t, manager)\n\t})\n\tt.Run(\"postgres\", func(t *testing.T) {\n\t\ttestDB := dbtest.CreateTestPostgres(t)\n\t\tmanager, err := NewPostgresManager(testDB, &Config{})\n\t\trequire.Nil(t, err)\n\t\tf(t, manager)\n\t})\n}\n\nfunc TestStoreAddUser(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", u.Name)\n\t\trequire.Equal(t, RoleUser, u.Role)\n\t\trequire.False(t, u.Provisioned)\n\t\trequire.NotEmpty(t, u.ID)\n\t\trequire.NotEmpty(t, u.SyncTopic)\n\t})\n}\n\nfunc TestStoreAddUserAlreadyExists(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"pass1\", RoleUser, false))\n\t\trequire.Equal(t, ErrUserExists, manager.AddUser(\"phil\", \"pass2\", RoleUser, false))\n\t})\n}\n\nfunc TestStoreRemoveUser(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", u.Name)\n\n\t\trequire.Nil(t, manager.RemoveUser(\"phil\"))\n\t\t_, err = manager.User(\"phil\")\n\t\trequire.Equal(t, ErrUserNotFound, err)\n\t})\n}\n\nfunc TestStoreUserByID(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleAdmin, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tu2, err := manager.UserByID(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, u.Name, u2.Name)\n\t\trequire.Equal(t, u.ID, u2.ID)\n\t})\n}\n\nfunc TestStoreUserByToken(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\ttk, err := manager.CreateToken(u.ID, \"test token\", time.Now().Add(24*time.Hour), netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, tk.Value)\n\n\t\tu2, err := manager.userByToken(tk.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", u2.Name)\n\t})\n}\n\nfunc TestStoreUserByStripeCustomer(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.ChangeBilling(\"phil\", &Billing{\n\t\t\tStripeCustomerID:     \"cus_test123\",\n\t\t\tStripeSubscriptionID: \"sub_test123\",\n\t\t}))\n\n\t\tu, err := manager.UserByStripeCustomer(\"cus_test123\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"phil\", u.Name)\n\t\trequire.Equal(t, \"cus_test123\", u.Billing.StripeCustomerID)\n\t})\n}\n\nfunc TestStoreUsers(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddUser(\"ben\", \"benpass\", RoleAdmin, false))\n\n\t\tusers, err := manager.Users()\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, len(users) >= 3) // phil, ben, and the everyone user\n\t})\n}\n\nfunc TestStoreUsersCount(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\tcount, err := manager.UsersCount()\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, count >= 1) // At least the everyone user\n\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tcount2, err := manager.UsersCount()\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, count+1, count2)\n\t})\n}\n\nfunc TestStoreChangePassword(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, u.Hash)\n\n\t\trequire.Nil(t, manager.ChangePassword(\"phil\", \"newpass\", false))\n\t\tu, err = manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, u.Hash)\n\t})\n}\n\nfunc TestStoreChangeRole(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, RoleUser, u.Role)\n\n\t\trequire.Nil(t, manager.ChangeRole(\"phil\", RoleAdmin))\n\t\tu, err = manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, RoleAdmin, u.Role)\n\t})\n}\n\nfunc TestStoreTokens(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\texpires := time.Now().Add(24 * time.Hour)\n\t\torigin := netip.MustParseAddr(\"9.9.9.9\")\n\n\t\ttk, err := manager.CreateToken(u.ID, \"my token\", expires, origin, false)\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, tk.Value)\n\t\trequire.Equal(t, \"my token\", tk.Label)\n\n\t\t// Get single token\n\t\ttk2, err := manager.Token(u.ID, tk.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, tk.Value, tk2.Value)\n\t\trequire.Equal(t, \"my token\", tk2.Label)\n\n\t\t// Get all tokens\n\t\ttokens, err := manager.Tokens(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, tokens, 1)\n\t\trequire.Equal(t, tk.Value, tokens[0].Value)\n\t})\n}\n\nfunc TestStoreTokenChange(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\texpires := time.Now().Add(time.Hour)\n\t\ttk, err := manager.CreateToken(u.ID, \"old label\", expires, netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\n\t\tnewLabel := \"new label\"\n\t\tnewExpires := time.Now().Add(2 * time.Hour)\n\t\ttk2, err := manager.ChangeToken(u.ID, tk.Value, &newLabel, &newExpires)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"new label\", tk2.Label)\n\t\trequire.Equal(t, newExpires.Unix(), tk2.Expires.Unix())\n\t})\n}\n\nfunc TestStoreTokenRemove(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\ttk, err := manager.CreateToken(u.ID, \"label\", time.Now().Add(time.Hour), netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.RemoveToken(u.ID, tk.Value))\n\t\t_, err = manager.Token(u.ID, tk.Value)\n\t\trequire.Equal(t, ErrTokenNotFound, err)\n\t})\n}\n\nfunc TestStoreTokenRemoveExpired(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\t// Create expired token and active token\n\t\ttkExpired, err := manager.CreateToken(u.ID, \"expired\", time.Now().Add(-time.Hour), netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\t\ttkActive, err := manager.CreateToken(u.ID, \"active\", time.Now().Add(time.Hour), netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.RemoveExpiredTokens())\n\n\t\t// Expired token should be gone\n\t\t_, err = manager.Token(u.ID, tkExpired.Value)\n\t\trequire.Equal(t, ErrTokenNotFound, err)\n\n\t\t// Active token should still exist\n\t\ttk, err := manager.Token(u.ID, tkActive.Value)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, tkActive.Value, tk.Value)\n\t})\n}\n\nfunc TestStoreTokenUpdateLastAccess(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\ttk, err := manager.CreateToken(u.ID, \"label\", time.Now().Add(time.Hour), netip.MustParseAddr(\"1.2.3.4\"), false)\n\t\trequire.Nil(t, err)\n\n\t\tnewTime := time.Now().Add(5 * time.Minute)\n\t\tnewOrigin := netip.MustParseAddr(\"5.5.5.5\")\n\t\tmanager.EnqueueTokenUpdate(tk.Value, &TokenUpdate{LastAccess: newTime, LastOrigin: newOrigin})\n\t})\n}\n\nfunc TestStoreAllowAccess(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"mytopic\", PermissionReadWrite))\n\t\tgrants, err := manager.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, grants, 1)\n\t\trequire.Equal(t, \"mytopic\", grants[0].TopicPattern)\n\t\trequire.True(t, grants[0].Permission.IsReadWrite())\n\t})\n}\n\nfunc TestStoreAllowAccessReadOnly(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"announcements\", PermissionRead))\n\t\tgrants, err := manager.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, grants, 1)\n\t\trequire.True(t, grants[0].Permission.IsRead())\n\t\trequire.False(t, grants[0].Permission.IsWrite())\n\t})\n}\n\nfunc TestStoreResetAccess(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"topic1\", PermissionReadWrite))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"topic2\", PermissionRead))\n\n\t\tgrants, err := manager.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, grants, 2)\n\n\t\trequire.Nil(t, manager.ResetAccess(\"phil\", \"topic1\"))\n\t\tgrants, err = manager.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, grants, 1)\n\t\trequire.Equal(t, \"topic2\", grants[0].TopicPattern)\n\t})\n}\n\nfunc TestStoreResetAccessAll(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"topic1\", PermissionReadWrite))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"topic2\", PermissionRead))\n\n\t\trequire.Nil(t, manager.ResetAccess(\"phil\", \"\"))\n\t\tgrants, err := manager.Grants(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, grants, 0)\n\t})\n}\n\nfunc TestStoreAuthorizeTopicAccess(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"mytopic\", PermissionReadWrite))\n\n\t\tread, write, found, err := manager.authorizeTopicAccess(\"phil\", \"mytopic\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, found)\n\t\trequire.True(t, read)\n\t\trequire.True(t, write)\n\t})\n}\n\nfunc TestStoreAuthorizeTopicAccessNotFound(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\n\t\t_, _, found, err := manager.authorizeTopicAccess(\"phil\", \"other\")\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, found)\n\t})\n}\n\nfunc TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"secret\", PermissionDenyAll))\n\n\t\tread, write, found, err := manager.authorizeTopicAccess(\"phil\", \"secret\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, found)\n\t\trequire.False(t, read)\n\t\trequire.False(t, write)\n\t})\n}\n\nfunc TestStoreReservations(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"mytopic\", PermissionRead, 0))\n\n\t\treservations, err := manager.Reservations(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, reservations, 1)\n\t\trequire.Equal(t, \"mytopic\", reservations[0].Topic)\n\t\trequire.True(t, reservations[0].Owner.IsReadWrite())\n\t\trequire.True(t, reservations[0].Everyone.IsRead())\n\t\trequire.False(t, reservations[0].Everyone.IsWrite())\n\t})\n}\n\nfunc TestStoreReservationsCount(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"topic1\", PermissionReadWrite, 0))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"topic2\", PermissionReadWrite, 0))\n\n\t\tcount, err := manager.ReservationsCount(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(2), count)\n\t})\n}\n\nfunc TestStoreHasReservation(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"mytopic\", PermissionReadWrite, 0))\n\n\t\thas, err := manager.HasReservation(\"phil\", \"mytopic\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, has)\n\n\t\thas, err = manager.HasReservation(\"phil\", \"other\")\n\t\trequire.Nil(t, err)\n\t\trequire.False(t, has)\n\t})\n}\n\nfunc TestStoreReservationOwner(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"mytopic\", PermissionReadWrite, 0))\n\n\t\towner, err := manager.ReservationOwner(\"mytopic\")\n\t\trequire.Nil(t, err)\n\t\trequire.NotEmpty(t, owner) // Returns the user ID\n\n\t\towner, err = manager.ReservationOwner(\"unowned\")\n\t\trequire.Nil(t, err)\n\t\trequire.Empty(t, owner)\n\t})\n}\n\nfunc TestStoreAddReservationWithLimit(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\n\t\t// Adding reservations within limit succeeds\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"topic1\", PermissionReadWrite, 2))\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"topic2\", PermissionRead, 2))\n\n\t\t// Adding a third reservation exceeds the limit\n\t\trequire.Equal(t, ErrTooManyReservations, manager.AddReservation(\"phil\", \"topic3\", PermissionRead, 2))\n\n\t\t// Updating an existing reservation within the limit succeeds\n\t\trequire.Nil(t, manager.AddReservation(\"phil\", \"topic1\", PermissionRead, 2))\n\n\t\treservations, err := manager.Reservations(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, reservations, 2)\n\t})\n}\n\nfunc TestStoreTiers(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\ttier := &Tier{\n\t\t\tID:                       \"ti_test\",\n\t\t\tCode:                     \"pro\",\n\t\t\tName:                     \"Pro\",\n\t\t\tMessageLimit:             5000,\n\t\t\tMessageExpiryDuration:    24 * time.Hour,\n\t\t\tEmailLimit:               100,\n\t\t\tCallLimit:                10,\n\t\t\tReservationLimit:         20,\n\t\t\tAttachmentFileSizeLimit:  10 * 1024 * 1024,\n\t\t\tAttachmentTotalSizeLimit: 100 * 1024 * 1024,\n\t\t\tAttachmentExpiryDuration: 48 * time.Hour,\n\t\t\tAttachmentBandwidthLimit: 500 * 1024 * 1024,\n\t\t}\n\t\trequire.Nil(t, manager.AddTier(tier))\n\n\t\t// Get by code\n\t\tt2, err := manager.Tier(\"pro\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"ti_test\", t2.ID)\n\t\trequire.Equal(t, \"pro\", t2.Code)\n\t\trequire.Equal(t, \"Pro\", t2.Name)\n\t\trequire.Equal(t, int64(5000), t2.MessageLimit)\n\t\trequire.Equal(t, int64(100), t2.EmailLimit)\n\t\trequire.Equal(t, int64(10), t2.CallLimit)\n\t\trequire.Equal(t, int64(20), t2.ReservationLimit)\n\n\t\t// List all tiers\n\t\ttiers, err := manager.Tiers()\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, tiers, 1)\n\t\trequire.Equal(t, \"pro\", tiers[0].Code)\n\t})\n}\n\nfunc TestStoreTierUpdate(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\ttier := &Tier{\n\t\t\tID:   \"ti_test\",\n\t\t\tCode: \"pro\",\n\t\t\tName: \"Pro\",\n\t\t}\n\t\trequire.Nil(t, manager.AddTier(tier))\n\n\t\ttier.Name = \"Professional\"\n\t\ttier.MessageLimit = 9999\n\t\trequire.Nil(t, manager.UpdateTier(tier))\n\n\t\tt2, err := manager.Tier(\"pro\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"Professional\", t2.Name)\n\t\trequire.Equal(t, int64(9999), t2.MessageLimit)\n\t})\n}\n\nfunc TestStoreTierRemove(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\ttier := &Tier{\n\t\t\tID:   \"ti_test\",\n\t\t\tCode: \"pro\",\n\t\t\tName: \"Pro\",\n\t\t}\n\t\trequire.Nil(t, manager.AddTier(tier))\n\n\t\tt2, err := manager.Tier(\"pro\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"pro\", t2.Code)\n\n\t\trequire.Nil(t, manager.RemoveTier(\"pro\"))\n\t\t_, err = manager.Tier(\"pro\")\n\t\trequire.Equal(t, ErrTierNotFound, err)\n\t})\n}\n\nfunc TestStoreTierByStripePrice(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\ttier := &Tier{\n\t\t\tID:                   \"ti_test\",\n\t\t\tCode:                 \"pro\",\n\t\t\tName:                 \"Pro\",\n\t\t\tStripeMonthlyPriceID: \"price_monthly\",\n\t\t\tStripeYearlyPriceID:  \"price_yearly\",\n\t\t}\n\t\trequire.Nil(t, manager.AddTier(tier))\n\n\t\tt2, err := manager.TierByStripePrice(\"price_monthly\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"pro\", t2.Code)\n\n\t\tt3, err := manager.TierByStripePrice(\"price_yearly\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"pro\", t3.Code)\n\t})\n}\n\nfunc TestStoreChangeTier(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\ttier := &Tier{\n\t\t\tID:   \"ti_test\",\n\t\t\tCode: \"pro\",\n\t\t\tName: \"Pro\",\n\t\t}\n\t\trequire.Nil(t, manager.AddTier(tier))\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.ChangeTier(\"phil\", \"pro\"))\n\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.NotNil(t, u.Tier)\n\t\trequire.Equal(t, \"pro\", u.Tier.Code)\n\t})\n}\n\nfunc TestStorePhoneNumbers(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.AddPhoneNumber(u.ID, \"+1234567890\"))\n\t\trequire.Nil(t, manager.AddPhoneNumber(u.ID, \"+0987654321\"))\n\n\t\tnumbers, err := manager.PhoneNumbers(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, numbers, 2)\n\n\t\trequire.Nil(t, manager.RemovePhoneNumber(u.ID, \"+1234567890\"))\n\t\tnumbers, err = manager.PhoneNumbers(u.ID)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, numbers, 1)\n\t\trequire.Equal(t, \"+0987654321\", numbers[0])\n\t})\n}\n\nfunc TestStoreChangeSettings(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tlang := \"de\"\n\t\tprefs := &Prefs{Language: &lang}\n\t\trequire.Nil(t, manager.ChangeSettings(u.ID, prefs))\n\n\t\tu2, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.NotNil(t, u2.Prefs)\n\t\trequire.Equal(t, \"de\", *u2.Prefs.Language)\n\t})\n}\n\nfunc TestStoreChangeBilling(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\n\t\tbilling := &Billing{\n\t\t\tStripeCustomerID:     \"cus_123\",\n\t\t\tStripeSubscriptionID: \"sub_456\",\n\t\t}\n\t\trequire.Nil(t, manager.ChangeBilling(\"phil\", billing))\n\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, \"cus_123\", u.Billing.StripeCustomerID)\n\t\trequire.Equal(t, \"sub_456\", u.Billing.StripeSubscriptionID)\n\t})\n}\n\nfunc TestStoreUpdateStats(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tmanager.EnqueueUserStats(u.ID, &Stats{Messages: 42, Emails: 3, Calls: 1})\n\t\trequire.Nil(t, manager.writeUserStatsQueue())\n\n\t\tu2, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(42), u2.Stats.Messages)\n\t\trequire.Equal(t, int64(3), u2.Stats.Emails)\n\t\trequire.Equal(t, int64(1), u2.Stats.Calls)\n\t})\n}\n\nfunc TestStoreResetStats(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\tmanager.EnqueueUserStats(u.ID, &Stats{Messages: 42, Emails: 3, Calls: 1})\n\t\trequire.Nil(t, manager.writeUserStatsQueue())\n\t\trequire.Nil(t, manager.ResetStats())\n\n\t\tu2, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, int64(0), u2.Stats.Messages)\n\t\trequire.Equal(t, int64(0), u2.Stats.Emails)\n\t\trequire.Equal(t, int64(0), u2.Stats.Calls)\n\t})\n}\n\nfunc TestStoreMarkUserRemoved(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.MarkUserRemoved(u))\n\n\t\tu2, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, u2.Deleted)\n\t})\n}\n\nfunc TestStoreRemoveDeletedUsers(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\tu, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.MarkUserRemoved(u))\n\n\t\t// RemoveDeletedUsers only removes users past the hard-delete duration (7 days).\n\t\t// Immediately after marking, the user should still exist.\n\t\trequire.Nil(t, manager.RemoveDeletedUsers())\n\t\tu2, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\trequire.True(t, u2.Deleted)\n\t})\n}\n\nfunc TestStoreAllGrants(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddUser(\"ben\", \"benpass\", RoleUser, false))\n\t\tphil, err := manager.User(\"phil\")\n\t\trequire.Nil(t, err)\n\t\tben, err := manager.User(\"ben\")\n\t\trequire.Nil(t, err)\n\n\t\trequire.Nil(t, manager.AllowAccess(\"phil\", \"topic1\", PermissionReadWrite))\n\t\trequire.Nil(t, manager.AllowAccess(\"ben\", \"topic2\", PermissionRead))\n\n\t\tgrants, err := manager.AllGrants()\n\t\trequire.Nil(t, err)\n\t\trequire.Contains(t, grants, phil.ID)\n\t\trequire.Contains(t, grants, ben.ID)\n\t})\n}\n\nfunc TestStoreOtherAccessCount(t *testing.T) {\n\tforEachStoreBackend(t, func(t *testing.T, manager *Manager) {\n\t\trequire.Nil(t, manager.AddUser(\"phil\", \"mypass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddUser(\"ben\", \"benpass\", RoleUser, false))\n\t\trequire.Nil(t, manager.AddReservation(\"ben\", \"mytopic\", PermissionReadWrite, 0))\n\n\t\tcount, err := manager.otherAccessCount(\"phil\", \"mytopic\")\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, 2, count) // ben's owner entry + everyone entry\n\t})\n}\n"
  },
  {
    "path": "user/types.go",
    "content": "package user\n\nimport (\n\t\"errors\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/log\"\n\t\"heckel.io/ntfy/v2/payments\"\n)\n\n// User is a struct that represents a user\ntype User struct {\n\tID          string\n\tName        string\n\tHash        string // Password hash (bcrypt)\n\tToken       string // Only set if token was used to log in\n\tRole        Role\n\tPrefs       *Prefs\n\tTier        *Tier\n\tStats       *Stats\n\tBilling     *Billing\n\tSyncTopic   string\n\tProvisioned bool // Whether the user was provisioned by the config file\n\tDeleted     bool // Whether the user was soft-deleted\n}\n\n// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,\n// or if the user itself is nil.\nfunc (u *User) TierID() string {\n\tif u == nil || u.Tier == nil {\n\t\treturn \"\"\n\t}\n\treturn u.Tier.ID\n}\n\n// IsAdmin returns true if the user is an admin\nfunc (u *User) IsAdmin() bool {\n\treturn u != nil && u.Role == RoleAdmin\n}\n\n// IsUser returns true if the user is a regular user, not an admin\nfunc (u *User) IsUser() bool {\n\treturn u != nil && u.Role == RoleUser\n}\n\n// Auther is an interface for authentication and authorization\ntype Auther interface {\n\t// Authenticate checks username and password and returns a user if correct. The method\n\t// returns in constant-ish time, regardless of whether the user exists or the password is\n\t// correct or incorrect.\n\tAuthenticate(username, password string) (*User, error)\n\n\t// Authorize returns nil if the given user has access to the given topic using the desired\n\t// permission. The user param may be nil to signal an anonymous user.\n\tAuthorize(user *User, topic string, perm Permission) error\n}\n\n// Token represents a user token, including expiry date\ntype Token struct {\n\tValue       string\n\tLabel       string\n\tLastAccess  time.Time\n\tLastOrigin  netip.Addr\n\tExpires     time.Time\n\tProvisioned bool\n}\n\n// TokenUpdate holds information about the last access time and origin IP address of a token\ntype TokenUpdate struct {\n\tLastAccess time.Time\n\tLastOrigin netip.Addr\n}\n\n// Prefs represents a user's configuration settings\ntype Prefs struct {\n\tLanguage      *string            `json:\"language,omitempty\"`\n\tNotification  *NotificationPrefs `json:\"notification,omitempty\"`\n\tSubscriptions []*Subscription    `json:\"subscriptions,omitempty\"`\n}\n\n// Tier represents a user's account type, including its account limits\ntype Tier struct {\n\tID                       string        // Tier identifier (ti_...)\n\tCode                     string        // Code of the tier\n\tName                     string        // Name of the tier\n\tMessageLimit             int64         // Daily message limit\n\tMessageExpiryDuration    time.Duration // Cache duration for messages\n\tEmailLimit               int64         // Daily email limit\n\tCallLimit                int64         // Daily phone call limit\n\tReservationLimit         int64         // Number of topic reservations allowed by user\n\tAttachmentFileSizeLimit  int64         // Max file size per file (bytes)\n\tAttachmentTotalSizeLimit int64         // Total file size for all files of this user (bytes)\n\tAttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted\n\tAttachmentBandwidthLimit int64         // Daily bandwidth limit for the user\n\tStripeMonthlyPriceID     string        // Monthly price ID for paid tiers (price_...)\n\tStripeYearlyPriceID      string        // Yearly price ID for paid tiers (price_...)\n}\n\n// Context returns fields for the log\nfunc (t *Tier) Context() log.Context {\n\treturn log.Context{\n\t\t\"tier_id\":                 t.ID,\n\t\t\"tier_code\":               t.Code,\n\t\t\"stripe_monthly_price_id\": t.StripeMonthlyPriceID,\n\t\t\"stripe_yearly_price_id\":  t.StripeYearlyPriceID,\n\t}\n}\n\n// Subscription represents a user's topic subscription\ntype Subscription struct {\n\tBaseURL     string  `json:\"base_url\"`\n\tTopic       string  `json:\"topic\"`\n\tDisplayName *string `json:\"display_name\"`\n}\n\n// Context returns fields for the log\nfunc (s *Subscription) Context() log.Context {\n\treturn log.Context{\n\t\t\"base_url\": s.BaseURL,\n\t\t\"topic\":    s.Topic,\n\t}\n}\n\n// NotificationPrefs represents the user's notification settings\ntype NotificationPrefs struct {\n\tSound       *string `json:\"sound,omitempty\"`\n\tMinPriority *int    `json:\"min_priority,omitempty\"`\n\tDeleteAfter *int    `json:\"delete_after,omitempty\"`\n}\n\n// Stats is a struct holding daily user statistics\ntype Stats struct {\n\tMessages int64\n\tEmails   int64\n\tCalls    int64\n}\n\n// Billing is a struct holding a user's billing information\ntype Billing struct {\n\tStripeCustomerID            string\n\tStripeSubscriptionID        string\n\tStripeSubscriptionStatus    payments.SubscriptionStatus\n\tStripeSubscriptionInterval  payments.PriceRecurringInterval\n\tStripeSubscriptionPaidUntil time.Time\n\tStripeSubscriptionCancelAt  time.Time\n}\n\n// Grant is a struct that represents an access control entry to a topic by a user\ntype Grant struct {\n\tTopicPattern string // May include wildcard (*)\n\tPermission   Permission\n\tProvisioned  bool // Whether the grant was provisioned by the config file\n}\n\n// Reservation is a struct that represents the ownership over a topic by a user\ntype Reservation struct {\n\tTopic    string\n\tOwner    Permission\n\tEveryone Permission\n}\n\n// Permission represents a read or write permission to a topic\ntype Permission uint8\n\n// Permissions to a topic\nconst (\n\tPermissionDenyAll Permission = iota\n\tPermissionRead\n\tPermissionWrite\n\tPermissionReadWrite // 3!\n)\n\n// NewPermission is a helper to create a Permission based on read/write bool values\nfunc NewPermission(read, write bool) Permission {\n\tp := uint8(0)\n\tif read {\n\t\tp |= uint8(PermissionRead)\n\t}\n\tif write {\n\t\tp |= uint8(PermissionWrite)\n\t}\n\treturn Permission(p)\n}\n\n// ParsePermission parses the string representation and returns a Permission\nfunc ParsePermission(s string) (Permission, error) {\n\tswitch strings.ToLower(s) {\n\tcase \"read-write\", \"rw\":\n\t\treturn NewPermission(true, true), nil\n\tcase \"read-only\", \"read\", \"ro\":\n\t\treturn NewPermission(true, false), nil\n\tcase \"write-only\", \"write\", \"wo\":\n\t\treturn NewPermission(false, true), nil\n\tcase \"deny-all\", \"deny\", \"none\":\n\t\treturn NewPermission(false, false), nil\n\tdefault:\n\t\treturn NewPermission(false, false), errors.New(\"invalid permission\")\n\t}\n}\n\n// IsRead returns true if readable\nfunc (p Permission) IsRead() bool {\n\treturn p&PermissionRead != 0\n}\n\n// IsWrite returns true if writable\nfunc (p Permission) IsWrite() bool {\n\treturn p&PermissionWrite != 0\n}\n\n// IsReadWrite returns true if readable and writable\nfunc (p Permission) IsReadWrite() bool {\n\treturn p.IsRead() && p.IsWrite()\n}\n\n// String returns a string representation of the permission\nfunc (p Permission) String() string {\n\tif p.IsReadWrite() {\n\t\treturn \"read-write\"\n\t} else if p.IsRead() {\n\t\treturn \"read-only\"\n\t} else if p.IsWrite() {\n\t\treturn \"write-only\"\n\t}\n\treturn \"deny-all\"\n}\n\n// Role represents a user's role, either admin or regular user\ntype Role string\n\n// User roles\nconst (\n\tRoleAdmin     = Role(\"admin\") // Some queries have these values hardcoded!\n\tRoleUser      = Role(\"user\")\n\tRoleAnonymous = Role(\"anonymous\")\n)\n\n// Everyone is a special username representing anonymous users\nconst (\n\tEveryone   = \"*\"\n\teveryoneID = \"u_everyone\"\n)\n\n// Config holds the configuration for the user Manager\ntype Config struct {\n\tFilename            string              // Database filename, e.g. \"/var/lib/ntfy/user.db\" (SQLite)\n\tDatabaseURL         string              // Database connection string (PostgreSQL)\n\tStartupQueries      string              // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)\n\tDefaultAccess       Permission          // Default permission if no ACL matches\n\tProvisionEnabled    bool                // Hack: Enable auto-provisioning of users and access grants, disabled for \"ntfy user\" commands\n\tUsers               []*User             // Predefined users to create on startup\n\tAccess              map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)\n\tTokens              map[string][]*Token // Predefined users to create on startup (username -> []*Token)\n\tQueueWriterInterval time.Duration       // Interval for the async queue writer to flush stats and token updates to the database\n\tBcryptCost          int                 // Cost of generated passwords; lowering makes testing faster\n}\n\n// Error constants used by the package\nvar (\n\tErrUnauthenticated        = errors.New(\"unauthenticated\")\n\tErrUnauthorized           = errors.New(\"unauthorized\")\n\tErrInvalidArgument        = errors.New(\"invalid argument\")\n\tErrUserNotFound           = errors.New(\"user not found\")\n\tErrUserExists             = errors.New(\"user already exists\")\n\tErrPasswordHashInvalid    = errors.New(\"password hash must be a bcrypt hash, use 'ntfy user hash' to generate\")\n\tErrPasswordHashWeak       = errors.New(\"password hash too weak, use 'ntfy user hash' to generate\")\n\tErrTierNotFound           = errors.New(\"tier not found\")\n\tErrTokenNotFound          = errors.New(\"token not found\")\n\tErrPhoneNumberNotFound    = errors.New(\"phone number not found\")\n\tErrTooManyReservations    = errors.New(\"new tier has lower reservation limit\")\n\tErrPhoneNumberExists      = errors.New(\"phone number already exists\")\n\tErrProvisionedUserChange  = errors.New(\"cannot change or delete provisioned user\")\n\tErrProvisionedTokenChange = errors.New(\"cannot change or delete provisioned token\")\n)\n\n// queries holds the database-specific SQL queries\ntype queries struct {\n\t// User queries\n\tselectUserByID               string\n\tselectUserByName             string\n\tselectUserByToken            string\n\tselectUserByStripeCustomerID string\n\tselectUsernames              string\n\tselectUsers                  string\n\tselectUserCount              string\n\tselectUserIDFromUsername     string\n\tinsertUser                   string\n\tupdateUserPass               string\n\tupdateUserRole               string\n\tupdateUserProvisioned        string\n\tupdateUserPrefs              string\n\tupdateUserStats              string\n\tupdateUserStatsResetAll      string\n\tupdateUserTier               string\n\tupdateUserDeleted            string\n\tdeleteUser                   string\n\tdeleteUserTier               string\n\tdeleteUsersMarked            string\n\tdeleteUsersProvisioned       string\n\n\t// Access queries\n\tselectTopicPerms            string\n\tselectUserAllAccess         string\n\tselectUserAccess            string\n\tselectUserReservations      string\n\tselectUserReservationsCount string\n\tselectUserReservationsOwner string\n\tselectUserHasReservation    string\n\tselectOtherAccessCount      string\n\tupsertUserAccess            string\n\tdeleteUserAccess            string\n\tdeleteUserAccessProvisioned string\n\tdeleteTopicAccess           string\n\tdeleteAllAccess             string\n\n\t// Token queries\n\tselectToken                string\n\tselectTokens               string\n\tselectTokenCount           string\n\tselectAllProvisionedTokens string\n\tupsertToken                string\n\tupdateToken                string\n\tupdateTokenLastAccess      string\n\tdeleteToken                string\n\tdeleteProvisionedToken     string\n\tdeleteAllProvisionedTokens string\n\tdeleteAllToken             string\n\tdeleteExpiredTokens        string\n\tdeleteExcessTokens         string\n\n\t// Tier queries\n\tinsertTier          string\n\tselectTiers         string\n\tselectTierByCode    string\n\tselectTierByPriceID string\n\tupdateTier          string\n\tdeleteTier          string\n\n\t// Phone queries\n\tselectPhoneNumbers string\n\tinsertPhoneNumber  string\n\tdeletePhoneNumber  string\n\n\t// Billing queries\n\tupdateBilling string\n}\n"
  },
  {
    "path": "user/types_test.go",
    "content": "package user\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"testing\"\n)\n\nfunc TestPermission(t *testing.T) {\n\trequire.Equal(t, PermissionReadWrite, NewPermission(true, true))\n\trequire.Equal(t, PermissionRead, NewPermission(true, false))\n\trequire.Equal(t, PermissionWrite, NewPermission(false, true))\n\trequire.Equal(t, PermissionDenyAll, NewPermission(false, false))\n\trequire.True(t, PermissionReadWrite.IsReadWrite())\n\trequire.True(t, PermissionReadWrite.IsRead())\n\trequire.True(t, PermissionReadWrite.IsWrite())\n\trequire.True(t, PermissionRead.IsRead())\n\trequire.True(t, PermissionWrite.IsWrite())\n}\n\nfunc TestParsePermission(t *testing.T) {\n\t_, err := ParsePermission(\"no\")\n\trequire.NotNil(t, err)\n\n\tp, err := ParsePermission(\"read-write\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, PermissionReadWrite, p)\n\n\tp, err = ParsePermission(\"rw\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, PermissionReadWrite, p)\n\n\tp, err = ParsePermission(\"read-only\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, PermissionRead, p)\n\n\tp, err = ParsePermission(\"WRITE\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, PermissionWrite, p)\n\n\tp, err = ParsePermission(\"deny-all\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, PermissionDenyAll, p)\n}\n\nfunc TestAllowedTier(t *testing.T) {\n\trequire.False(t, AllowedTier(\"  no\"))\n\trequire.True(t, AllowedTier(\"yes\"))\n}\n\nfunc TestTierContext(t *testing.T) {\n\ttier := &Tier{\n\t\tID:                   \"ti_abc\",\n\t\tCode:                 \"pro\",\n\t\tStripeMonthlyPriceID: \"price_123\",\n\t\tStripeYearlyPriceID:  \"price_456\",\n\t}\n\tcontext := tier.Context()\n\trequire.Equal(t, \"ti_abc\", context[\"tier_id\"])\n\trequire.Equal(t, \"pro\", context[\"tier_code\"])\n\trequire.Equal(t, \"price_123\", context[\"stripe_monthly_price_id\"])\n\trequire.Equal(t, \"price_456\", context[\"stripe_yearly_price_id\"])\n\n}\n\nfunc TestUsernameRegex(t *testing.T) {\n\tusername := \"phil\"\n\tusernameEmail := \"phil@ntfy.sh\"\n\tusernameEmailAlias := \"phil+alias@ntfy.sh\"\n\tusernameInvalid := \"phil\\rocks\"\n\n\trequire.True(t, AllowedUsername(username))\n\trequire.True(t, AllowedUsername(usernameEmail))\n\trequire.True(t, AllowedUsername(usernameEmailAlias))\n\trequire.False(t, AllowedUsername(usernameInvalid))\n}\n"
  },
  {
    "path": "user/util.go",
    "content": "package user\n\nimport (\n\t\"database/sql\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nvar (\n\tallowedUsernameRegex     = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`)    // Does not include Everyone (*)\n\tallowedTopicRegex        = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)  // No '*'\n\tallowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!\n\tallowedTierRegex         = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)\n\tallowedTokenRegex        = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix)\n)\n\n// AllowedRole returns true if the given role can be used for new users\nfunc AllowedRole(role Role) bool {\n\treturn role == RoleUser || role == RoleAdmin\n}\n\n// AllowedUsername returns true if the given username is valid\nfunc AllowedUsername(username string) bool {\n\treturn allowedUsernameRegex.MatchString(username)\n}\n\n// AllowedTopic returns true if the given topic name is valid\nfunc AllowedTopic(topic string) bool {\n\treturn allowedTopicRegex.MatchString(topic)\n}\n\n// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)\nfunc AllowedTopicPattern(topic string) bool {\n\treturn allowedTopicPatternRegex.MatchString(topic)\n}\n\n// AllowedTier returns true if the given tier name is valid\nfunc AllowedTier(tier string) bool {\n\treturn allowedTierRegex.MatchString(tier)\n}\n\n// ValidPasswordHash checks if the given password hash is a valid bcrypt hash\nfunc ValidPasswordHash(hash string, minCost int) error {\n\tif !strings.HasPrefix(hash, \"$2a$\") && !strings.HasPrefix(hash, \"$2b$\") && !strings.HasPrefix(hash, \"$2y$\") {\n\t\treturn ErrPasswordHashInvalid\n\t}\n\tcost, err := bcrypt.Cost([]byte(hash))\n\tif err != nil { // Check if the hash is valid (length, format, etc.)\n\t\treturn err\n\t} else if cost < minCost {\n\t\treturn ErrPasswordHashWeak\n\t}\n\treturn nil\n}\n\n// ValidToken returns true if the given token matches the naming convention\nfunc ValidToken(token string) bool {\n\treturn allowedTokenRegex.MatchString(token)\n}\n\n// GenerateToken generates a new token with a prefix and a fixed length\n// Lowercase only to support \"<topic>+<token>@<domain>\" email addresses\nfunc GenerateToken() string {\n\treturn util.RandomLowerStringPrefix(tokenPrefix, tokenLength)\n}\n\n// HashPassword hashes the given password using bcrypt with the configured cost\nfunc HashPassword(password string) (string, error) {\n\treturn hashPassword(password, DefaultUserPasswordBcryptCost)\n}\n\nfunc hashPassword(password string, cost int) (string, error) {\n\thash, err := bcrypt.GenerateFromPassword([]byte(password), cost)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(hash), nil\n}\n\nfunc nullString(s string) sql.NullString {\n\tif s == \"\" {\n\t\treturn sql.NullString{}\n\t}\n\treturn sql.NullString{String: s, Valid: true}\n}\n\nfunc nullInt64(v int64) sql.NullInt64 {\n\tif v == 0 {\n\t\treturn sql.NullInt64{}\n\t}\n\treturn sql.NullInt64{Int64: v, Valid: true}\n}\n\n// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,\n// and escapes '_', assuming '\\' as escape character.\nfunc toSQLWildcard(s string) string {\n\treturn escapeUnderscore(strings.ReplaceAll(s, \"*\", \"%\"))\n}\n\n// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*',\n// and removes the '\\_' escape character.\nfunc fromSQLWildcard(s string) string {\n\treturn strings.ReplaceAll(unescapeUnderscore(s), \"%\", \"*\")\n}\n\nfunc escapeUnderscore(s string) string {\n\treturn strings.ReplaceAll(s, \"_\", \"\\\\_\")\n}\n\nfunc unescapeUnderscore(s string) string {\n\treturn strings.ReplaceAll(s, \"\\\\_\", \"_\")\n}\n"
  },
  {
    "path": "user/util_test.go",
    "content": "package user\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestAllowedRole(t *testing.T) {\n\trequire.True(t, AllowedRole(RoleUser))\n\trequire.True(t, AllowedRole(RoleAdmin))\n\trequire.False(t, AllowedRole(RoleAnonymous))\n\trequire.False(t, AllowedRole(Role(\"invalid\")))\n\trequire.False(t, AllowedRole(Role(\"\")))\n\trequire.False(t, AllowedRole(Role(\"superadmin\")))\n}\n\nfunc TestAllowedTopic(t *testing.T) {\n\t// Valid topics\n\trequire.True(t, AllowedTopic(\"test\"))\n\trequire.True(t, AllowedTopic(\"mytopic\"))\n\trequire.True(t, AllowedTopic(\"topic123\"))\n\trequire.True(t, AllowedTopic(\"my-topic\"))\n\trequire.True(t, AllowedTopic(\"my_topic\"))\n\trequire.True(t, AllowedTopic(\"Topic123\"))\n\trequire.True(t, AllowedTopic(\"a\"))\n\trequire.True(t, AllowedTopic(strings.Repeat(\"a\", 64))) // Max length\n\n\t// Invalid topics - wildcards not allowed\n\trequire.False(t, AllowedTopic(\"topic*\"))\n\trequire.False(t, AllowedTopic(\"*\"))\n\trequire.False(t, AllowedTopic(\"my*topic\"))\n\n\t// Invalid topics - special characters\n\trequire.False(t, AllowedTopic(\"my topic\"))  // Space\n\trequire.False(t, AllowedTopic(\"my.topic\"))  // Dot\n\trequire.False(t, AllowedTopic(\"my/topic\"))  // Slash\n\trequire.False(t, AllowedTopic(\"my@topic\"))  // At sign\n\trequire.False(t, AllowedTopic(\"my+topic\"))  // Plus\n\trequire.False(t, AllowedTopic(\"topic!\"))    // Exclamation\n\trequire.False(t, AllowedTopic(\"topic#\"))    // Hash\n\trequire.False(t, AllowedTopic(\"topic$\"))    // Dollar\n\trequire.False(t, AllowedTopic(\"topic%\"))    // Percent\n\trequire.False(t, AllowedTopic(\"topic&\"))    // Ampersand\n\trequire.False(t, AllowedTopic(\"my\\\\topic\")) // Backslash\n\n\t// Invalid topics - length\n\trequire.False(t, AllowedTopic(\"\"))                      // Empty\n\trequire.False(t, AllowedTopic(strings.Repeat(\"a\", 65))) // Too long\n}\n\nfunc TestAllowedTopicPattern(t *testing.T) {\n\t// Valid patterns - same as AllowedTopic\n\trequire.True(t, AllowedTopicPattern(\"test\"))\n\trequire.True(t, AllowedTopicPattern(\"mytopic\"))\n\trequire.True(t, AllowedTopicPattern(\"topic123\"))\n\trequire.True(t, AllowedTopicPattern(\"my-topic\"))\n\trequire.True(t, AllowedTopicPattern(\"my_topic\"))\n\trequire.True(t, AllowedTopicPattern(\"a\"))\n\trequire.True(t, AllowedTopicPattern(strings.Repeat(\"a\", 64))) // Max length\n\n\t// Valid patterns - with wildcards\n\trequire.True(t, AllowedTopicPattern(\"*\"))\n\trequire.True(t, AllowedTopicPattern(\"topic*\"))\n\trequire.True(t, AllowedTopicPattern(\"*topic\"))\n\trequire.True(t, AllowedTopicPattern(\"my*topic\"))\n\trequire.True(t, AllowedTopicPattern(\"***\"))\n\trequire.True(t, AllowedTopicPattern(\"test_*\"))\n\trequire.True(t, AllowedTopicPattern(\"my-*-topic\"))\n\trequire.True(t, AllowedTopicPattern(strings.Repeat(\"*\", 64))) // Max length with wildcards\n\n\t// Invalid patterns - special characters (other than wildcard)\n\trequire.False(t, AllowedTopicPattern(\"my topic\"))  // Space\n\trequire.False(t, AllowedTopicPattern(\"my.topic\"))  // Dot\n\trequire.False(t, AllowedTopicPattern(\"my/topic\"))  // Slash\n\trequire.False(t, AllowedTopicPattern(\"my@topic\"))  // At sign\n\trequire.False(t, AllowedTopicPattern(\"my+topic\"))  // Plus\n\trequire.False(t, AllowedTopicPattern(\"topic!\"))    // Exclamation\n\trequire.False(t, AllowedTopicPattern(\"topic#\"))    // Hash\n\trequire.False(t, AllowedTopicPattern(\"topic$\"))    // Dollar\n\trequire.False(t, AllowedTopicPattern(\"topic%\"))    // Percent\n\trequire.False(t, AllowedTopicPattern(\"topic&\"))    // Ampersand\n\trequire.False(t, AllowedTopicPattern(\"my\\\\topic\")) // Backslash\n\n\t// Invalid patterns - length\n\trequire.False(t, AllowedTopicPattern(\"\"))                      // Empty\n\trequire.False(t, AllowedTopicPattern(strings.Repeat(\"a\", 65))) // Too long\n}\n\nfunc TestValidPasswordHash(t *testing.T) {\n\t// Valid bcrypt hashes with different versions\n\trequire.Nil(t, ValidPasswordHash(\"$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy\", 10))\n\trequire.Nil(t, ValidPasswordHash(\"$2b$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C\", 10))\n\trequire.Nil(t, ValidPasswordHash(\"$2y$12$1234567890123456789012u1234567890123456789012345678901\", 10))\n\n\t// Valid hash with minimum cost\n\trequire.Nil(t, ValidPasswordHash(\"$2a$04$1234567890123456789012u1234567890123456789012345678901\", 4))\n\n\t// Invalid - wrong prefix\n\trequire.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash(\"$2c$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy\", 10))\n\trequire.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash(\"$3a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy\", 10))\n\trequire.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash(\"bcrypt$10$hash\", 10))\n\trequire.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash(\"nothash\", 10))\n\trequire.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash(\"\", 10))\n\n\t// Invalid - malformed hash\n\trequire.NotNil(t, ValidPasswordHash(\"$2a$10$tooshort\", 10))\n\trequire.NotNil(t, ValidPasswordHash(\"$2a$10\", 10))\n\trequire.NotNil(t, ValidPasswordHash(\"$2a$\", 10))\n\n\t// Invalid - cost too low\n\trequire.Equal(t, ErrPasswordHashWeak, ValidPasswordHash(\"$2a$04$1234567890123456789012u1234567890123456789012345678901\", 10))\n\trequire.Equal(t, ErrPasswordHashWeak, ValidPasswordHash(\"$2a$09$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy\", 10))\n\n\t// Edge case - cost exactly at minimum\n\trequire.Nil(t, ValidPasswordHash(\"$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy\", 10))\n}\n\nfunc TestValidToken(t *testing.T) {\n\t// Valid tokens\n\trequire.True(t, ValidToken(\"tk_1234567890123456789012345678x\"))\n\trequire.True(t, ValidToken(\"tk_abcdefghijklmnopqrstuvwxyzabc\"))\n\trequire.True(t, ValidToken(\"tk_ABCDEFGHIJKLMNOPQRSTUVWXYZABC\"))\n\trequire.True(t, ValidToken(\"tk_012345678901234567890123456ab\"))\n\trequire.True(t, ValidToken(\"tk_-----------------------------\"))\n\trequire.True(t, ValidToken(\"tk______________________________\"))\n\n\t// Invalid tokens - wrong prefix\n\trequire.False(t, ValidToken(\"tx_1234567890123456789012345678x\"))\n\trequire.False(t, ValidToken(\"tk1234567890123456789012345678xy\"))\n\trequire.False(t, ValidToken(\"token_1234567890123456789012345\"))\n\n\t// Invalid tokens - wrong length\n\trequire.False(t, ValidToken(\"tk_\"))                               // Too short\n\trequire.False(t, ValidToken(\"tk_123\"))                            // Too short\n\trequire.False(t, ValidToken(\"tk_123456789012345678901234567890\")) // Too long (30 chars after prefix)\n\trequire.False(t, ValidToken(\"tk_123456789012345678901234567\"))    // Too short (28 chars)\n\n\t// Invalid tokens - invalid characters\n\trequire.False(t, ValidToken(\"tk_123456789012345678901234567!@\"))\n\trequire.False(t, ValidToken(\"tk_12345678901234567890123456 8x\"))\n\trequire.False(t, ValidToken(\"tk_123456789012345678901234567.x\"))\n\trequire.False(t, ValidToken(\"tk_123456789012345678901234567*x\"))\n\n\t// Invalid tokens - no prefix\n\trequire.False(t, ValidToken(\"1234567890123456789012345678901x\"))\n\trequire.False(t, ValidToken(\"\"))\n}\n\nfunc TestGenerateToken(t *testing.T) {\n\t// Generate multiple tokens\n\ttokens := make(map[string]bool)\n\tfor i := 0; i < 100; i++ {\n\t\ttoken := GenerateToken()\n\n\t\t// Check format\n\t\trequire.True(t, strings.HasPrefix(token, \"tk_\"), \"Token should start with tk_\")\n\t\trequire.Equal(t, 32, len(token), \"Token should be 32 characters long\")\n\n\t\t// Check it's valid\n\t\trequire.True(t, ValidToken(token), \"Generated token should be valid\")\n\n\t\t// Check it's lowercase\n\t\trequire.Equal(t, strings.ToLower(token), token, \"Token should be lowercase\")\n\n\t\t// Check uniqueness\n\t\trequire.False(t, tokens[token], \"Token should be unique\")\n\t\ttokens[token] = true\n\t}\n\n\t// Verify we got 100 unique tokens\n\trequire.Equal(t, 100, len(tokens))\n}\n\nfunc TestHashPassword(t *testing.T) {\n\tpassword := \"test-password-123\"\n\n\t// Hash the password\n\thash, err := HashPassword(password)\n\trequire.Nil(t, err)\n\trequire.NotEmpty(t, hash)\n\n\t// Check it's a valid bcrypt hash\n\trequire.Nil(t, ValidPasswordHash(hash, DefaultUserPasswordBcryptCost))\n\n\t// Check it starts with correct prefix\n\trequire.True(t, strings.HasPrefix(hash, \"$2a$\"))\n\n\t// Hash the same password again - should produce different hash\n\thash2, err := HashPassword(password)\n\trequire.Nil(t, err)\n\trequire.NotEqual(t, hash, hash2, \"Same password should produce different hashes (salt)\")\n\n\t// Empty password should still work\n\temptyHash, err := HashPassword(\"\")\n\trequire.Nil(t, err)\n\trequire.NotEmpty(t, emptyHash)\n\trequire.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))\n}\n\nfunc TestHashPassword_WithCost(t *testing.T) {\n\tpassword := \"test-password\"\n\n\t// Test with different costs\n\thash4, err := hashPassword(password, 4)\n\trequire.Nil(t, err)\n\trequire.True(t, strings.HasPrefix(hash4, \"$2a$04$\"))\n\n\thash10, err := hashPassword(password, 10)\n\trequire.Nil(t, err)\n\trequire.True(t, strings.HasPrefix(hash10, \"$2a$10$\"))\n\n\thash12, err := hashPassword(password, 12)\n\trequire.Nil(t, err)\n\trequire.True(t, strings.HasPrefix(hash12, \"$2a$12$\"))\n\n\t// All should be valid\n\trequire.Nil(t, ValidPasswordHash(hash4, 4))\n\trequire.Nil(t, ValidPasswordHash(hash10, 10))\n\trequire.Nil(t, ValidPasswordHash(hash12, 12))\n}\n\nfunc TestUser_TierID(t *testing.T) {\n\t// User with tier\n\tu := &User{\n\t\tTier: &Tier{\n\t\t\tID:   \"ti_123\",\n\t\t\tCode: \"pro\",\n\t\t},\n\t}\n\trequire.Equal(t, \"ti_123\", u.TierID())\n\n\t// User without tier\n\tu2 := &User{\n\t\tTier: nil,\n\t}\n\trequire.Equal(t, \"\", u2.TierID())\n\n\t// Nil user\n\tvar u3 *User\n\trequire.Equal(t, \"\", u3.TierID())\n}\n\nfunc TestUser_IsAdmin(t *testing.T) {\n\tadmin := &User{Role: RoleAdmin}\n\trequire.True(t, admin.IsAdmin())\n\trequire.False(t, admin.IsUser())\n\n\tuser := &User{Role: RoleUser}\n\trequire.False(t, user.IsAdmin())\n\n\tanonymous := &User{Role: RoleAnonymous}\n\trequire.False(t, anonymous.IsAdmin())\n\n\t// Nil user\n\tvar nilUser *User\n\trequire.False(t, nilUser.IsAdmin())\n}\n\nfunc TestUser_IsUser(t *testing.T) {\n\tuser := &User{Role: RoleUser}\n\trequire.True(t, user.IsUser())\n\trequire.False(t, user.IsAdmin())\n\n\tadmin := &User{Role: RoleAdmin}\n\trequire.False(t, admin.IsUser())\n\n\tanonymous := &User{Role: RoleAnonymous}\n\trequire.False(t, anonymous.IsUser())\n\n\t// Nil user\n\tvar nilUser *User\n\trequire.False(t, nilUser.IsUser())\n}\n\nfunc TestPermission_String(t *testing.T) {\n\trequire.Equal(t, \"read-write\", PermissionReadWrite.String())\n\trequire.Equal(t, \"read-only\", PermissionRead.String())\n\trequire.Equal(t, \"write-only\", PermissionWrite.String())\n\trequire.Equal(t, \"deny-all\", PermissionDenyAll.String())\n}\n"
  },
  {
    "path": "util/batching_queue.go",
    "content": "package util\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// BatchingQueue is a queue that creates batches of the enqueued elements based on a\n// max batch size and a batch timeout.\n//\n// Example:\n//\n//\tq := NewBatchingQueue[int](2, 500 * time.Millisecond)\n//\tgo func() {\n//\t  for batch := range q.Dequeue() {\n//\t    fmt.Println(batch)\n//\t  }\n//\t}()\n//\tq.Enqueue(1)\n//\tq.Enqueue(2)\n//\tq.Enqueue(3)\n//\ttime.Sleep(time.Second)\n//\n// This example will emit batch [1, 2] immediately (because the batch size is 2), and\n// a batch [3] after 500ms.\ntype BatchingQueue[T any] struct {\n\tbatchSize int\n\ttimeout   time.Duration\n\tin        []T\n\tout       chan []T\n\tmu        sync.Mutex\n}\n\n// NewBatchingQueue creates a new BatchingQueue\nfunc NewBatchingQueue[T any](batchSize int, timeout time.Duration) *BatchingQueue[T] {\n\tq := &BatchingQueue[T]{\n\t\tbatchSize: batchSize,\n\t\ttimeout:   timeout,\n\t\tin:        make([]T, 0),\n\t\tout:       make(chan []T),\n\t}\n\tgo q.timeoutTicker()\n\treturn q\n}\n\n// Enqueue enqueues an element to the queue. If the configured batch size is reached,\n// the batch will be emitted immediately.\nfunc (q *BatchingQueue[T]) Enqueue(element T) {\n\tq.mu.Lock()\n\tq.in = append(q.in, element)\n\tvar elements []T\n\tif len(q.in) == q.batchSize {\n\t\telements = q.dequeueAll()\n\t}\n\tq.mu.Unlock()\n\tif len(elements) > 0 {\n\t\tq.out <- elements\n\t}\n}\n\n// Dequeue returns a channel emitting batches of elements\nfunc (q *BatchingQueue[T]) Dequeue() <-chan []T {\n\treturn q.out\n}\n\nfunc (q *BatchingQueue[T]) dequeueAll() []T {\n\telements := make([]T, len(q.in))\n\tcopy(elements, q.in)\n\tq.in = q.in[:0]\n\treturn elements\n}\n\nfunc (q *BatchingQueue[T]) timeoutTicker() {\n\tif q.timeout == 0 {\n\t\treturn\n\t}\n\tticker := time.NewTicker(q.timeout)\n\tfor range ticker.C {\n\t\tq.mu.Lock()\n\t\telements := q.dequeueAll()\n\t\tq.mu.Unlock()\n\t\tif len(elements) > 0 {\n\t\t\tq.out <- elements\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/batching_queue_test.go",
    "content": "package util_test\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"heckel.io/ntfy/v2/util\"\n\t\"math/rand\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestBatchingQueue_InfTimeout(t *testing.T) {\n\tq := util.NewBatchingQueue[int](25, 1*time.Hour)\n\tbatches, total := make([][]int, 0), 0\n\tvar mu sync.Mutex\n\tgo func() {\n\t\tfor batch := range q.Dequeue() {\n\t\t\tmu.Lock()\n\t\t\tbatches = append(batches, batch)\n\t\t\ttotal += len(batch)\n\t\t\tmu.Unlock()\n\t\t}\n\t}()\n\tfor i := 0; i < 101; i++ {\n\t\tgo q.Enqueue(i)\n\t}\n\ttime.Sleep(time.Second)\n\tmu.Lock()\n\trequire.Equal(t, 100, total) // One is missing, stuck in the last batch!\n\trequire.Equal(t, 4, len(batches))\n\tmu.Unlock()\n}\n\nfunc TestBatchingQueue_WithTimeout(t *testing.T) {\n\tq := util.NewBatchingQueue[int](25, 100*time.Millisecond)\n\tbatches, total := make([][]int, 0), 0\n\tvar mu sync.Mutex\n\tgo func() {\n\t\tfor batch := range q.Dequeue() {\n\t\t\tmu.Lock()\n\t\t\tbatches = append(batches, batch)\n\t\t\ttotal += len(batch)\n\t\t\tmu.Unlock()\n\t\t}\n\t}()\n\tfor i := 0; i < 101; i++ {\n\t\tgo func(i int) {\n\t\t\ttime.Sleep(time.Duration(rand.Intn(700)) * time.Millisecond)\n\t\t\tq.Enqueue(i)\n\t\t}(i)\n\t}\n\ttime.Sleep(time.Second)\n\tmu.Lock()\n\trequire.Equal(t, 101, total)\n\trequire.True(t, len(batches) > 4) // 101/25\n\trequire.True(t, len(batches) < 21)\n\tmu.Unlock()\n}\n"
  },
  {
    "path": "util/content_type_writer.go",
    "content": "package util\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n)\n\n// ContentTypeWriter is an implementation of http.ResponseWriter that will detect the content type and set the\n// Content-Type and (optionally) Content-Disposition headers accordingly.\n//\n// It will always set a Content-Type based on http.DetectContentType, but will never send the \"text/html\"\n// content type.\ntype ContentTypeWriter struct {\n\tw        http.ResponseWriter\n\tfilename string\n\tsniffed  bool\n}\n\n// NewContentTypeWriter creates a new ContentTypeWriter\nfunc NewContentTypeWriter(w http.ResponseWriter, filename string) *ContentTypeWriter {\n\treturn &ContentTypeWriter{w, filename, false}\n}\n\nfunc (w *ContentTypeWriter) Write(p []byte) (n int, err error) {\n\tif w.sniffed {\n\t\treturn w.w.Write(p)\n\t}\n\t// Detect and set Content-Type header\n\t// Fix content types that we don't want to inline-render in the browser. In particular,\n\t// we don't want to render HTML in the browser for security reasons.\n\tcontentType, _ := DetectContentType(p, w.filename)\n\tif strings.HasPrefix(contentType, \"text/html\") {\n\t\tcontentType = strings.ReplaceAll(contentType, \"text/html\", \"text/plain\")\n\t} else if contentType == \"application/octet-stream\" {\n\t\tcontentType = \"\" // Reset to let downstream http.ResponseWriter take care of it\n\t}\n\tif contentType != \"\" {\n\t\tw.w.Header().Set(\"Content-Type\", contentType)\n\t}\n\tw.sniffed = true\n\treturn w.w.Write(p)\n}\n"
  },
  {
    "path": "util/content_type_writer_test.go",
    "content": "package util\n\nimport (\n\t\"crypto/rand\"\n\t\"github.com/stretchr/testify/require\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestSniffWriter_WriteHTML(t *testing.T) {\n\trr := httptest.NewRecorder()\n\tsw := NewContentTypeWriter(rr, \"\")\n\tsw.Write([]byte(\"<script>alert('hi')</script>\"))\n\trequire.Equal(t, \"text/plain; charset=utf-8\", rr.Header().Get(\"Content-Type\"))\n}\n\nfunc TestSniffWriter_WriteTwoWriteCalls(t *testing.T) {\n\trr := httptest.NewRecorder()\n\tsw := NewContentTypeWriter(rr, \"\")\n\tsw.Write([]byte{0x25, 0x50, 0x44, 0x46, 0x2d, 0x11, 0x22, 0x33})\n\tsw.Write([]byte(\"<script>alert('hi')</script>\"))\n\trequire.Equal(t, \"application/pdf\", rr.Header().Get(\"Content-Type\"))\n}\n\nfunc TestSniffWriter_NoSniffWriterWriteHTML(t *testing.T) {\n\t// This test just makes sure that without the sniff-w, we would get text/html\n\n\trr := httptest.NewRecorder()\n\trr.Write([]byte(\"<script>alert('hi')</script>\"))\n\trequire.Equal(t, \"text/html; charset=utf-8\", rr.Header().Get(\"Content-Type\"))\n}\n\nfunc TestSniffWriter_WriteHTMLSplitIntoTwoWrites(t *testing.T) {\n\t// This test shows how splitting the HTML into two Write() calls will still yield text/plain\n\n\trr := httptest.NewRecorder()\n\tsw := NewContentTypeWriter(rr, \"\")\n\tsw.Write([]byte(\"<scr\"))\n\tsw.Write([]byte(\"ipt>alert('hi')</script>\"))\n\trequire.Equal(t, \"text/plain; charset=utf-8\", rr.Header().Get(\"Content-Type\"))\n}\n\nfunc TestSniffWriter_WriteUnknownMimeType(t *testing.T) {\n\trr := httptest.NewRecorder()\n\tsw := NewContentTypeWriter(rr, \"\")\n\trandomBytes := make([]byte, 199)\n\trand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings\n\tsw.Write(randomBytes)\n\trequire.Equal(t, \"application/octet-stream\", rr.Header().Get(\"Content-Type\"))\n}\n\nfunc TestSniffWriter_WriteWithFilenameAPK(t *testing.T) {\n\trr := httptest.NewRecorder()\n\tsw := NewContentTypeWriter(rr, \"https://example.com/ntfy.apk\")\n\tsw.Write([]byte{0x50, 0x4B, 0x03, 0x04})\n\trequire.Equal(t, \"application/vnd.android.package-archive\", rr.Header().Get(\"Content-Type\"))\n}\n"
  },
  {
    "path": "util/embedfs/test.txt",
    "content": "This is a test file for embedfs_test.go\n"
  },
  {
    "path": "util/embedfs.go",
    "content": "package util\n\nimport (\n\t\"embed\"\n\t\"errors\"\n\t\"io\"\n\t\"io/fs\"\n\t\"time\"\n)\n\n// CachingEmbedFS is a wrapper around embed.FS that allows setting a ModTime, so that the\n// default static file server can send 304s back. It can be used like this:\n//\n//\t  var (\n//\t     //go:embed docs\n//\t     docsStaticFs     embed.FS\n//\t     docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}\n//\t  )\n//\n//\t\t http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)\ntype CachingEmbedFS struct {\n\tModTime time.Time\n\tFS      embed.FS\n}\n\n// Open opens a file in the embedded filesystem and returns a fs.File with the static ModTime\nfunc (f CachingEmbedFS) Open(name string) (fs.File, error) {\n\tfile, err := f.FS.Open(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tstat, err := file.Stat()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cachingEmbedFile{file, f.ModTime, stat}, nil\n}\n\ntype cachingEmbedFile struct {\n\tfile    fs.File\n\tmodTime time.Time\n\tfs.FileInfo\n}\n\nfunc (f cachingEmbedFile) Stat() (fs.FileInfo, error) {\n\treturn f, nil\n}\n\nfunc (f cachingEmbedFile) Read(bytes []byte) (int, error) {\n\treturn f.file.Read(bytes)\n}\n\nfunc (f *cachingEmbedFile) Seek(offset int64, whence int) (int64, error) {\n\tif seeker, ok := f.file.(io.Seeker); ok {\n\t\treturn seeker.Seek(offset, whence)\n\t}\n\treturn 0, errors.New(\"io.Seeker not implemented\")\n}\n\nfunc (f cachingEmbedFile) ModTime() time.Time {\n\treturn f.modTime // We override this!\n}\n\nfunc (f cachingEmbedFile) Close() error {\n\treturn f.file.Close()\n}\n"
  },
  {
    "path": "util/embedfs_test.go",
    "content": "package util\n\nimport (\n\t\"embed\"\n\t\"github.com/stretchr/testify/require\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n)\n\nvar (\n\tmodTime = time.Now()\n\n\t//go:embed embedfs\n\ttestFs       embed.FS\n\ttestFsCached = &CachingEmbedFS{ModTime: modTime, FS: testFs}\n)\n\nfunc TestCachingEmbedFS(t *testing.T) {\n\ts := http.FileServer(http.FS(testFsCached))\n\n\trr := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/embedfs/test.txt\", nil)\n\ts.ServeHTTP(rr, req)\n\trequire.Equal(t, 200, rr.Code)\n\tlastModified := rr.Header().Get(\"Last-Modified\")\n\n\trr = httptest.NewRecorder()\n\treq, _ = http.NewRequest(\"GET\", \"/embedfs/test.txt\", nil)\n\treq.Header.Set(\"If-Modified-Since\", lastModified)\n\ts.ServeHTTP(rr, req)\n\trequire.Equal(t, 304, rr.Code) // Huzzah!\n}\n\nfunc TestCachingEmbedFS_Range(t *testing.T) {\n\ts := http.FileServer(http.FS(testFsCached))\n\trr := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/embedfs/test.txt\", nil)\n\treq.Header.Set(\"Range\", \"bytes=1-20\")\n\ts.ServeHTTP(rr, req)\n\trequire.Equal(t, 206, rr.Code)\n\trequire.Equal(t, \"his is a test file f\", rr.Body.String())\n}\n"
  },
  {
    "path": "util/gzip_handler.go",
    "content": "package util\n\nimport (\n\t\"compress/gzip\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n)\n\n// Gzip is a HTTP middleware to transparently compress responses using gzip.\n// Original code from https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7 (MIT)\nfunc Gzip(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\n\t\tgz := gzPool.Get().(*gzip.Writer)\n\t\tdefer gzPool.Put(gz)\n\n\t\tgz.Reset(w)\n\t\tdefer gz.Close()\n\n\t\tr.Header.Del(\"Accept-Encoding\") // prevent double-gzipping\n\t\tnext.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)\n\t})\n}\n\nvar gzPool = sync.Pool{\n\tNew: func() any {\n\t\tw := gzip.NewWriter(io.Discard)\n\t\treturn w\n\t},\n}\n\ntype gzipResponseWriter struct {\n\tio.Writer\n\thttp.ResponseWriter\n}\n\nfunc (w *gzipResponseWriter) WriteHeader(status int) {\n\tw.Header().Del(\"Content-Length\")\n\tw.ResponseWriter.WriteHeader(status)\n}\n\nfunc (w *gzipResponseWriter) Write(b []byte) (int, error) {\n\treturn w.Writer.Write(b)\n}\n"
  },
  {
    "path": "util/gzip_handler_test.go",
    "content": "package util\n\nimport (\n\t\"compress/gzip\"\n\t\"github.com/stretchr/testify/require\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestGzipHandler(t *testing.T) {\n\ts := Gzip(http.FileServer(http.FS(testFs)))\n\n\trr := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/embedfs/test.txt\", nil)\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\ts.ServeHTTP(rr, req)\n\trequire.Equal(t, 200, rr.Code)\n\trequire.Equal(t, \"gzip\", rr.Header().Get(\"Content-Encoding\"))\n\trequire.Equal(t, \"\", rr.Header().Get(\"Content-Length\"))\n\n\tgz, _ := gzip.NewReader(rr.Body)\n\tb, _ := io.ReadAll(gz)\n\trequire.Equal(t, \"This is a test file for embedfs_test.go\\n\", string(b))\n}\n\nfunc TestGzipHandler_NoGzip(t *testing.T) {\n\ts := Gzip(http.FileServer(http.FS(testFs)))\n\n\trr := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/embedfs/test.txt\", nil)\n\ts.ServeHTTP(rr, req)\n\trequire.Equal(t, 200, rr.Code)\n\trequire.Equal(t, \"\", rr.Header().Get(\"Content-Encoding\"))\n\trequire.Equal(t, \"40\", rr.Header().Get(\"Content-Length\"))\n\n\tb, _ := io.ReadAll(rr.Body)\n\trequire.Equal(t, \"This is a test file for embedfs_test.go\\n\", string(b))\n}\n"
  },
  {
    "path": "util/limit.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"golang.org/x/time/rate\"\n\t\"io\"\n\t\"sync\"\n\t\"time\"\n)\n\n// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached\nvar ErrLimitReached = errors.New(\"limit reached\")\n\n// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value\ntype Limiter interface {\n\t// Allow adds one to the limiters value, or returns false if the limit has been reached\n\tAllow() bool\n\n\t// AllowN adds n to the limiters value, or returns false if the limit has been reached\n\tAllowN(n int64) bool\n\n\t// Value returns the current internal limiter value\n\tValue() int64\n\n\t// Reset resets the state of the limiter\n\tReset()\n}\n\n// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached\n// ErrLimitReached will be returned. FixedLimiter may be used by multiple goroutines.\ntype FixedLimiter struct {\n\tvalue int64\n\tlimit int64\n\tmu    sync.Mutex\n}\n\nvar _ Limiter = (*FixedLimiter)(nil)\n\n// NewFixedLimiter creates a new Limiter\nfunc NewFixedLimiter(limit int64) *FixedLimiter {\n\treturn NewFixedLimiterWithValue(limit, 0)\n}\n\n// NewFixedLimiterWithValue creates a new Limiter and sets the initial value\nfunc NewFixedLimiterWithValue(limit, value int64) *FixedLimiter {\n\treturn &FixedLimiter{\n\t\tlimit: limit,\n\t\tvalue: value,\n\t}\n}\n\n// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was\n// exceeded, false is returned.\nfunc (l *FixedLimiter) Allow() bool {\n\treturn l.AllowN(1)\n}\n\n// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was\n// exceeded after adding n, false is returned.\nfunc (l *FixedLimiter) AllowN(n int64) bool {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tif l.value+n > l.limit {\n\t\treturn false\n\t}\n\tl.value += n\n\treturn true\n}\n\n// Value returns the current limiter value\nfunc (l *FixedLimiter) Value() int64 {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\treturn l.value\n}\n\n// Reset sets the limiter's value back to zero\nfunc (l *FixedLimiter) Reset() {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.value = 0\n}\n\n// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.\ntype RateLimiter struct {\n\tr       rate.Limit\n\tb       int\n\tvalue   int64\n\tlimiter *rate.Limiter\n\tmu      sync.Mutex\n}\n\nvar _ Limiter = (*RateLimiter)(nil)\n\n// NewRateLimiter creates a new RateLimiter\nfunc NewRateLimiter(r rate.Limit, b int) *RateLimiter {\n\treturn NewRateLimiterWithValue(r, b, 0)\n}\n\n// NewRateLimiterWithValue creates a new RateLimiter with the given starting value.\n//\n// Note that the starting value only has informational value. It does not impact the underlying\n// value of the rate.Limiter.\nfunc NewRateLimiterWithValue(r rate.Limit, b int, value int64) *RateLimiter {\n\treturn &RateLimiter{\n\t\tr:       r,\n\t\tb:       b,\n\t\tvalue:   value,\n\t\tlimiter: rate.NewLimiter(r, b),\n\t}\n}\n\n// NewBytesLimiter creates a RateLimiter that is meant to be used for a bytes-per-interval limit,\n// e.g. 250 MB per day. And example of the underlying idea can be found here: https://go.dev/play/p/0ljgzIZQ6dJ\nfunc NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter {\n\treturn NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes)\n}\n\n// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was\n// exceeded, false is returned.\nfunc (l *RateLimiter) Allow() bool {\n\treturn l.AllowN(1)\n}\n\n// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was\n// exceeded after adding n, false is returned.\nfunc (l *RateLimiter) AllowN(n int64) bool {\n\tif n <= 0 {\n\t\treturn false // No-op. Can't take back bytes you're written!\n\t}\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tif !l.limiter.AllowN(time.Now(), int(n)) {\n\t\treturn false\n\t}\n\tl.value += n\n\treturn true\n}\n\n// Value returns the current limiter value\nfunc (l *RateLimiter) Value() int64 {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\treturn l.value\n}\n\n// Reset sets the limiter's value back to zero, and resets the underlying rate.Limiter\nfunc (l *RateLimiter) Reset() {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.limiter = rate.NewLimiter(l.r, l.b)\n\tl.value = 0\n}\n\n// LimitWriter implements an io.Writer that will pass through all Write calls to the underlying\n// writer w until any of the limiter's limit is reached, at which point a Write will return ErrLimitReached.\n// Each limiter's value is increased with every write.\ntype LimitWriter struct {\n\tw        io.Writer\n\twritten  int64\n\tlimiters []Limiter\n\tmu       sync.Mutex\n}\n\n// NewLimitWriter creates a new LimitWriter\nfunc NewLimitWriter(w io.Writer, limiters ...Limiter) *LimitWriter {\n\treturn &LimitWriter{\n\t\tw:        w,\n\t\tlimiters: limiters,\n\t}\n}\n\n// Write passes through all writes to the underlying writer until any of the given limiter's limit is reached\nfunc (w *LimitWriter) Write(p []byte) (n int, err error) {\n\tw.mu.Lock()\n\tdefer w.mu.Unlock()\n\tfor i := 0; i < len(w.limiters); i++ {\n\t\tif !w.limiters[i].AllowN(int64(len(p))) {\n\t\t\tfor j := i - 1; j >= 0; j-- {\n\t\t\t\tw.limiters[j].AllowN(-int64(len(p))) // Revert limiters limits if not allowed\n\t\t\t}\n\t\t\treturn 0, ErrLimitReached\n\t\t}\n\t}\n\tn, err = w.w.Write(p)\n\tw.written += int64(n)\n\treturn\n}\n"
  },
  {
    "path": "util/limit_test.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"github.com/stretchr/testify/require\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestFixedLimiter_AllowValueReset(t *testing.T) {\n\tl := NewFixedLimiter(10)\n\trequire.True(t, l.AllowN(5))\n\trequire.Equal(t, int64(5), l.Value())\n\n\trequire.True(t, l.AllowN(5))\n\trequire.Equal(t, int64(10), l.Value())\n\n\trequire.False(t, l.Allow())\n\trequire.Equal(t, int64(10), l.Value())\n\n\tl.Reset()\n\trequire.Equal(t, int64(0), l.Value())\n\trequire.True(t, l.Allow())\n\trequire.True(t, l.AllowN(9))\n\trequire.False(t, l.Allow())\n}\n\nfunc TestFixedLimiter_AddSub(t *testing.T) {\n\tl := NewFixedLimiter(10)\n\tl.AllowN(5)\n\tif l.value != 5 {\n\t\tt.Fatalf(\"expected value to be %d, got %d\", 5, l.value)\n\t}\n\tl.AllowN(-2)\n\tif l.value != 3 {\n\t\tt.Fatalf(\"expected value to be %d, got %d\", 7, l.value)\n\t}\n}\n\nfunc TestBytesLimiter_Add_Simple(t *testing.T) {\n\tl := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h\n\trequire.True(t, l.AllowN(100*1024*1024))\n\trequire.Equal(t, int64(100*1024*1024), l.Value())\n\n\trequire.True(t, l.AllowN(100*1024*1024))\n\trequire.Equal(t, int64(200*1024*1024), l.Value())\n\n\trequire.False(t, l.AllowN(300*1024*1024))\n\trequire.Equal(t, int64(200*1024*1024), l.Value())\n}\n\nfunc TestBytesLimiter_Add_Wait(t *testing.T) {\n\tl := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms)\n\trequire.True(t, l.AllowN(250*1024*1024))\n\trequire.False(t, l.AllowN(400))\n\ttime.Sleep(200 * time.Millisecond)\n\trequire.True(t, l.AllowN(400))\n}\n\nfunc TestLimitWriter_WriteNoLimiter(t *testing.T) {\n\tvar buf bytes.Buffer\n\tlw := NewLimitWriter(&buf)\n\tif _, err := lw.Write(make([]byte, 10)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := lw.Write(make([]byte, 1)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif buf.Len() != 11 {\n\t\tt.Fatalf(\"expected buffer length to be %d, got %d\", 11, buf.Len())\n\t}\n}\n\nfunc TestLimitWriter_WriteOneLimiter(t *testing.T) {\n\tvar buf bytes.Buffer\n\tl := NewFixedLimiter(10)\n\tlw := NewLimitWriter(&buf, l)\n\tif _, err := lw.Write(make([]byte, 10)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := lw.Write(make([]byte, 1)); err != ErrLimitReached {\n\t\tt.Fatalf(\"expected ErrLimitReached, got %#v\", err)\n\t}\n\tif buf.Len() != 10 {\n\t\tt.Fatalf(\"expected buffer length to be %d, got %d\", 10, buf.Len())\n\t}\n\tif l.value != 10 {\n\t\tt.Fatalf(\"expected limiter value to be %d, got %d\", 10, l.value)\n\t}\n}\n\nfunc TestLimitWriter_WriteTwoLimiters(t *testing.T) {\n\tvar buf bytes.Buffer\n\tl1 := NewFixedLimiter(11)\n\tl2 := NewFixedLimiter(9)\n\tlw := NewLimitWriter(&buf, l1, l2)\n\tif _, err := lw.Write(make([]byte, 8)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := lw.Write(make([]byte, 2)); err != ErrLimitReached {\n\t\tt.Fatalf(\"expected ErrLimitReached, got %#v\", err)\n\t}\n\tif buf.Len() != 8 {\n\t\tt.Fatalf(\"expected buffer length to be %d, got %d\", 8, buf.Len())\n\t}\n\tif l1.value != 8 {\n\t\tt.Fatalf(\"expected limiter 1 value to be %d, got %d\", 8, l1.value)\n\t}\n\tif l2.value != 8 {\n\t\tt.Fatalf(\"expected limiter 2 value to be %d, got %d\", 8, l2.value)\n\t}\n}\n\nfunc TestLimitWriter_WriteTwoDifferentLimiters(t *testing.T) {\n\tvar buf bytes.Buffer\n\tl1 := NewFixedLimiter(32)\n\tl2 := NewBytesLimiter(8, 200*time.Millisecond)\n\tlw := NewLimitWriter(&buf, l1, l2)\n\t_, err := lw.Write(make([]byte, 8))\n\trequire.Nil(t, err)\n\t_, err = lw.Write(make([]byte, 4))\n\trequire.Equal(t, ErrLimitReached, err)\n}\n\nfunc TestLimitWriter_WriteTwoDifferentLimiters_Wait(t *testing.T) {\n\tvar buf bytes.Buffer\n\tl1 := NewFixedLimiter(32)\n\tl2 := NewBytesLimiter(8, 200*time.Millisecond)\n\tlw := NewLimitWriter(&buf, l1, l2)\n\t_, err := lw.Write(make([]byte, 8))\n\trequire.Nil(t, err)\n\ttime.Sleep(250 * time.Millisecond)\n\t_, err = lw.Write(make([]byte, 8))\n\trequire.Nil(t, err)\n\t_, err = lw.Write(make([]byte, 4))\n\trequire.Equal(t, ErrLimitReached, err)\n}\n\nfunc TestLimitWriter_WriteTwoDifferentLimiters_Wait_FixedLimiterFail(t *testing.T) {\n\tvar buf bytes.Buffer\n\tl1 := NewFixedLimiter(11) // <<< This fails below\n\tl2 := NewBytesLimiter(8, 200*time.Millisecond)\n\tlw := NewLimitWriter(&buf, l1, l2)\n\t_, err := lw.Write(make([]byte, 8))\n\trequire.Nil(t, err)\n\ttime.Sleep(250 * time.Millisecond)\n\t_, err = lw.Write(make([]byte, 8)) // <<< FixedLimiter fails\n\trequire.Equal(t, ErrLimitReached, err)\n}\n"
  },
  {
    "path": "util/lookup_cache.go",
    "content": "package util\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// LookupCache is a single-value cache with a time-to-live (TTL). The cache has a lookup function\n// to retrieve the value and stores it until TTL is reached.\n//\n// Example:\n//\n//\tlookup := func() (string, error) {\n//\t   r, _ := http.Get(\"...\")\n//\t   s, _ := io.ReadAll(r.Body)\n//\t   return string(s), nil\n//\t}\n//\tc := NewLookupCache[string](lookup, time.Hour)\n//\tfmt.Println(c.Get()) // Fetches the string via HTTP\n//\tfmt.Println(c.Get()) // Uses cached value\ntype LookupCache[T any] struct {\n\tvalue   *T\n\tlookup  func() (T, error)\n\tttl     time.Duration\n\tupdated time.Time\n\tmu      sync.Mutex\n}\n\n// LookupFunc is a function that is called by the LookupCache if the underlying\n// value is out-of-date. It returns the new value, or an error.\ntype LookupFunc[T any] func() (T, error)\n\n// NewLookupCache creates a new LookupCache with a given time-to-live (TTL)\nfunc NewLookupCache[T any](lookup LookupFunc[T], ttl time.Duration) *LookupCache[T] {\n\treturn &LookupCache[T]{\n\t\tvalue:  nil,\n\t\tlookup: lookup,\n\t\tttl:    ttl,\n\t}\n}\n\n// Value returns the cached value, or retrieves it via the lookup function\nfunc (c *LookupCache[T]) Value() (T, error) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tif c.value == nil || (c.ttl > 0 && time.Since(c.updated) > c.ttl) {\n\t\tvalue, err := c.lookup()\n\t\tif err != nil {\n\t\t\tvar t T\n\t\t\treturn t, err\n\t\t}\n\t\tc.value = &value\n\t\tc.updated = time.Now()\n\t}\n\treturn *c.value, nil\n}\n"
  },
  {
    "path": "util/lookup_cache_test.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"github.com/stretchr/testify/require\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestLookupCache_Success(t *testing.T) {\n\tvalues, i := []string{\"first\", \"second\"}, 0\n\tc := NewLookupCache[string](func() (string, error) {\n\t\ttime.Sleep(300 * time.Millisecond)\n\t\tv := values[i]\n\t\ti++\n\t\treturn v, nil\n\t}, 500*time.Millisecond)\n\n\tstart := time.Now()\n\tv, err := c.Value()\n\trequire.Nil(t, err)\n\trequire.Equal(t, values[0], v)\n\trequire.True(t, time.Since(start) >= 300*time.Millisecond)\n\n\tstart = time.Now()\n\tv, err = c.Value()\n\trequire.Nil(t, err)\n\trequire.Equal(t, values[0], v)\n\trequire.True(t, time.Since(start) < 200*time.Millisecond)\n\n\ttime.Sleep(550 * time.Millisecond)\n\n\tstart = time.Now()\n\tv, err = c.Value()\n\trequire.Nil(t, err)\n\trequire.Equal(t, values[1], v)\n\trequire.True(t, time.Since(start) >= 300*time.Millisecond)\n\n\tstart = time.Now()\n\tv, err = c.Value()\n\trequire.Nil(t, err)\n\trequire.Equal(t, values[1], v)\n\trequire.True(t, time.Since(start) < 200*time.Millisecond)\n}\n\nfunc TestLookupCache_Error(t *testing.T) {\n\tc := NewLookupCache[string](func() (string, error) {\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\treturn \"\", errors.New(\"some error\")\n\t}, 500*time.Millisecond)\n\n\tstart := time.Now()\n\tv, err := c.Value()\n\trequire.NotNil(t, err)\n\trequire.Equal(t, \"\", v)\n\trequire.True(t, time.Since(start) >= 200*time.Millisecond)\n\n\tstart = time.Now()\n\tv, err = c.Value()\n\trequire.NotNil(t, err)\n\trequire.Equal(t, \"\", v)\n\trequire.True(t, time.Since(start) >= 200*time.Millisecond)\n}\n"
  },
  {
    "path": "util/peek.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"strings\"\n)\n\n// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.\n// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully\n// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining\n// underlying reader.\ntype PeekedReadCloser struct {\n\tPeekedBytes  []byte\n\tLimitReached bool\n\tpeeked       io.Reader\n\tunderlying   io.ReadCloser\n\tclosed       bool\n}\n\n// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser.\n// It does not return an error if limit is reached. Instead, LimitReached will be set to true.\nfunc Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {\n\tif underlying == nil {\n\t\tunderlying = io.NopCloser(strings.NewReader(\"\"))\n\t}\n\tpeeked := make([]byte, limit)\n\tread, err := io.ReadFull(underlying, peeked)\n\tif err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF {\n\t\treturn nil, err\n\t}\n\treturn &PeekedReadCloser{\n\t\tPeekedBytes:  peeked[:read],\n\t\tLimitReached: read == limit,\n\t\tunderlying:   underlying,\n\t\tpeeked:       bytes.NewReader(peeked[:read]),\n\t\tclosed:       false,\n\t}, nil\n}\n\n// Read reads from the peeked bytes and then from the underlying stream\nfunc (r *PeekedReadCloser) Read(p []byte) (n int, err error) {\n\tif r.closed {\n\t\treturn 0, io.EOF\n\t}\n\tn, err = r.peeked.Read(p)\n\tif errors.Is(err, io.EOF) {\n\t\treturn r.underlying.Read(p)\n\t} else if err != nil {\n\t\treturn 0, err\n\t}\n\treturn\n}\n\n// Close closes the underlying stream\nfunc (r *PeekedReadCloser) Close() error {\n\tif r.closed {\n\t\treturn io.EOF\n\t}\n\tr.closed = true\n\treturn r.underlying.Close()\n}\n"
  },
  {
    "path": "util/peek_test.go",
    "content": "package util\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"io\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestPeak_LimitReached(t *testing.T) {\n\tunderlying := io.NopCloser(strings.NewReader(\"1234567890\"))\n\tpeaked, err := Peek(underlying, 5)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequire.Equal(t, []byte(\"12345\"), peaked.PeekedBytes)\n\trequire.Equal(t, true, peaked.LimitReached)\n\n\tall, err := io.ReadAll(peaked)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequire.Equal(t, []byte(\"1234567890\"), all)\n\trequire.Equal(t, []byte(\"12345\"), peaked.PeekedBytes)\n\trequire.Equal(t, true, peaked.LimitReached)\n}\n\nfunc TestPeak_LimitNotReached(t *testing.T) {\n\tunderlying := io.NopCloser(strings.NewReader(\"1234567890\"))\n\tpeaked, err := Peek(underlying, 15)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tall, err := io.ReadAll(peaked)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequire.Equal(t, []byte(\"1234567890\"), all)\n\trequire.Equal(t, []byte(\"1234567890\"), peaked.PeekedBytes)\n\trequire.Equal(t, false, peaked.LimitReached)\n}\n\nfunc TestPeak_Nil(t *testing.T) {\n\tpeaked, err := Peek(nil, 15)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tall, err := io.ReadAll(peaked)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequire.Equal(t, []byte(\"\"), all)\n\trequire.Equal(t, []byte(\"\"), peaked.PeekedBytes)\n\trequire.Equal(t, false, peaked.LimitReached)\n}\n"
  },
  {
    "path": "util/sprig/LICENSE.txt",
    "content": "Copyright (C) 2013-2020 Masterminds\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "util/sprig/crypto.go",
    "content": "package sprig\n\nimport (\n\t\"crypto/sha1\"\n\t\"crypto/sha256\"\n\t\"crypto/sha512\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"hash/adler32\"\n)\n\n// sha512sum computes the SHA-512 hash of the input string and returns it as a hex-encoded string.\n// This function can be used in templates to generate secure hashes of sensitive data.\n//\n// Example usage in templates: {{ \"hello world\" | sha512sum }}\nfunc sha512sum(input string) string {\n\thash := sha512.Sum512([]byte(input))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// sha256sum computes the SHA-256 hash of the input string and returns it as a hex-encoded string.\n// This is a commonly used cryptographic hash function that produces a 256-bit (32-byte) hash value.\n//\n// Example usage in templates: {{ \"hello world\" | sha256sum }}\nfunc sha256sum(input string) string {\n\thash := sha256.Sum256([]byte(input))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// sha1sum computes the SHA-1 hash of the input string and returns it as a hex-encoded string.\n// Note: SHA-1 is no longer considered secure against well-funded attackers for cryptographic purposes.\n// Consider using sha256sum or sha512sum for security-critical applications.\n//\n// Example usage in templates: {{ \"hello world\" | sha1sum }}\nfunc sha1sum(input string) string {\n\thash := sha1.Sum([]byte(input))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// adler32sum computes the Adler-32 checksum of the input string and returns it as a decimal string.\n// This is a non-cryptographic hash function primarily used for error detection.\n//\n// Example usage in templates: {{ \"hello world\" | adler32sum }}\nfunc adler32sum(input string) string {\n\thash := adler32.Checksum([]byte(input))\n\treturn fmt.Sprintf(\"%d\", hash)\n}\n"
  },
  {
    "path": "util/sprig/crypto_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n)\n\nfunc TestSha512Sum(t *testing.T) {\n\ttpl := `{{\"abc\" | sha512sum}}`\n\tif err := runt(tpl, \"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestSha256Sum(t *testing.T) {\n\ttpl := `{{\"abc\" | sha256sum}}`\n\tif err := runt(tpl, \"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestSha1Sum(t *testing.T) {\n\ttpl := `{{\"abc\" | sha1sum}}`\n\tif err := runt(tpl, \"a9993e364706816aba3e25717850c26c9cd0d89d\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestAdler32Sum(t *testing.T) {\n\ttpl := `{{\"abc\" | adler32sum}}`\n\tif err := runt(tpl, \"38600999\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n"
  },
  {
    "path": "util/sprig/date.go",
    "content": "package sprig\n\nimport (\n\t\"math\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// date formats a date according to the provided format string.\n//\n// Parameters:\n//   - fmt: A Go time format string (e.g., \"2006-01-02 15:04:05\")\n//   - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)\n//\n// If date is not one of the recognized types, the current time is used.\n//\n// Example usage in templates: {{ now | date \"2006-01-02\" }}\nfunc date(fmt string, date any) string {\n\treturn dateInZone(fmt, date, \"Local\")\n}\n\n// htmlDate formats a date in HTML5 date format (YYYY-MM-DD).\n//\n// Parameters:\n//   - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)\n//\n// If date is not one of the recognized types, the current time is used.\n//\n// Example usage in templates: {{ now | htmlDate }}\nfunc htmlDate(date any) string {\n\treturn dateInZone(\"2006-01-02\", date, \"Local\")\n}\n\n// htmlDateInZone formats a date in HTML5 date format (YYYY-MM-DD) in the specified timezone.\n//\n// Parameters:\n//   - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)\n//   - zone: Timezone name (e.g., \"UTC\", \"America/New_York\")\n//\n// If date is not one of the recognized types, the current time is used.\n// If the timezone is invalid, UTC is used.\n//\n// Example usage in templates: {{ now | htmlDateInZone \"UTC\" }}\nfunc htmlDateInZone(date any, zone string) string {\n\treturn dateInZone(\"2006-01-02\", date, zone)\n}\n\n// dateInZone formats a date according to the provided format string in the specified timezone.\n//\n// Parameters:\n//   - fmt: A Go time format string (e.g., \"2006-01-02 15:04:05\")\n//   - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)\n//   - zone: Timezone name (e.g., \"UTC\", \"America/New_York\")\n//\n// If date is not one of the recognized types, the current time is used.\n// If the timezone is invalid, UTC is used.\n//\n// Example usage in templates: {{ now | dateInZone \"2006-01-02 15:04:05\" \"UTC\" }}\nfunc dateInZone(fmt string, date any, zone string) string {\n\tvar t time.Time\n\tswitch date := date.(type) {\n\tdefault:\n\t\tt = time.Now()\n\tcase time.Time:\n\t\tt = date\n\tcase *time.Time:\n\t\tt = *date\n\tcase int64:\n\t\tt = time.Unix(date, 0)\n\tcase int:\n\t\tt = time.Unix(int64(date), 0)\n\tcase int32:\n\t\tt = time.Unix(int64(date), 0)\n\t}\n\tloc, err := time.LoadLocation(zone)\n\tif err != nil {\n\t\tloc, _ = time.LoadLocation(\"UTC\")\n\t}\n\treturn t.In(loc).Format(fmt)\n}\n\n// dateModify modifies a date by adding a duration and returns the resulting time.\n//\n// Parameters:\n//   - fmt: A duration string (e.g., \"24h\", \"-12h30m\", \"1h15m30s\")\n//   - date: The time.Time to modify\n//\n// If the duration string is invalid, the original date is returned.\n//\n// Example usage in templates: {{ now | dateModify \"-24h\" }}\nfunc dateModify(fmt string, date time.Time) time.Time {\n\td, err := time.ParseDuration(fmt)\n\tif err != nil {\n\t\treturn date\n\t}\n\treturn date.Add(d)\n}\n\n// mustDateModify modifies a date by adding a duration and returns the resulting time or an error.\n//\n// Parameters:\n//   - fmt: A duration string (e.g., \"24h\", \"-12h30m\", \"1h15m30s\")\n//   - date: The time.Time to modify\n//\n// Unlike dateModify, this function returns an error if the duration string is invalid.\n//\n// Example usage in templates: {{ now | mustDateModify \"24h\" }}\nfunc mustDateModify(fmt string, date time.Time) (time.Time, error) {\n\td, err := time.ParseDuration(fmt)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn date.Add(d), nil\n}\n\n// dateAgo returns a string representing the time elapsed since the given date.\n//\n// Parameters:\n//   - date: Can be a time.Time, int, or int64 (seconds since UNIX epoch)\n//\n// If date is not one of the recognized types, the current time is used.\n//\n// Example usage in templates: {{ \"2023-01-01\" | toDate \"2006-01-02\" | dateAgo }}\nfunc dateAgo(date any) string {\n\tvar t time.Time\n\tswitch date := date.(type) {\n\tdefault:\n\t\tt = time.Now()\n\tcase time.Time:\n\t\tt = date\n\tcase int64:\n\t\tt = time.Unix(date, 0)\n\tcase int:\n\t\tt = time.Unix(int64(date), 0)\n\t}\n\treturn time.Since(t).Round(time.Second).String()\n}\n\n// duration converts seconds to a duration string.\n//\n// Parameters:\n//   - sec: Can be a string (parsed as int64), or int64 representing seconds\n//\n// Example usage in templates: {{ 3600 | duration }} -> \"1h0m0s\"\nfunc duration(sec any) string {\n\tvar n int64\n\tswitch value := sec.(type) {\n\tdefault:\n\t\tn = 0\n\tcase string:\n\t\tn, _ = strconv.ParseInt(value, 10, 64)\n\tcase int64:\n\t\tn = value\n\t}\n\treturn (time.Duration(n) * time.Second).String()\n}\n\n// durationRound formats a duration in a human-readable rounded format.\n//\n// Parameters:\n//   - duration: Can be a string (parsed as duration), int64 (nanoseconds),\n//     or time.Time (time since that moment)\n//\n// Returns a string with the largest appropriate unit (y, mo, d, h, m, s).\n//\n// Example usage in templates: {{ 3600 | duration | durationRound }} -> \"1h\"\nfunc durationRound(duration any) string {\n\tvar d time.Duration\n\tswitch duration := duration.(type) {\n\tdefault:\n\t\td = 0\n\tcase string:\n\t\td, _ = time.ParseDuration(duration)\n\tcase int64:\n\t\td = time.Duration(duration)\n\tcase time.Time:\n\t\td = time.Since(duration)\n\t}\n\tu := uint64(math.Abs(float64(d)))\n\tvar (\n\t\tyear   = uint64(time.Hour) * 24 * 365\n\t\tmonth  = uint64(time.Hour) * 24 * 30\n\t\tday    = uint64(time.Hour) * 24\n\t\thour   = uint64(time.Hour)\n\t\tminute = uint64(time.Minute)\n\t\tsecond = uint64(time.Second)\n\t)\n\tswitch {\n\tcase u > year:\n\t\treturn strconv.FormatUint(u/year, 10) + \"y\"\n\tcase u > month:\n\t\treturn strconv.FormatUint(u/month, 10) + \"mo\"\n\tcase u > day:\n\t\treturn strconv.FormatUint(u/day, 10) + \"d\"\n\tcase u > hour:\n\t\treturn strconv.FormatUint(u/hour, 10) + \"h\"\n\tcase u > minute:\n\t\treturn strconv.FormatUint(u/minute, 10) + \"m\"\n\tcase u > second:\n\t\treturn strconv.FormatUint(u/second, 10) + \"s\"\n\t}\n\treturn \"0s\"\n}\n\n// toDate parses a string into a time.Time using the specified format.\n//\n// Parameters:\n//   - fmt: A Go time format string (e.g., \"2006-01-02\")\n//   - str: The date string to parse\n//\n// If parsing fails, returns a zero time.Time.\n//\n// Example usage in templates: {{ \"2023-01-01\" | toDate \"2006-01-02\" }}\nfunc toDate(fmt, str string) time.Time {\n\tt, _ := time.ParseInLocation(fmt, str, time.Local)\n\treturn t\n}\n\n// mustToDate parses a string into a time.Time using the specified format or returns an error.\n//\n// Parameters:\n//   - fmt: A Go time format string (e.g., \"2006-01-02\")\n//   - str: The date string to parse\n//\n// Unlike toDate, this function returns an error if parsing fails.\n//\n// Example usage in templates: {{ mustToDate \"2006-01-02\" \"2023-01-01\" }}\nfunc mustToDate(fmt, str string) (time.Time, error) {\n\treturn time.ParseInLocation(fmt, str, time.Local)\n}\n\n// unixEpoch returns the Unix timestamp (seconds since January 1, 1970 UTC) for the given time.\n//\n// Parameters:\n//   - date: A time.Time value\n//\n// Example usage in templates: {{ now | unixEpoch }}\nfunc unixEpoch(date time.Time) string {\n\treturn strconv.FormatInt(date.Unix(), 10)\n}\n"
  },
  {
    "path": "util/sprig/date_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestHtmlDate(t *testing.T) {\n\tt.Skip()\n\ttpl := `{{ htmlDate 0}}`\n\tif err := runt(tpl, \"1970-01-01\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestAgo(t *testing.T) {\n\ttpl := \"{{ ago .Time }}\"\n\tif err := runtv(tpl, \"2m5s\", map[string]any{\"Time\": time.Now().Add(-125 * time.Second)}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif err := runtv(tpl, \"2h34m17s\", map[string]any{\"Time\": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif err := runtv(tpl, \"-5s\", map[string]any{\"Time\": time.Now().Add(5 * time.Second)}); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToDate(t *testing.T) {\n\ttpl := `{{toDate \"2006-01-02\" \"2017-12-31\" | date \"02/01/2006\"}}`\n\tif err := runt(tpl, \"31/12/2017\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestUnixEpoch(t *testing.T) {\n\ttm, err := time.Parse(\"02 Jan 06 15:04:05 MST\", \"13 Jun 19 20:39:39 GMT\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl := `{{unixEpoch .Time}}`\n\n\tif err = runtv(tpl, \"1560458379\", map[string]any{\"Time\": tm}); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestDateInZone(t *testing.T) {\n\ttm, err := time.Parse(\"02 Jan 06 15:04:05 MST\", \"13 Jun 19 20:39:39 GMT\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl := `{{ dateInZone \"02 Jan 06 15:04 -0700\" .Time \"UTC\" }}`\n\n\t// Test time.Time input\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": tm}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test pointer to time.Time input\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": &tm}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test no time input. This should be close enough to time.Now() we can test\n\tloc, _ := time.LoadLocation(\"UTC\")\n\tif err = runtv(tpl, time.Now().In(loc).Format(\"02 Jan 06 15:04 -0700\"), map[string]any{\"Time\": \"\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test unix timestamp as int64\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": int64(1560458379)}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test unix timestamp as int32\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": int32(1560458379)}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test unix timestamp as int\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": int(1560458379)}); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Test case of invalid timezone\n\ttpl = `{{ dateInZone \"02 Jan 06 15:04 -0700\" .Time \"foobar\" }}`\n\tif err = runtv(tpl, \"13 Jun 19 20:39 +0000\", map[string]any{\"Time\": tm}); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestDuration(t *testing.T) {\n\ttpl := \"{{ duration .Secs }}\"\n\tif err := runtv(tpl, \"1m1s\", map[string]any{\"Secs\": \"61\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\tif err := runtv(tpl, \"1h0m0s\", map[string]any{\"Secs\": \"3600\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\t// 1d2h3m4s but go is opinionated\n\tif err := runtv(tpl, \"26h3m4s\", map[string]any{\"Secs\": \"93784\"}); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestDurationRound(t *testing.T) {\n\ttpl := \"{{ durationRound .Time }}\"\n\tif err := runtv(tpl, \"2h\", map[string]any{\"Time\": \"2h5s\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\tif err := runtv(tpl, \"1d\", map[string]any{\"Time\": \"24h5s\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\tif err := runtv(tpl, \"3mo\", map[string]any{\"Time\": \"2400h5s\"}); err != nil {\n\t\tt.Error(err)\n\t}\n\tif err := runtv(tpl, \"1m\", map[string]any{\"Time\": \"-1m1s\"}); err != nil {\n\t\tt.Error(err)\n\t}\n}\n"
  },
  {
    "path": "util/sprig/defaults.go",
    "content": "package sprig\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"slices\"\n\t\"strings\"\n)\n\n// defaultValue checks whether `given` is set, and returns default if not set.\n//\n// This returns `d` if `given` appears not to be set, and `given` otherwise.\n//\n// For numeric types 0 is unset.\n// For strings, maps, arrays, and slices, len() = 0 is considered unset.\n// For bool, false is unset.\n// Structs are never considered unset.\n//\n// For everything else, including pointers, a nil value is unset.\nfunc defaultValue(d any, given ...any) any {\n\tif empty(given) || empty(given[0]) {\n\t\treturn d\n\t}\n\treturn given[0]\n}\n\n// empty returns true if the given value has the zero value for its type.\n// This is a helper function used by defaultValue, coalesce, all, and anyNonEmpty.\n//\n// The following values are considered empty:\n// - Invalid values\n// - nil values\n// - Zero-length arrays, slices, maps, and strings\n// - Boolean false\n// - Zero for all numeric types\n// - Structs are never considered empty\n//\n// Parameters:\n//   - given: The value to check for emptiness\n//\n// Returns:\n//   - bool: True if the value is considered empty, false otherwise\nfunc empty(given any) bool {\n\tg := reflect.ValueOf(given)\n\tif !g.IsValid() {\n\t\treturn true\n\t}\n\t// Basically adapted from text/template.isTrue\n\tswitch g.Kind() {\n\tdefault:\n\t\treturn g.IsNil()\n\tcase reflect.Array, reflect.Slice, reflect.Map, reflect.String:\n\t\treturn g.Len() == 0\n\tcase reflect.Bool:\n\t\treturn !g.Bool()\n\tcase reflect.Complex64, reflect.Complex128:\n\t\treturn g.Complex() == 0\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:\n\t\treturn g.Int() == 0\n\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:\n\t\treturn g.Uint() == 0\n\tcase reflect.Float32, reflect.Float64:\n\t\treturn g.Float() == 0\n\tcase reflect.Struct:\n\t\treturn false\n\t}\n}\n\n// coalesce returns the first non-empty value from a list of values.\n// If all values are empty, it returns nil.\n//\n// This is useful for providing a series of fallback values.\n//\n// Parameters:\n//   - v: A variadic list of values to check\n//\n// Returns:\n//   - any: The first non-empty value, or nil if all values are empty\nfunc coalesce(v ...any) any {\n\tfor _, val := range v {\n\t\tif !empty(val) {\n\t\t\treturn val\n\t\t}\n\t}\n\treturn nil\n}\n\n// all checks if all values in a list are non-empty.\n// Returns true if every value in the list is non-empty.\n// If the list is empty, returns true (vacuously true).\n//\n// Parameters:\n//   - v: A variadic list of values to check\n//\n// Returns:\n//   - bool: True if all values are non-empty, false otherwise\nfunc all(v ...any) bool {\n\treturn !slices.ContainsFunc(v, empty)\n}\n\n// anyNonEmpty checks if at least one value in a list is non-empty.\n// Returns true if any value in the list is non-empty.\n// If the list is empty, returns false.\n//\n// Parameters:\n//   - v: A variadic list of values to check\n//\n// Returns:\n//   - bool: True if at least one value is non-empty, false otherwise\nfunc anyNonEmpty(v ...any) bool {\n\tfor _, val := range v {\n\t\tif !empty(val) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// fromJSON decodes a JSON string into a structured value.\n// This function ignores any errors that occur during decoding.\n// If the JSON is invalid, it returns nil.\n//\n// Parameters:\n//   - v: The JSON string to decode\n//\n// Returns:\n//   - any: The decoded value, or nil if decoding failed\nfunc fromJSON(v string) any {\n\toutput, _ := mustFromJSON(v)\n\treturn output\n}\n\n// mustFromJSON decodes a JSON string into a structured value.\n// Unlike fromJSON, this function returns any errors that occur during decoding.\n//\n// Parameters:\n//   - v: The JSON string to decode\n//\n// Returns:\n//   - any: The decoded value\n//   - error: Any error that occurred during decoding\nfunc mustFromJSON(v string) (any, error) {\n\tvar output any\n\terr := json.Unmarshal([]byte(v), &output)\n\treturn output, err\n}\n\n// toJSON encodes a value into a JSON string.\n// This function ignores any errors that occur during encoding.\n// If the value cannot be encoded, it returns an empty string.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The JSON string representation of the value\nfunc toJSON(v any) string {\n\toutput, _ := json.Marshal(v)\n\treturn string(output)\n}\n\n// mustToJSON encodes a value into a JSON string.\n// Unlike toJSON, this function returns any errors that occur during encoding.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The JSON string representation of the value\n//   - error: Any error that occurred during encoding\nfunc mustToJSON(v any) (string, error) {\n\toutput, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(output), nil\n}\n\n// toPrettyJSON encodes a value into a pretty (indented) JSON string.\n// This function ignores any errors that occur during encoding.\n// If the value cannot be encoded, it returns an empty string.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The indented JSON string representation of the value\nfunc toPrettyJSON(v any) string {\n\toutput, _ := json.MarshalIndent(v, \"\", \"  \")\n\treturn string(output)\n}\n\n// mustToPrettyJSON encodes a value into a pretty (indented) JSON string.\n// Unlike toPrettyJSON, this function returns any errors that occur during encoding.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The indented JSON string representation of the value\n//   - error: Any error that occurred during encoding\nfunc mustToPrettyJSON(v any) (string, error) {\n\toutput, err := json.MarshalIndent(v, \"\", \"  \")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(output), nil\n}\n\n// toRawJSON encodes a value into a JSON string with no escaping of HTML characters.\n// This function panics if an error occurs during encoding.\n// Unlike toJSON, HTML characters like <, >, and & are not escaped.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The JSON string representation of the value without HTML escaping\nfunc toRawJSON(v any) string {\n\toutput, err := mustToRawJSON(v)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn output\n}\n\n// mustToRawJSON encodes a value into a JSON string with no escaping of HTML characters.\n// Unlike toRawJSON, this function returns any errors that occur during encoding.\n// HTML characters like <, >, and & are not escaped in the output.\n//\n// Parameters:\n//   - v: The value to encode to JSON\n//\n// Returns:\n//   - string: The JSON string representation of the value without HTML escaping\n//   - error: Any error that occurred during encoding\nfunc mustToRawJSON(v any) (string, error) {\n\tbuf := new(bytes.Buffer)\n\tenc := json.NewEncoder(buf)\n\tenc.SetEscapeHTML(false)\n\tif err := enc.Encode(&v); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn strings.TrimSuffix(buf.String(), \"\\n\"), nil\n}\n\n// ternary implements a conditional (ternary) operator.\n// It returns the first value if the condition is true, otherwise returns the second value.\n// This is similar to the ?: operator in many programming languages.\n//\n// Parameters:\n//   - vt: The value to return if the condition is true\n//   - vf: The value to return if the condition is false\n//   - v: The boolean condition to evaluate\n//\n// Returns:\n//   - any: Either vt or vf depending on the value of v\nfunc ternary(vt any, vf any, v bool) any {\n\tif v {\n\t\treturn vt\n\t}\n\treturn vf\n}\n"
  },
  {
    "path": "util/sprig/defaults_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestDefault(t *testing.T) {\n\ttpl := `{{\"\" | default \"foo\"}}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{default \"foo\" 234}}`\n\tif err := runt(tpl, \"234\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{default \"foo\" 2.34}}`\n\tif err := runt(tpl, \"2.34\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ .Nothing | default \"123\" }}`\n\tif err := runt(tpl, \"123\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ default \"123\" }}`\n\tif err := runt(tpl, \"123\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestEmpty(t *testing.T) {\n\ttpl := `{{if empty 1}}1{{else}}0{{end}}`\n\tif err := runt(tpl, \"0\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{if empty 0}}1{{else}}0{{end}}`\n\tif err := runt(tpl, \"1\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{if empty \"\"}}1{{else}}0{{end}}`\n\tif err := runt(tpl, \"1\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{if empty 0.0}}1{{else}}0{{end}}`\n\tif err := runt(tpl, \"1\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{if empty false}}1{{else}}0{{end}}`\n\tif err := runt(tpl, \"1\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tdict := map[string]any{\"top\": map[string]any{}}\n\ttpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}`\n\tif err := runtv(tpl, \"1\", dict); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}`\n\tif err := runtv(tpl, \"1\", dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestCoalesce(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ coalesce 1 }}`:                            \"1\",\n\t\t`{{ coalesce \"\" 0 nil 2 }}`:                   \"2\",\n\t\t`{{ $two := 2 }}{{ coalesce \"\" 0 nil $two }}`: \"2\",\n\t\t`{{ $two := 2 }}{{ coalesce \"\" $two 0 0 0 }}`: \"2\",\n\t\t`{{ $two := 2 }}{{ coalesce \"\" $two 3 4 5 }}`: \"2\",\n\t\t`{{ coalesce }}`:                              \"<no value>\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n\n\tdict := map[string]any{\"top\": map[string]any{}}\n\ttpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar \"airplane\"}}`\n\tif err := runtv(tpl, \"airplane\", dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestAll(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ all 1 }}`:                            \"true\",\n\t\t`{{ all \"\" 0 nil 2 }}`:                   \"false\",\n\t\t`{{ $two := 2 }}{{ all \"\" 0 nil $two }}`: \"false\",\n\t\t`{{ $two := 2 }}{{ all \"\" $two 0 0 0 }}`: \"false\",\n\t\t`{{ $two := 2 }}{{ all \"\" $two 3 4 5 }}`: \"false\",\n\t\t`{{ all }}`:                              \"true\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n\n\tdict := map[string]any{\"top\": map[string]any{}}\n\ttpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar \"airplane\"}}`\n\tif err := runtv(tpl, \"false\", dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestAny(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ any 1 }}`:                              \"true\",\n\t\t`{{ any \"\" 0 nil 2 }}`:                     \"true\",\n\t\t`{{ $two := 2 }}{{ any \"\" 0 nil $two }}`:   \"true\",\n\t\t`{{ $two := 2 }}{{ any \"\" $two 3 4 5 }}`:   \"true\",\n\t\t`{{ $zero := 0 }}{{ any \"\" $zero 0 0 0 }}`: \"false\",\n\t\t`{{ any }}`: \"false\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n\n\tdict := map[string]any{\"top\": map[string]any{}}\n\ttpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar \"airplane\"}}`\n\tif err := runtv(tpl, \"true\", dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestFromJSON(t *testing.T) {\n\tdict := map[string]any{\"Input\": `{\"foo\": 55}`}\n\n\ttpl := `{{.Input | fromJSON}}`\n\texpected := `map[foo:55]`\n\tif err := runtv(tpl, expected, dict); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{(.Input | fromJSON).foo}}`\n\texpected = `55`\n\tif err := runtv(tpl, expected, dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tdict := map[string]any{\"Top\": map[string]any{\"bool\": true, \"string\": \"test\", \"number\": 42}}\n\n\ttpl := `{{.Top | toJSON}}`\n\texpected := `{\"bool\":true,\"number\":42,\"string\":\"test\"}`\n\tif err := runtv(tpl, expected, dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToPrettyJSON(t *testing.T) {\n\tdict := map[string]any{\"Top\": map[string]any{\"bool\": true, \"string\": \"test\", \"number\": 42}}\n\ttpl := `{{.Top | toPrettyJSON}}`\n\texpected := `{\n  \"bool\": true,\n  \"number\": 42,\n  \"string\": \"test\"\n}`\n\tif err := runtv(tpl, expected, dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToRawJSON(t *testing.T) {\n\tdict := map[string]any{\"Top\": map[string]any{\"bool\": true, \"string\": \"test\", \"number\": 42, \"html\": \"<HEAD>\"}}\n\ttpl := `{{.Top | toRawJSON}}`\n\texpected := `{\"bool\":true,\"html\":\"<HEAD>\",\"number\":42,\"string\":\"test\"}`\n\n\tif err := runtv(tpl, expected, dict); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestTernary(t *testing.T) {\n\ttpl := `{{true | ternary \"foo\" \"bar\"}}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ternary \"foo\" \"bar\" true}}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{false | ternary \"foo\" \"bar\"}}`\n\tif err := runt(tpl, \"bar\"); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ternary \"foo\" \"bar\" false}}`\n\tif err := runt(tpl, \"bar\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n"
  },
  {
    "path": "util/sprig/dict.go",
    "content": "package sprig\n\n// get retrieves a value from a map by its key.\n// If the key exists, returns the corresponding value.\n// If the key doesn't exist, returns an empty string.\n//\n// Parameters:\n//   - d: The map to retrieve the value from\n//   - key: The key to look up\n//\n// Returns:\n//   - any: The value associated with the key, or an empty string if not found\nfunc get(d map[string]any, key string) any {\n\tif val, ok := d[key]; ok {\n\t\treturn val\n\t}\n\treturn \"\"\n}\n\n// set adds or updates a key-value pair in a map.\n// Modifies the map in place and returns the modified map.\n//\n// Parameters:\n//   - d: The map to modify\n//   - key: The key to set\n//   - value: The value to associate with the key\n//\n// Returns:\n//   - map[string]any: The modified map (same instance as the input map)\nfunc set(d map[string]any, key string, value any) map[string]any {\n\td[key] = value\n\treturn d\n}\n\n// unset removes a key-value pair from a map.\n// If the key doesn't exist, the map remains unchanged.\n// Modifies the map in place and returns the modified map.\n//\n// Parameters:\n//   - d: The map to modify\n//   - key: The key to remove\n//\n// Returns:\n//   - map[string]any: The modified map (same instance as the input map)\nfunc unset(d map[string]any, key string) map[string]any {\n\tdelete(d, key)\n\treturn d\n}\n\n// hasKey checks if a key exists in a map.\n//\n// Parameters:\n//   - d: The map to check\n//   - key: The key to look for\n//\n// Returns:\n//   - bool: True if the key exists in the map, false otherwise\nfunc hasKey(d map[string]any, key string) bool {\n\t_, ok := d[key]\n\treturn ok\n}\n\n// pluck extracts values for a specific key from multiple maps.\n// Only includes values from maps where the key exists.\n//\n// Parameters:\n//   - key: The key to extract values for\n//   - d: A variadic list of maps to extract values from\n//\n// Returns:\n//   - []any: A slice containing all values associated with the key across all maps\nfunc pluck(key string, d ...map[string]any) []any {\n\tvar res []any\n\tfor _, dict := range d {\n\t\tif val, ok := dict[key]; ok {\n\t\t\tres = append(res, val)\n\t\t}\n\t}\n\treturn res\n}\n\n// keys collects all keys from one or more maps.\n// The returned slice may contain duplicate keys if multiple maps contain the same key.\n//\n// Parameters:\n//   - dicts: A variadic list of maps to collect keys from\n//\n// Returns:\n//   - []string: A slice containing all keys from all provided maps\nfunc keys(dicts ...map[string]any) []string {\n\tvar k []string\n\tfor _, dict := range dicts {\n\t\tfor key := range dict {\n\t\t\tk = append(k, key)\n\t\t}\n\t}\n\treturn k\n}\n\n// pick creates a new map containing only the specified keys from the original map.\n// If a key doesn't exist in the original map, it won't be included in the result.\n//\n// Parameters:\n//   - dict: The source map\n//   - keys: A variadic list of keys to include in the result\n//\n// Returns:\n//   - map[string]any: A new map containing only the specified keys and their values\nfunc pick(dict map[string]any, keys ...string) map[string]any {\n\tres := map[string]any{}\n\tfor _, k := range keys {\n\t\tif v, ok := dict[k]; ok {\n\t\t\tres[k] = v\n\t\t}\n\t}\n\treturn res\n}\n\n// omit creates a new map excluding the specified keys from the original map.\n// The original map remains unchanged.\n//\n// Parameters:\n//   - dict: The source map\n//   - keys: A variadic list of keys to exclude from the result\n//\n// Returns:\n//   - map[string]any: A new map containing all key-value pairs except those specified\nfunc omit(dict map[string]any, keys ...string) map[string]any {\n\tres := map[string]any{}\n\tomit := make(map[string]bool, len(keys))\n\tfor _, k := range keys {\n\t\tomit[k] = true\n\t}\n\tfor k, v := range dict {\n\t\tif _, ok := omit[k]; !ok {\n\t\t\tres[k] = v\n\t\t}\n\t}\n\treturn res\n}\n\n// dict creates a new map from a list of key-value pairs.\n// The arguments are treated as key-value pairs, where even-indexed arguments are keys\n// and odd-indexed arguments are values.\n// If there's an odd number of arguments, the last key will be assigned an empty string value.\n//\n// Parameters:\n//   - v: A variadic list of alternating keys and values\n//\n// Returns:\n//   - map[string]any: A new map containing the specified key-value pairs\nfunc dict(v ...any) map[string]any {\n\tdict := map[string]any{}\n\tlenv := len(v)\n\tfor i := 0; i < lenv; i += 2 {\n\t\tkey := strval(v[i])\n\t\tif i+1 >= lenv {\n\t\t\tdict[key] = \"\"\n\t\t\tcontinue\n\t\t}\n\t\tdict[key] = v[i+1]\n\t}\n\treturn dict\n}\n\n// values collects all values from a map into a slice.\n// The order of values in the resulting slice is not guaranteed.\n//\n// Parameters:\n//   - dict: The map to collect values from\n//\n// Returns:\n//   - []any: A slice containing all values from the map\nfunc values(dict map[string]any) []any {\n\tvar values []any\n\tfor _, value := range dict {\n\t\tvalues = append(values, value)\n\t}\n\treturn values\n}\n\n// dig safely accesses nested values in maps using a sequence of keys.\n// If any key in the path doesn't exist, it returns the default value.\n// The function expects at least 3 arguments: one or more keys, a default value, and a map.\n//\n// Parameters:\n//   - ps: A variadic list where:\n//   - The first N-2 arguments are string keys forming the path\n//   - The second-to-last argument is the default value to return if the path doesn't exist\n//   - The last argument is the map to traverse\n//\n// Returns:\n//   - any: The value found at the specified path, or the default value if not found\n//   - error: Any error that occurred during traversal\n//\n// Panics:\n//   - If fewer than 3 arguments are provided\nfunc dig(ps ...any) (any, error) {\n\tif len(ps) < 3 {\n\t\tpanic(\"dig needs at least three arguments\")\n\t}\n\tdict := ps[len(ps)-1].(map[string]any)\n\tdef := ps[len(ps)-2]\n\tks := make([]string, len(ps)-2)\n\tfor i := 0; i < len(ks); i++ {\n\t\tks[i] = ps[i].(string)\n\t}\n\n\treturn digFromDict(dict, def, ks)\n}\n\n// digFromDict is a helper function for dig that recursively traverses a map using a sequence of keys.\n// If any key in the path doesn't exist, it returns the default value.\n//\n// Parameters:\n//   - dict: The map to traverse\n//   - d: The default value to return if the path doesn't exist\n//   - ks: A slice of string keys forming the path to traverse\n//\n// Returns:\n//   - any: The value found at the specified path, or the default value if not found\n//   - error: Any error that occurred during traversal\nfunc digFromDict(dict map[string]any, d any, ks []string) (any, error) {\n\tk, ns := ks[0], ks[1:]\n\tstep, has := dict[k]\n\tif !has {\n\t\treturn d, nil\n\t}\n\tif len(ns) == 0 {\n\t\treturn step, nil\n\t}\n\treturn digFromDict(step.(map[string]any), d, ns)\n}\n"
  },
  {
    "path": "util/sprig/dict_test.go",
    "content": "package sprig\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestDict(t *testing.T) {\n\ttpl := `{{$d := dict 1 2 \"three\" \"four\" 5}}{{range $k, $v := $d}}{{$k}}{{$v}}{{end}}`\n\tout, err := runRaw(tpl, nil)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(out) != 12 {\n\t\tt.Errorf(\"Expected length 12, got %d\", len(out))\n\t}\n\t// dict does not guarantee ordering because it is backed by a map.\n\tif !strings.Contains(out, \"12\") {\n\t\tt.Error(\"Expected grouping 12\")\n\t}\n\tif !strings.Contains(out, \"threefour\") {\n\t\tt.Error(\"Expected grouping threefour\")\n\t}\n\tif !strings.Contains(out, \"5\") {\n\t\tt.Error(\"Expected 5\")\n\t}\n\ttpl = `{{$t := dict \"I\" \"shot\" \"the\" \"albatross\"}}{{$t.the}} {{$t.I}}`\n\tif err := runt(tpl, \"albatross shot\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestUnset(t *testing.T) {\n\ttpl := `{{- $d := dict \"one\" 1 \"two\" 222222 -}}\n\t{{- $_ := unset $d \"two\" -}}\n\t{{- range $k, $v := $d}}{{$k}}{{$v}}{{- end -}}\n\t`\n\n\texpect := \"one1\"\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestHasKey(t *testing.T) {\n\ttpl := `{{- $d := dict \"one\" 1 \"two\" 222222 -}}\n\t{{- if hasKey $d \"one\" -}}1{{- end -}}\n\t`\n\n\texpect := \"1\"\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestPluck(t *testing.T) {\n\ttpl := `\n\t{{- $d := dict \"one\" 1 \"two\" 222222 -}}\n\t{{- $d2 := dict \"one\" 1 \"two\" 33333 -}}\n\t{{- $d3 := dict \"one\" 1 -}}\n\t{{- $d4 := dict \"one\" 1 \"two\" 4444 -}}\n\t{{- pluck \"two\" $d $d2 $d3 $d4 -}}\n\t`\n\n\texpect := \"[222222 33333 4444]\"\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestKeys(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ dict \"foo\" 1 \"bar\" 2 | keys | sortAlpha }}`: \"[bar foo]\",\n\t\t`{{ dict | keys }}`:                             \"[]\",\n\t\t`{{ keys (dict \"foo\" 1) (dict \"bar\" 2) (dict \"bar\" 3) | uniq | sortAlpha }}`: \"[bar foo]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestPick(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ pick $d \"two\" | len -}}`:               \"1\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ pick $d \"two\" -}}`:                     \"map[two:222222]\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ pick $d \"one\" \"two\" | len -}}`:         \"2\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ pick $d \"one\" \"two\" \"three\" | len -}}`: \"2\",\n\t\t`{{- $d := dict }}{{ pick $d \"two\" | len -}}`:                                    \"0\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\nfunc TestOmit(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ omit $d \"one\" | len -}}`:         \"1\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ omit $d \"one\" -}}`:               \"map[two:222222]\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ omit $d \"one\" \"two\" | len -}}`:   \"0\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" 222222 }}{{ omit $d \"two\" \"three\" | len -}}`: \"1\",\n\t\t`{{- $d := dict }}{{ omit $d \"two\" | len -}}`:                              \"0\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestGet(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{- $d := dict \"one\" 1 }}{{ get $d \"one\" -}}`:           \"1\",\n\t\t`{{- $d := dict \"one\" 1 \"two\" \"2\" }}{{ get $d \"two\" -}}`: \"2\",\n\t\t`{{- $d := dict }}{{ get $d \"two\" -}}`:                   \"\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestSet(t *testing.T) {\n\ttpl := `{{- $d := dict \"one\" 1 \"two\" 222222 -}}\n\t{{- $_ := set $d \"two\" 2 -}}\n\t{{- $_ := set $d \"three\" 3 -}}\n\t{{- if hasKey $d \"one\" -}}{{$d.one}}{{- end -}}\n\t{{- if hasKey $d \"two\" -}}{{$d.two}}{{- end -}}\n\t{{- if hasKey $d \"three\" -}}{{$d.three}}{{- end -}}\n\t`\n\n\texpect := \"123\"\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestValues(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{- $d := dict \"a\" 1 \"b\" 2 }}{{ values $d | sortAlpha | join \",\" }}`:       \"1,2\",\n\t\t`{{- $d := dict \"a\" \"first\" \"b\" 2 }}{{ values $d | sortAlpha | join \",\" }}`: \"2,first\",\n\t}\n\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestDig(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{- $d := dict \"a\" (dict \"b\" (dict \"c\" 1)) }}{{ dig \"a\" \"b\" \"c\" \"\" $d }}`:  \"1\",\n\t\t`{{- $d := dict \"a\" (dict \"b\" (dict \"c\" 1)) }}{{ dig \"a\" \"b\" \"z\" \"2\" $d }}`: \"2\",\n\t\t`{{ dict \"a\" 1 | dig \"a\" \"\" }}`:                                             \"1\",\n\t\t`{{ dict \"a\" 1 | dig \"z\" \"2\" }}`:                                            \"2\",\n\t}\n\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/sprig/doc.go",
    "content": "/*\nPackage sprig provides template functions for Go.\n\nThis package contains a number of utility functions for working with data\ninside of Go `html/template` and `text/template` files.\n\nTo add these functions, use the `template.Funcs()` method:\n\n\tt := template.New(\"foo\").Funcs(sprig.FuncMap())\n\nNote that you should add the function map before you parse any template files.\n\n\tIn several cases, Sprig reverses the order of arguments from the way they\n\tappear in the standard library. This is to make it easier to pipe\n\targuments into functions.\n\nSee http://masterminds.github.io/sprig/ for more detailed documentation on each of the available functions.\n*/\npackage sprig\n"
  },
  {
    "path": "util/sprig/example_test.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"text/template\"\n)\n\nfunc Example() {\n\t// Set up variables and template.\n\tvars := map[string]any{\"Name\": \"  John Jacob Jingleheimer Schmidt \"}\n\ttpl := `Hello {{.Name | trim | lower}}`\n\n\t// Get the Sprig function map.\n\tfmap := TxtFuncMap()\n\tt := template.Must(template.New(\"test\").Funcs(fmap).Parse(tpl))\n\n\terr := t.Execute(os.Stdout, vars)\n\tif err != nil {\n\t\tfmt.Printf(\"Error during template execution: %s\", err)\n\t\treturn\n\t}\n\t// Output:\n\t// Hello john jacob jingleheimer schmidt\n}\n"
  },
  {
    "path": "util/sprig/flow_control.go",
    "content": "package sprig\n\nimport \"errors\"\n\n// fail is a function that always returns an error with the given message.\nfunc fail(msg string) (string, error) {\n\treturn \"\", errors.New(msg)\n}\n"
  },
  {
    "path": "util/sprig/flow_control_test.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFail(t *testing.T) {\n\tconst msg = \"This is an error!\"\n\ttpl := fmt.Sprintf(`{{fail \"%s\"}}`, msg)\n\t_, err := runRaw(tpl, nil)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), msg)\n}\n"
  },
  {
    "path": "util/sprig/functions.go",
    "content": "package sprig\n\nimport (\n\t\"path\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n)\n\nconst (\n\tloopExecutionLimit = 10_000  // Limit the number of loop executions to prevent execution from taking too long\n\tstringLengthLimit  = 100_000 // Limit the length of strings to prevent memory issues\n\tsliceSizeLimit     = 10_000  // Limit the size of slices to prevent memory issues\n)\n\n// TxtFuncMap produces the function map.\n//\n// Use this to pass the functions into the template engine:\n//\n//\ttpl := template.New(\"foo\").Funcs(sprig.FuncMap()))\n//\n// TxtFuncMap returns a 'text/template'.FuncMap\nfunc TxtFuncMap() template.FuncMap {\n\treturn map[string]any{\n\t\t// Date functions\n\t\t\"ago\":            dateAgo,\n\t\t\"date\":           date,\n\t\t\"dateInZone\":     dateInZone,\n\t\t\"dateModify\":     dateModify,\n\t\t\"duration\":       duration,\n\t\t\"durationRound\":  durationRound,\n\t\t\"htmlDate\":       htmlDate,\n\t\t\"htmlDateInZone\": htmlDateInZone,\n\t\t\"mustDateModify\": mustDateModify,\n\t\t\"mustToDate\":     mustToDate,\n\t\t\"now\":            time.Now,\n\t\t\"toDate\":         toDate,\n\t\t\"unixEpoch\":      unixEpoch,\n\n\t\t// Strings\n\t\t\"trunc\":      trunc,\n\t\t\"trim\":       strings.TrimSpace,\n\t\t\"upper\":      strings.ToUpper,\n\t\t\"lower\":      strings.ToLower,\n\t\t\"title\":      title,\n\t\t\"substr\":     substring,\n\t\t\"repeat\":     repeat,\n\t\t\"trimAll\":    trimAll,\n\t\t\"trimPrefix\": trimPrefix,\n\t\t\"trimSuffix\": trimSuffix,\n\t\t\"contains\":   contains,\n\t\t\"hasPrefix\":  hasPrefix,\n\t\t\"hasSuffix\":  hasSuffix,\n\t\t\"quote\":      quote,\n\t\t\"squote\":     squote,\n\t\t\"cat\":        cat,\n\t\t\"indent\":     indent,\n\t\t\"nindent\":    nindent,\n\t\t\"replace\":    replace,\n\t\t\"plural\":     plural,\n\t\t\"sha1sum\":    sha1sum,\n\t\t\"sha256sum\":  sha256sum,\n\t\t\"sha512sum\":  sha512sum,\n\t\t\"adler32sum\": adler32sum,\n\t\t\"toString\":   strval,\n\n\t\t// Wrap Atoi to stop errors.\n\t\t\"atoi\":      atoi,\n\t\t\"seq\":       seq,\n\t\t\"toDecimal\": toDecimal,\n\t\t\"split\":     split,\n\t\t\"splitList\": splitList,\n\t\t\"splitn\":    splitn,\n\t\t\"toStrings\": strslice,\n\n\t\t\"until\":     until,\n\t\t\"untilStep\": untilStep,\n\n\t\t// Basic arithmetic\n\t\t\"add1\":    add1,\n\t\t\"add\":     add,\n\t\t\"sub\":     sub,\n\t\t\"div\":     div,\n\t\t\"mod\":     mod,\n\t\t\"mul\":     mul,\n\t\t\"randInt\": randInt,\n\t\t\"biggest\": maxAsInt64,\n\t\t\"max\":     maxAsInt64,\n\t\t\"min\":     minAsInt64,\n\t\t\"maxf\":    maxAsFloat64,\n\t\t\"minf\":    minAsFloat64,\n\t\t\"ceil\":    ceil,\n\t\t\"floor\":   floor,\n\t\t\"round\":   round,\n\n\t\t// string slices. Note that we reverse the order b/c that's better\n\t\t// for template processing.\n\t\t\"join\":      join,\n\t\t\"sortAlpha\": sortAlpha,\n\n\t\t// Defaults\n\t\t\"default\":          defaultValue,\n\t\t\"empty\":            empty,\n\t\t\"coalesce\":         coalesce,\n\t\t\"all\":              all,\n\t\t\"any\":              anyNonEmpty,\n\t\t\"compact\":          compact,\n\t\t\"mustCompact\":      mustCompact,\n\t\t\"fromJSON\":         fromJSON,\n\t\t\"toJSON\":           toJSON,\n\t\t\"toPrettyJSON\":     toPrettyJSON,\n\t\t\"toRawJSON\":        toRawJSON,\n\t\t\"mustFromJSON\":     mustFromJSON,\n\t\t\"mustToJSON\":       mustToJSON,\n\t\t\"mustToPrettyJSON\": mustToPrettyJSON,\n\t\t\"mustToRawJSON\":    mustToRawJSON,\n\t\t\"ternary\":          ternary,\n\n\t\t// Reflection\n\t\t\"typeOf\":     typeOf,\n\t\t\"typeIs\":     typeIs,\n\t\t\"typeIsLike\": typeIsLike,\n\t\t\"kindOf\":     kindOf,\n\t\t\"kindIs\":     kindIs,\n\t\t\"deepEqual\":  reflect.DeepEqual,\n\n\t\t// Paths\n\t\t\"base\":  path.Base,\n\t\t\"dir\":   path.Dir,\n\t\t\"clean\": path.Clean,\n\t\t\"ext\":   path.Ext,\n\t\t\"isAbs\": path.IsAbs,\n\n\t\t// Filepaths\n\t\t\"osBase\":  filepath.Base,\n\t\t\"osClean\": filepath.Clean,\n\t\t\"osDir\":   filepath.Dir,\n\t\t\"osExt\":   filepath.Ext,\n\t\t\"osIsAbs\": filepath.IsAbs,\n\n\t\t// Encoding\n\t\t\"b64enc\": base64encode,\n\t\t\"b64dec\": base64decode,\n\t\t\"b32enc\": base32encode,\n\t\t\"b32dec\": base32decode,\n\n\t\t// Data Structures\n\t\t\"tuple\":  list, // FIXME: with the addition of append/prepend these are no longer immutable.\n\t\t\"list\":   list,\n\t\t\"dict\":   dict,\n\t\t\"get\":    get,\n\t\t\"set\":    set,\n\t\t\"unset\":  unset,\n\t\t\"hasKey\": hasKey,\n\t\t\"pluck\":  pluck,\n\t\t\"keys\":   keys,\n\t\t\"pick\":   pick,\n\t\t\"omit\":   omit,\n\t\t\"values\": values,\n\n\t\t\"append\":      push,\n\t\t\"push\":        push,\n\t\t\"mustAppend\":  mustPush,\n\t\t\"mustPush\":    mustPush,\n\t\t\"prepend\":     prepend,\n\t\t\"mustPrepend\": mustPrepend,\n\t\t\"first\":       first,\n\t\t\"mustFirst\":   mustFirst,\n\t\t\"rest\":        rest,\n\t\t\"mustRest\":    mustRest,\n\t\t\"last\":        last,\n\t\t\"mustLast\":    mustLast,\n\t\t\"initial\":     initial,\n\t\t\"mustInitial\": mustInitial,\n\t\t\"reverse\":     reverse,\n\t\t\"mustReverse\": mustReverse,\n\t\t\"uniq\":        uniq,\n\t\t\"mustUniq\":    mustUniq,\n\t\t\"without\":     without,\n\t\t\"mustWithout\": mustWithout,\n\t\t\"has\":         has,\n\t\t\"mustHas\":     mustHas,\n\t\t\"slice\":       slice,\n\t\t\"mustSlice\":   mustSlice,\n\t\t\"concat\":      concat,\n\t\t\"dig\":         dig,\n\t\t\"chunk\":       chunk,\n\t\t\"mustChunk\":   mustChunk,\n\n\t\t// Flow Control\n\t\t\"fail\": fail,\n\n\t\t// Regex\n\t\t\"regexMatch\":                 regexMatch,\n\t\t\"mustRegexMatch\":             mustRegexMatch,\n\t\t\"regexFindAll\":               regexFindAll,\n\t\t\"mustRegexFindAll\":           mustRegexFindAll,\n\t\t\"regexFind\":                  regexFind,\n\t\t\"mustRegexFind\":              mustRegexFind,\n\t\t\"regexReplaceAll\":            regexReplaceAll,\n\t\t\"mustRegexReplaceAll\":        mustRegexReplaceAll,\n\t\t\"regexReplaceAllLiteral\":     regexReplaceAllLiteral,\n\t\t\"mustRegexReplaceAllLiteral\": mustRegexReplaceAllLiteral,\n\t\t\"regexSplit\":                 regexSplit,\n\t\t\"mustRegexSplit\":             mustRegexSplit,\n\t\t\"regexQuoteMeta\":             regexQuoteMeta,\n\n\t\t// URLs\n\t\t\"urlParse\": urlParse,\n\t\t\"urlJoin\":  urlJoin,\n\t}\n}\n"
  },
  {
    "path": "util/sprig/functions_linux_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestOsBase(t *testing.T) {\n\tassert.NoError(t, runt(`{{ osBase \"foo/bar\" }}`, \"bar\"))\n}\n\nfunc TestOsDir(t *testing.T) {\n\tassert.NoError(t, runt(`{{ osDir \"foo/bar/baz\" }}`, \"foo/bar\"))\n}\n\nfunc TestOsIsAbs(t *testing.T) {\n\tassert.NoError(t, runt(`{{ osIsAbs \"/foo\" }}`, \"true\"))\n\tassert.NoError(t, runt(`{{ osIsAbs \"foo\" }}`, \"false\"))\n}\n\nfunc TestOsClean(t *testing.T) {\n\tassert.NoError(t, runt(`{{ osClean \"/foo/../foo/../bar\" }}`, \"/bar\"))\n}\n\nfunc TestOsExt(t *testing.T) {\n\tassert.NoError(t, runt(`{{ osExt \"/foo/bar/baz.txt\" }}`, \".txt\"))\n}\n"
  },
  {
    "path": "util/sprig/functions_test.go",
    "content": "package sprig\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"testing\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestBase(t *testing.T) {\n\tassert.NoError(t, runt(`{{ base \"foo/bar\" }}`, \"bar\"))\n}\n\nfunc TestDir(t *testing.T) {\n\tassert.NoError(t, runt(`{{ dir \"foo/bar/baz\" }}`, \"foo/bar\"))\n}\n\nfunc TestIsAbs(t *testing.T) {\n\tassert.NoError(t, runt(`{{ isAbs \"/foo\" }}`, \"true\"))\n\tassert.NoError(t, runt(`{{ isAbs \"foo\" }}`, \"false\"))\n}\n\nfunc TestClean(t *testing.T) {\n\tassert.NoError(t, runt(`{{ clean \"/foo/../foo/../bar\" }}`, \"/bar\"))\n}\n\nfunc TestExt(t *testing.T) {\n\tassert.NoError(t, runt(`{{ ext \"/foo/bar/baz.txt\" }}`, \".txt\"))\n}\n\nfunc TestRegex(t *testing.T) {\n\tassert.NoError(t, runt(`{{ regexQuoteMeta \"1.2.3\" }}`, \"1\\\\.2\\\\.3\"))\n\tassert.NoError(t, runt(`{{ regexQuoteMeta \"pretzel\" }}`, \"pretzel\"))\n}\n\n// runt runs a template and checks that the output exactly matches the expected string.\nfunc runt(tpl, expect string) error {\n\treturn runtv(tpl, expect, map[string]string{})\n}\n\n// runtv takes a template, and expected return, and values for substitution.\n//\n// It runs the template and verifies that the output is an exact match.\nfunc runtv(tpl, expect string, vars any) error {\n\tfmap := TxtFuncMap()\n\tt := template.Must(template.New(\"test\").Funcs(fmap).Parse(tpl))\n\tvar b bytes.Buffer\n\terr := t.Execute(&b, vars)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif expect != b.String() {\n\t\treturn fmt.Errorf(\"expected '%s', got '%s'\", expect, b.String())\n\t}\n\treturn nil\n}\n\n// runRaw runs a template with the given variables and returns the result.\nfunc runRaw(tpl string, vars any) (string, error) {\n\tfmap := TxtFuncMap()\n\tt := template.Must(template.New(\"test\").Funcs(fmap).Parse(tpl))\n\tvar b bytes.Buffer\n\terr := t.Execute(&b, vars)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn b.String(), nil\n}\n"
  },
  {
    "path": "util/sprig/list.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"reflect\"\n\t\"sort\"\n)\n\n// Reflection is used in these functions so that slices and arrays of strings,\n// ints, and other types not implementing []any can be worked with.\n// For example, this is useful if you need to work on the output of regexs.\n\n// list creates a new list (slice) containing the provided arguments.\n// It accepts any number of arguments of any type and returns them as a slice.\nfunc list(v ...any) []any {\n\treturn v\n}\n\n// push appends an element to the end of a list (slice or array).\n// It takes a list and a value, and returns a new list with the value appended.\n// This function will panic if the first argument is not a slice or array.\nfunc push(list any, v any) []any {\n\tl, err := mustPush(list, v)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustPush is the implementation of push that returns an error instead of panicking.\n// It converts the input list to a slice of any type, then appends the value.\nfunc mustPush(list any, v any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tnl := make([]any, l)\n\t\tfor i := 0; i < l; i++ {\n\t\t\tnl[i] = l2.Index(i).Interface()\n\t\t}\n\t\treturn append(nl, v), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot push on type %s\", tp)\n\t}\n}\n\n// prepend adds an element to the beginning of a list (slice or array).\n// It takes a list and a value, and returns a new list with the value at the start.\n// This function will panic if the first argument is not a slice or array.\nfunc prepend(list any, v any) []any {\n\tl, err := mustPrepend(list, v)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustPrepend is the implementation of prepend that returns an error instead of panicking.\n// It converts the input list to a slice of any type, then prepends the value.\nfunc mustPrepend(list any, v any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tnl := make([]any, l)\n\t\tfor i := 0; i < l; i++ {\n\t\t\tnl[i] = l2.Index(i).Interface()\n\t\t}\n\t\treturn append([]any{v}, nl...), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot prepend on type %s\", tp)\n\t}\n}\n\n// chunk divides a list into sub-lists of the specified size.\n// It takes a size and a list, and returns a list of lists, each containing\n// up to 'size' elements from the original list.\n// This function will panic if the second argument is not a slice or array.\nfunc chunk(size int, list any) [][]any {\n\tl, err := mustChunk(size, list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustChunk is the implementation of chunk that returns an error instead of panicking.\n// It divides the input list into chunks of the specified size.\nfunc mustChunk(size int, list any) ([][]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tnumChunks := int(math.Floor(float64(l-1)/float64(size)) + 1)\n\t\tif numChunks > sliceSizeLimit {\n\t\t\treturn nil, fmt.Errorf(\"number of chunks %d exceeds maximum limit of %d\", numChunks, sliceSizeLimit)\n\t\t}\n\t\tresult := make([][]any, numChunks)\n\t\tfor i := 0; i < numChunks; i++ {\n\t\t\tclen := size\n\t\t\t// Handle the last chunk which might be smaller\n\t\t\tif i == numChunks-1 {\n\t\t\t\tclen = int(math.Floor(math.Mod(float64(l), float64(size))))\n\t\t\t\tif clen == 0 {\n\t\t\t\t\tclen = size\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult[i] = make([]any, clen)\n\t\t\tfor j := 0; j < clen; j++ {\n\t\t\t\tix := i*size + j\n\t\t\t\tresult[i][j] = l2.Index(ix).Interface()\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot chunk type %s\", tp)\n\t}\n}\n\n// last returns the last element of a list (slice or array).\n// If the list is empty, it returns nil.\n// This function will panic if the argument is not a slice or array.\nfunc last(list any) any {\n\tl, err := mustLast(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn l\n}\n\n// mustLast is the implementation of last that returns an error instead of panicking.\n// It returns the last element of the list or nil if the list is empty.\nfunc mustLast(list any) (any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\n\t\tl := l2.Len()\n\t\tif l == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\treturn l2.Index(l - 1).Interface(), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find last on type %s\", tp)\n\t}\n}\n\n// first returns the first element of a list (slice or array).\n// If the list is empty, it returns nil.\n// This function will panic if the argument is not a slice or array.\nfunc first(list any) any {\n\tl, err := mustFirst(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn l\n}\n\n// mustFirst is the implementation of first that returns an error instead of panicking.\n// It returns the first element of the list or nil if the list is empty.\nfunc mustFirst(list any) (any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\n\t\tl := l2.Len()\n\t\tif l == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\treturn l2.Index(0).Interface(), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find first on type %s\", tp)\n\t}\n}\n\n// rest returns all elements of a list except the first one.\n// If the list is empty, it returns nil.\n// This function will panic if the argument is not a slice or array.\nfunc rest(list any) []any {\n\tl, err := mustRest(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn l\n}\n\n// mustRest is the implementation of rest that returns an error instead of panicking.\n// It returns all elements of the list except the first one, or nil if the list is empty.\nfunc mustRest(list any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tif l == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\t\tnl := make([]any, l-1)\n\t\tfor i := 1; i < l; i++ {\n\t\t\tnl[i-1] = l2.Index(i).Interface()\n\t\t}\n\t\treturn nl, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find rest on type %s\", tp)\n\t}\n}\n\n// initial returns all elements of a list except the last one.\n// If the list is empty, it returns nil.\n// This function will panic if the argument is not a slice or array.\nfunc initial(list any) []any {\n\tl, err := mustInitial(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn l\n}\n\n// mustInitial is the implementation of initial that returns an error instead of panicking.\n// It returns all elements of the list except the last one, or nil if the list is empty.\nfunc mustInitial(list any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tif l == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\t\tnl := make([]any, l-1)\n\t\tfor i := 0; i < l-1; i++ {\n\t\t\tnl[i] = l2.Index(i).Interface()\n\t\t}\n\t\treturn nl, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find initial on type %s\", tp)\n\t}\n}\n\n// sortAlpha sorts a list of strings alphabetically.\n// If the input is not a slice or array, it returns a single-element slice\n// containing the string representation of the input.\nfunc sortAlpha(list any) []string {\n\tk := reflect.Indirect(reflect.ValueOf(list)).Kind()\n\tswitch k {\n\tcase reflect.Slice, reflect.Array:\n\t\ta := strslice(list)\n\t\ts := sort.StringSlice(a)\n\t\ts.Sort()\n\t\treturn s\n\t}\n\treturn []string{strval(list)}\n}\n\n// reverse returns a new list with the elements in reverse order.\n// This function will panic if the argument is not a slice or array.\nfunc reverse(v any) []any {\n\tl, err := mustReverse(v)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn l\n}\n\n// mustReverse is the implementation of reverse that returns an error instead of panicking.\n// It returns a new list with the elements in reverse order.\nfunc mustReverse(v any) ([]any, error) {\n\ttp := reflect.TypeOf(v).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(v)\n\t\tl := l2.Len()\n\t\t// We do not sort in place because the incoming array should not be altered.\n\t\tnl := make([]any, l)\n\t\tfor i := 0; i < l; i++ {\n\t\t\tnl[l-i-1] = l2.Index(i).Interface()\n\t\t}\n\t\treturn nl, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find reverse on type %s\", tp)\n\t}\n}\n\n// compact returns a new list with all \"empty\" elements removed.\n// An element is considered empty if it's nil, zero, an empty string, or an empty collection.\n// This function will panic if the argument is not a slice or array.\nfunc compact(list any) []any {\n\tl, err := mustCompact(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustCompact is the implementation of compact that returns an error instead of panicking.\n// It returns a new list with all \"empty\" elements removed.\nfunc mustCompact(list any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tvar nl []any\n\t\tvar item any\n\t\tfor i := 0; i < l; i++ {\n\t\t\titem = l2.Index(i).Interface()\n\t\t\tif !empty(item) {\n\t\t\t\tnl = append(nl, item)\n\t\t\t}\n\t\t}\n\t\treturn nl, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot compact on type %s\", tp)\n\t}\n}\n\n// uniq returns a new list with duplicate elements removed.\n// The first occurrence of each element is kept.\n// This function will panic if the argument is not a slice or array.\nfunc uniq(list any) []any {\n\tl, err := mustUniq(list)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustUniq is the implementation of uniq that returns an error instead of panicking.\n// It returns a new list with duplicate elements removed.\nfunc mustUniq(list any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tvar dest []any\n\t\tvar item any\n\t\tfor i := 0; i < l; i++ {\n\t\t\titem = l2.Index(i).Interface()\n\t\t\tif !inList(dest, item) {\n\t\t\t\tdest = append(dest, item)\n\t\t\t}\n\t\t}\n\t\treturn dest, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find uniq on type %s\", tp)\n\t}\n}\n\n// inList checks if a value is present in a list.\n// It uses deep equality comparison to check for matches.\n// Returns true if the value is found, false otherwise.\nfunc inList(haystack []any, needle any) bool {\n\tfor _, h := range haystack {\n\t\tif reflect.DeepEqual(needle, h) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// without returns a new list with all occurrences of the specified values removed.\n// This function will panic if the first argument is not a slice or array.\nfunc without(list any, omit ...any) []any {\n\tl, err := mustWithout(list, omit...)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustWithout is the implementation of without that returns an error instead of panicking.\n// It returns a new list with all occurrences of the specified values removed.\nfunc mustWithout(list any, omit ...any) ([]any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tres := []any{}\n\t\tvar item any\n\t\tfor i := 0; i < l; i++ {\n\t\t\titem = l2.Index(i).Interface()\n\t\t\tif !inList(omit, item) {\n\t\t\t\tres = append(res, item)\n\t\t\t}\n\t\t}\n\t\treturn res, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"cannot find without on type %s\", tp)\n\t}\n}\n\n// has checks if a value is present in a list.\n// Returns true if the value is found, false otherwise.\n// This function will panic if the second argument is not a slice or array.\nfunc has(needle any, haystack any) bool {\n\tl, err := mustHas(needle, haystack)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustHas is the implementation of has that returns an error instead of panicking.\n// It checks if a value is present in a list.\nfunc mustHas(needle any, haystack any) (bool, error) {\n\tif haystack == nil {\n\t\treturn false, nil\n\t}\n\ttp := reflect.TypeOf(haystack).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(haystack)\n\t\tvar item any\n\t\tl := l2.Len()\n\t\tfor i := 0; i < l; i++ {\n\t\t\titem = l2.Index(i).Interface()\n\t\t\tif reflect.DeepEqual(needle, item) {\n\t\t\t\treturn true, nil\n\t\t\t}\n\t\t}\n\t\treturn false, nil\n\tdefault:\n\t\treturn false, fmt.Errorf(\"cannot find has on type %s\", tp)\n\t}\n}\n\n// slice extracts a portion of a list based on the provided indices.\n// Usage examples:\n// $list := [1, 2, 3, 4, 5]\n// slice $list     -> list[0:5] = list[:]\n// slice $list 0 3 -> list[0:3] = list[:3]\n// slice $list 3 5 -> list[3:5]\n// slice $list 3   -> list[3:5] = list[3:]\n//\n// This function will panic if the first argument is not a slice or array.\nfunc slice(list any, indices ...any) any {\n\tl, err := mustSlice(list, indices...)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// mustSlice is the implementation of slice that returns an error instead of panicking.\n// It extracts a portion of a list based on the provided indices.\nfunc mustSlice(list any, indices ...any) (any, error) {\n\ttp := reflect.TypeOf(list).Kind()\n\tswitch tp {\n\tcase reflect.Slice, reflect.Array:\n\t\tl2 := reflect.ValueOf(list)\n\t\tl := l2.Len()\n\t\tif l == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\t\t// Determine start and end indices\n\t\tvar start, end int\n\t\tif len(indices) > 0 {\n\t\t\tstart = toInt(indices[0])\n\t\t}\n\t\tif len(indices) < 2 {\n\t\t\tend = l\n\t\t} else {\n\t\t\tend = toInt(indices[1])\n\t\t}\n\t\treturn l2.Slice(start, end).Interface(), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"list should be type of slice or array but %s\", tp)\n\t}\n}\n\n// concat combines multiple lists into a single list.\n// It takes any number of lists and returns a new list containing all elements.\n// This function will panic if any argument is not a slice or array.\nfunc concat(lists ...any) any {\n\tvar res []any\n\tfor _, list := range lists {\n\t\ttp := reflect.TypeOf(list).Kind()\n\t\tswitch tp {\n\t\tcase reflect.Slice, reflect.Array:\n\t\t\tl2 := reflect.ValueOf(list)\n\t\t\tfor i := 0; i < l2.Len(); i++ {\n\t\t\t\tres = append(res, l2.Index(i).Interface())\n\t\t\t}\n\t\tdefault:\n\t\t\tpanic(fmt.Sprintf(\"cannot concat type %s as list\", tp))\n\t\t}\n\t}\n\treturn res\n}\n"
  },
  {
    "path": "util/sprig/list_test.go",
    "content": "package sprig\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTuple(t *testing.T) {\n\ttpl := `{{$t := tuple 1 \"a\" \"foo\"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`\n\tif err := runt(tpl, \"foo1a\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestList(t *testing.T) {\n\ttpl := `{{$t := list 1 \"a\" \"foo\"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`\n\tif err := runt(tpl, \"foo1a\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestPush(t *testing.T) {\n\t// Named `append` in the function map\n\ttests := map[string]string{\n\t\t`{{ $t := tuple 1 2 3  }}{{ append $t 4 | len }}`:                             \"4\",\n\t\t`{{ $t := tuple 1 2 3 4  }}{{ append $t 5 | join \"-\" }}`:                      \"1-2-3-4-5\",\n\t\t`{{ $t := regexSplit \"/\" \"foo/bar/baz\" -1 }}{{ append $t \"qux\" | join \"-\" }}`: \"foo-bar-baz-qux\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustPush(t *testing.T) {\n\t// Named `append` in the function map\n\ttests := map[string]string{\n\t\t`{{ $t := tuple 1 2 3  }}{{ mustAppend $t 4 | len }}`:                           \"4\",\n\t\t`{{ $t := tuple 1 2 3 4  }}{{ mustAppend $t 5 | join \"-\" }}`:                    \"1-2-3-4-5\",\n\t\t`{{ $t := regexSplit \"/\" \"foo/bar/baz\" -1 }}{{ mustPush $t \"qux\" | join \"-\" }}`: \"foo-bar-baz-qux\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestChunk(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`:                                 \"3\",\n\t\t`{{ tuple | chunk 3 | len }}`:                                               \"0\",\n\t\t`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join \"-\"}}|{{end}}`: \"1-2-3|4-5-6|7-8-9|\",\n\t\t`{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join \"-\"}}|{{end}}`:   \"1-2-3|4-5-6|7-8|\",\n\t\t`{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join \"-\"}}|{{end}}`:               \"1-2|\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustChunk(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`:                                 \"3\",\n\t\t`{{ tuple | mustChunk 3 | len }}`:                                               \"0\",\n\t\t`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join \"-\"}}|{{end}}`: \"1-2-3|4-5-6|7-8-9|\",\n\t\t`{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join \"-\"}}|{{end}}`:   \"1-2-3|4-5-6|7-8|\",\n\t\t`{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join \"-\"}}|{{end}}`:               \"1-2|\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n\terr := runt(`{{ tuple `+strings.Repeat(\" 0\", 10001)+` | mustChunk 1 }}`, \"a\")\n\tassert.ErrorContains(t, err, \"number of chunks 10001 exceeds maximum limit of 10000\")\n}\n\nfunc TestPrepend(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ $t := tuple 1 2 3  }}{{ prepend $t 0 | len }}`:                             \"4\",\n\t\t`{{ $t := tuple 1 2 3 4  }}{{ prepend $t 0 | join \"-\" }}`:                      \"0-1-2-3-4\",\n\t\t`{{ $t := regexSplit \"/\" \"foo/bar/baz\" -1 }}{{ prepend $t \"qux\" | join \"-\" }}`: \"qux-foo-bar-baz\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustPrepend(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ $t := tuple 1 2 3  }}{{ mustPrepend $t 0 | len }}`:                             \"4\",\n\t\t`{{ $t := tuple 1 2 3 4  }}{{ mustPrepend $t 0 | join \"-\" }}`:                      \"0-1-2-3-4\",\n\t\t`{{ $t := regexSplit \"/\" \"foo/bar/baz\" -1 }}{{ mustPrepend $t \"qux\" | join \"-\" }}`: \"qux-foo-bar-baz\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestFirst(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | first }}`:                          \"1\",\n\t\t`{{ list | first }}`:                                \"<no value>\",\n\t\t`{{ regexSplit \"/src/\" \"foo/src/bar\" -1 | first }}`: \"foo\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustFirst(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustFirst }}`:                          \"1\",\n\t\t`{{ list | mustFirst }}`:                                \"<no value>\",\n\t\t`{{ regexSplit \"/src/\" \"foo/src/bar\" -1 | mustFirst }}`: \"foo\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestLast(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | last }}`:                          \"3\",\n\t\t`{{ list | last }}`:                                \"<no value>\",\n\t\t`{{ regexSplit \"/src/\" \"foo/src/bar\" -1 | last }}`: \"bar\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustLast(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustLast }}`:                          \"3\",\n\t\t`{{ list | mustLast }}`:                                \"<no value>\",\n\t\t`{{ regexSplit \"/src/\" \"foo/src/bar\" -1 | mustLast }}`: \"bar\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestInitial(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | initial | len }}`:                \"2\",\n\t\t`{{ list 1 2 3 | initial | last }}`:               \"2\",\n\t\t`{{ list 1 2 3 | initial | first }}`:              \"1\",\n\t\t`{{ list | initial }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | initial }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustInitial(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustInitial | len }}`:                \"2\",\n\t\t`{{ list 1 2 3 | mustInitial | last }}`:               \"2\",\n\t\t`{{ list 1 2 3 | mustInitial | first }}`:              \"1\",\n\t\t`{{ list | mustInitial }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | mustInitial }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestRest(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | rest | len }}`:                \"2\",\n\t\t`{{ list 1 2 3 | rest | last }}`:               \"3\",\n\t\t`{{ list 1 2 3 | rest | first }}`:              \"2\",\n\t\t`{{ list | rest }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | rest }}`: \"[bar baz]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustRest(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustRest | len }}`:                \"2\",\n\t\t`{{ list 1 2 3 | mustRest | last }}`:               \"3\",\n\t\t`{{ list 1 2 3 | mustRest | first }}`:              \"2\",\n\t\t`{{ list | mustRest }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | mustRest }}`: \"[bar baz]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestReverse(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | reverse | first }}`:              \"3\",\n\t\t`{{ list 1 2 3 | reverse | rest | first }}`:       \"2\",\n\t\t`{{ list 1 2 3 | reverse | last }}`:               \"1\",\n\t\t`{{ list 1 2 3 4 | reverse }}`:                    \"[4 3 2 1]\",\n\t\t`{{ list 1 | reverse }}`:                          \"[1]\",\n\t\t`{{ list | reverse }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | reverse }}`: \"[baz bar foo]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustReverse(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustReverse | first }}`:              \"3\",\n\t\t`{{ list 1 2 3 | mustReverse | rest | first }}`:       \"2\",\n\t\t`{{ list 1 2 3 | mustReverse | last }}`:               \"1\",\n\t\t`{{ list 1 2 3 4 | mustReverse }}`:                    \"[4 3 2 1]\",\n\t\t`{{ list 1 | mustReverse }}`:                          \"[1]\",\n\t\t`{{ list | mustReverse }}`:                            \"[]\",\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | mustReverse }}`: \"[baz bar foo]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestCompact(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 0 \"\" \"hello\" | compact }}`:          `[1 hello]`,\n\t\t`{{ list \"\" \"\" | compact }}`:                   `[]`,\n\t\t`{{ list | compact }}`:                         `[]`,\n\t\t`{{ regexSplit \"/\" \"foo//bar\" -1 | compact }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustCompact(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 0 \"\" \"hello\" | mustCompact }}`:          `[1 hello]`,\n\t\t`{{ list \"\" \"\" | mustCompact }}`:                   `[]`,\n\t\t`{{ list | mustCompact }}`:                         `[]`,\n\t\t`{{ regexSplit \"/\" \"foo//bar\" -1 | mustCompact }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestUniq(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 4 | uniq }}`:                    `[1 2 3 4]`,\n\t\t`{{ list \"a\" \"b\" \"c\" \"d\" | uniq }}`:            `[a b c d]`,\n\t\t`{{ list 1 1 1 1 2 2 2 2 | uniq }}`:            `[1 2]`,\n\t\t`{{ list \"foo\" 1 1 1 1 \"foo\" \"foo\" | uniq }}`:  `[foo 1]`,\n\t\t`{{ list | uniq }}`:                            `[]`,\n\t\t`{{ regexSplit \"/\" \"foo/foo/bar\" -1 | uniq }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustUniq(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 4 | mustUniq }}`:                    `[1 2 3 4]`,\n\t\t`{{ list \"a\" \"b\" \"c\" \"d\" | mustUniq }}`:            `[a b c d]`,\n\t\t`{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`:            `[1 2]`,\n\t\t`{{ list \"foo\" 1 1 1 1 \"foo\" \"foo\" | mustUniq }}`:  `[foo 1]`,\n\t\t`{{ list | mustUniq }}`:                            `[]`,\n\t\t`{{ regexSplit \"/\" \"foo/foo/bar\" -1 | mustUniq }}`: \"[foo bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestWithout(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ without (list 1 2 3 4) 1 }}`:                         `[2 3 4]`,\n\t\t`{{ without (list \"a\" \"b\" \"c\" \"d\") \"a\" }}`:               `[b c d]`,\n\t\t`{{ without (list 1 1 1 1 2) 1 }}`:                       `[2]`,\n\t\t`{{ without (list) 1 }}`:                                 `[]`,\n\t\t`{{ without (list 1 2 3) }}`:                             `[1 2 3]`,\n\t\t`{{ without list }}`:                                     `[]`,\n\t\t`{{ without (regexSplit \"/\" \"foo/bar/baz\" -1 ) \"foo\" }}`: \"[bar baz]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustWithout(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ mustWithout (list 1 2 3 4) 1 }}`:                         `[2 3 4]`,\n\t\t`{{ mustWithout (list \"a\" \"b\" \"c\" \"d\") \"a\" }}`:               `[b c d]`,\n\t\t`{{ mustWithout (list 1 1 1 1 2) 1 }}`:                       `[2]`,\n\t\t`{{ mustWithout (list) 1 }}`:                                 `[]`,\n\t\t`{{ mustWithout (list 1 2 3) }}`:                             `[1 2 3]`,\n\t\t`{{ mustWithout list }}`:                                     `[]`,\n\t\t`{{ mustWithout (regexSplit \"/\" \"foo/bar/baz\" -1 ) \"foo\" }}`: \"[bar baz]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestHas(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | has 1 }}`:                          `true`,\n\t\t`{{ list 1 2 3 | has 4 }}`:                          `false`,\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | has \"bar\" }}`: `true`,\n\t\t`{{ has \"bar\" nil }}`:                               `false`,\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustHas(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ list 1 2 3 | mustHas 1 }}`:                          `true`,\n\t\t`{{ list 1 2 3 | mustHas 4 }}`:                          `false`,\n\t\t`{{ regexSplit \"/\" \"foo/bar/baz\" -1 | mustHas \"bar\" }}`: `true`,\n\t\t`{{ mustHas \"bar\" nil }}`:                               `false`,\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestSlice(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ slice (list 1 2 3) }}`:                          \"[1 2 3]\",\n\t\t`{{ slice (list 1 2 3) 0 1 }}`:                      \"[1]\",\n\t\t`{{ slice (list 1 2 3) 1 3 }}`:                      \"[2 3]\",\n\t\t`{{ slice (list 1 2 3) 1 }}`:                        \"[2 3]\",\n\t\t`{{ slice (regexSplit \"/\" \"foo/bar/baz\" -1) 1 2 }}`: \"[bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestMustSlice(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ mustSlice (list 1 2 3) }}`:                          \"[1 2 3]\",\n\t\t`{{ mustSlice (list 1 2 3) 0 1 }}`:                      \"[1]\",\n\t\t`{{ mustSlice (list 1 2 3) 1 3 }}`:                      \"[2 3]\",\n\t\t`{{ mustSlice (list 1 2 3) 1 }}`:                        \"[2 3]\",\n\t\t`{{ mustSlice (regexSplit \"/\" \"foo/bar/baz\" -1) 1 2 }}`: \"[bar]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n\nfunc TestConcat(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ concat (list 1 2 3) }}`:                                   \"[1 2 3]\",\n\t\t`{{ concat (list 1 2 3) (list 4 5) }}`:                        \"[1 2 3 4 5]\",\n\t\t`{{ concat (list 1 2 3) (list 4 5) (list) }}`:                 \"[1 2 3 4 5]\",\n\t\t`{{ concat (list 1 2 3) (list 4 5) (list nil) }}`:             \"[1 2 3 4 5 <nil>]\",\n\t\t`{{ concat (list 1 2 3) (list 4 5) (list ( list \"foo\" ) ) }}`: \"[1 2 3 4 5 [foo]]\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\n"
  },
  {
    "path": "util/sprig/numeric.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"math/rand\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// toFloat64 converts a value to a 64-bit float.\n// It handles various input types:\n// - string: parsed as a float, returns 0 if parsing fails\n// - integer types: converted to float64\n// - unsigned integer types: converted to float64\n// - float types: returned as is\n// - bool: true becomes 1.0, false becomes 0.0\n// - other types: returns 0.0\n//\n// Parameters:\n//   - v: The value to convert to float64\n//\n// Returns:\n//   - float64: The converted value\nfunc toFloat64(v any) float64 {\n\tif str, ok := v.(string); ok {\n\t\tiv, err := strconv.ParseFloat(str, 64)\n\t\tif err != nil {\n\t\t\treturn 0\n\t\t}\n\t\treturn iv\n\t}\n\n\tval := reflect.Indirect(reflect.ValueOf(v))\n\tswitch val.Kind() {\n\tcase reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:\n\t\treturn float64(val.Int())\n\tcase reflect.Uint8, reflect.Uint16, reflect.Uint32:\n\t\treturn float64(val.Uint())\n\tcase reflect.Uint, reflect.Uint64:\n\t\treturn float64(val.Uint())\n\tcase reflect.Float32, reflect.Float64:\n\t\treturn val.Float()\n\tcase reflect.Bool:\n\t\tif val.Bool() {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\tdefault:\n\t\treturn 0\n\t}\n}\n\n// toInt converts a value to a 32-bit integer.\n// This is a wrapper around toInt64 that casts the result to int.\n//\n// Parameters:\n//   - v: The value to convert to int\n//\n// Returns:\n//   - int: The converted value\nfunc toInt(v any) int {\n\t// It's not optimal. But I don't want duplicate toInt64 code.\n\treturn int(toInt64(v))\n}\n\n// toInt64 converts a value to a 64-bit integer.\n// It handles various input types:\n// - string: parsed as an integer, returns 0 if parsing fails\n// - integer types: converted to int64\n// - unsigned integer types: converted to int64 (values > MaxInt64 become MaxInt64)\n// - float types: truncated to int64\n// - bool: true becomes 1, false becomes 0\n// - other types: returns 0\nfunc toInt64(v any) int64 {\n\tif str, ok := v.(string); ok {\n\t\tiv, err := strconv.ParseInt(str, 10, 64)\n\t\tif err != nil {\n\t\t\treturn 0\n\t\t}\n\t\treturn iv\n\t}\n\tval := reflect.Indirect(reflect.ValueOf(v))\n\tswitch val.Kind() {\n\tcase reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:\n\t\treturn val.Int()\n\tcase reflect.Uint8, reflect.Uint16, reflect.Uint32:\n\t\treturn int64(val.Uint())\n\tcase reflect.Uint, reflect.Uint64:\n\t\ttv := val.Uint()\n\t\tif tv <= math.MaxInt64 {\n\t\t\treturn int64(tv)\n\t\t}\n\t\t// TODO: What is the sensible thing to do here?\n\t\treturn math.MaxInt64\n\tcase reflect.Float32, reflect.Float64:\n\t\treturn int64(val.Float())\n\tcase reflect.Bool:\n\t\tif val.Bool() {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\tdefault:\n\t\treturn 0\n\t}\n}\n\n// add1 increments a value by 1.\n// The input is first converted to int64 using toInt64.\n//\n// Parameters:\n//   - i: The value to increment\n//\n// Returns:\n//   - int64: The incremented value\nfunc add1(i any) int64 {\n\treturn toInt64(i) + 1\n}\n\n// add sums all the provided values.\n// All inputs are converted to int64 using toInt64 before addition.\n//\n// Parameters:\n//   - i: A variadic list of values to sum\n//\n// Returns:\n//   - int64: The sum of all values\nfunc add(i ...any) int64 {\n\tvar a int64\n\tfor _, b := range i {\n\t\ta += toInt64(b)\n\t}\n\treturn a\n}\n\n// sub subtracts the second value from the first.\n// Both inputs are converted to int64 using toInt64 before subtraction.\n//\n// Parameters:\n//   - a: The value to subtract from\n//   - b: The value to subtract\n//\n// Returns:\n//   - int64: The result of a - b\nfunc sub(a, b any) int64 {\n\treturn toInt64(a) - toInt64(b)\n}\n\n// div divides the first value by the second.\n// Both inputs are converted to int64 using toInt64 before division.\n// Note: This performs integer division, so the result is truncated.\n//\n// Parameters:\n//   - a: The dividend\n//   - b: The divisor\n//\n// Returns:\n//   - int64: The result of a / b\n//\n// Panics:\n//   - If b evaluates to 0 (division by zero)\nfunc div(a, b any) int64 {\n\treturn toInt64(a) / toInt64(b)\n}\n\n// mod returns the remainder of dividing the first value by the second.\n// Both inputs are converted to int64 using toInt64 before the modulo operation.\n//\n// Parameters:\n//   - a: The dividend\n//   - b: The divisor\n//\n// Returns:\n//   - int64: The remainder of a / b\n//\n// Panics:\n//   - If b evaluates to 0 (modulo by zero)\nfunc mod(a, b any) int64 {\n\treturn toInt64(a) % toInt64(b)\n}\n\n// mul multiplies all the provided values.\n// All inputs are converted to int64 using toInt64 before multiplication.\n//\n// Parameters:\n//   - a: The first value to multiply\n//   - v: Additional values to multiply with a\n//\n// Returns:\n//   - int64: The product of all values\nfunc mul(a any, v ...any) int64 {\n\tval := toInt64(a)\n\tfor _, b := range v {\n\t\tval = val * toInt64(b)\n\t}\n\treturn val\n}\n\n// randInt generates a random integer between min (inclusive) and max (exclusive).\n//\n// Parameters:\n//   - min: The lower bound (inclusive)\n//   - max: The upper bound (exclusive)\n//\n// Returns:\n//   - int: A random integer in the range [min, max)\n//\n// Panics:\n//   - If max <= min (via rand.Intn)\nfunc randInt(min, max int) int {\n\treturn rand.Intn(max-min) + min\n}\n\n// maxAsInt64 returns the maximum value from a list of values as an int64.\n// All inputs are converted to int64 using toInt64 before comparison.\n//\n// Parameters:\n//   - a: The first value to compare\n//   - i: Additional values to compare\n//\n// Returns:\n//   - int64: The maximum value from all inputs\nfunc maxAsInt64(a any, i ...any) int64 {\n\taa := toInt64(a)\n\tfor _, b := range i {\n\t\tbb := toInt64(b)\n\t\tif bb > aa {\n\t\t\taa = bb\n\t\t}\n\t}\n\treturn aa\n}\n\n// maxAsFloat64 returns the maximum value from a list of values as a float64.\n// All inputs are converted to float64 using toFloat64 before comparison.\n//\n// Parameters:\n//   - a: The first value to compare\n//   - i: Additional values to compare\n//\n// Returns:\n//   - float64: The maximum value from all inputs\nfunc maxAsFloat64(a any, i ...any) float64 {\n\tm := toFloat64(a)\n\tfor _, b := range i {\n\t\tm = math.Max(m, toFloat64(b))\n\t}\n\treturn m\n}\n\n// minAsInt64 returns the minimum value from a list of values as an int64.\n// All inputs are converted to int64 using toInt64 before comparison.\n//\n// Parameters:\n//   - a: The first value to compare\n//   - i: Additional values to compare\n//\n// Returns:\n//   - int64: The minimum value from all inputs\nfunc minAsInt64(a any, i ...any) int64 {\n\taa := toInt64(a)\n\tfor _, b := range i {\n\t\tbb := toInt64(b)\n\t\tif bb < aa {\n\t\t\taa = bb\n\t\t}\n\t}\n\treturn aa\n}\n\n// minAsFloat64 returns the minimum value from a list of values as a float64.\n// All inputs are converted to float64 using toFloat64 before comparison.\n//\n// Parameters:\n//   - a: The first value to compare\n//   - i: Additional values to compare\n//\n// Returns:\n//   - float64: The minimum value from all inputs\nfunc minAsFloat64(a any, i ...any) float64 {\n\tm := toFloat64(a)\n\tfor _, b := range i {\n\t\tm = math.Min(m, toFloat64(b))\n\t}\n\treturn m\n}\n\n// until generates a sequence of integers from 0 to count (exclusive).\n// If count is negative, it generates a sequence from 0 to count (inclusive) with step -1.\n//\n// Parameters:\n//   - count: The end value (exclusive if positive, inclusive if negative)\n//\n// Returns:\n//   - []int: A slice containing the generated sequence\nfunc until(count int) []int {\n\tstep := 1\n\tif count < 0 {\n\t\tstep = -1\n\t}\n\treturn untilStep(0, count, step)\n}\n\n// untilStep generates a sequence of integers from start to stop with the specified step.\n// The sequence is generated as follows:\n// - If step is 0, returns an empty slice\n// - If stop < start and step < 0, generates a decreasing sequence from start to stop (exclusive)\n// - If stop > start and step > 0, generates an increasing sequence from start to stop (exclusive)\n// - Otherwise, returns an empty slice\n//\n// Parameters:\n//   - start: The starting value (inclusive)\n//   - stop: The ending value (exclusive)\n//   - step: The increment between values\n//\n// Returns:\n//   - []int: A slice containing the generated sequence\n//\n// Panics:\n//   - If the number of iterations would exceed loopExecutionLimit\nfunc untilStep(start, stop, step int) []int {\n\tvar v []int\n\tif step == 0 {\n\t\treturn v\n\t}\n\titerations := math.Abs(float64(stop)-float64(start)) / float64(step)\n\tif iterations > loopExecutionLimit {\n\t\tpanic(fmt.Sprintf(\"too many iterations in untilStep; max allowed is %d, got %f\", loopExecutionLimit, iterations))\n\t}\n\tif stop < start {\n\t\tif step >= 0 {\n\t\t\treturn v\n\t\t}\n\t\tfor i := start; i > stop; i += step {\n\t\t\tv = append(v, i)\n\t\t}\n\t\treturn v\n\t}\n\tif step <= 0 {\n\t\treturn v\n\t}\n\tfor i := start; i < stop; i += step {\n\t\tv = append(v, i)\n\t}\n\treturn v\n}\n\n// floor returns the greatest integer value less than or equal to the input.\n// The input is first converted to float64 using toFloat64.\n//\n// Parameters:\n//   - a: The value to floor\n//\n// Returns:\n//   - float64: The greatest integer value less than or equal to a\nfunc floor(a any) float64 {\n\treturn math.Floor(toFloat64(a))\n}\n\n// ceil returns the least integer value greater than or equal to the input.\n// The input is first converted to float64 using toFloat64.\n//\n// Parameters:\n//   - a: The value to ceil\n//\n// Returns:\n//   - float64: The least integer value greater than or equal to a\nfunc ceil(a any) float64 {\n\treturn math.Ceil(toFloat64(a))\n}\n\n// round rounds a number to a specified number of decimal places.\n// The input is first converted to float64 using toFloat64.\n//\n// Parameters:\n//   - a: The value to round\n//   - p: The number of decimal places to round to\n//   - rOpt: Optional rounding threshold (default is 0.5)\n//\n// Returns:\n//   - float64: The rounded value\n//\n// Examples:\n//   - round(3.14159, 2) returns 3.14\n//   - round(3.14159, 2, 0.6) returns 3.14 (only rounds up if fraction ≥ 0.6)\nfunc round(a any, p int, rOpt ...float64) float64 {\n\troundOn := .5\n\tif len(rOpt) > 0 {\n\t\troundOn = rOpt[0]\n\t}\n\tval := toFloat64(a)\n\tplaces := toFloat64(p)\n\tvar round float64\n\tpow := math.Pow(10, places)\n\tdigit := pow * val\n\t_, div := math.Modf(digit)\n\tif div >= roundOn {\n\t\tround = math.Ceil(digit)\n\t} else {\n\t\tround = math.Floor(digit)\n\t}\n\treturn round / pow\n}\n\n// toDecimal converts a value from octal to decimal.\n// The input is first converted to a string using fmt.Sprint, then parsed as an octal number.\n// If the parsing fails, it returns 0.\n//\n// Parameters:\n//   - v: The octal value to convert\n//\n// Returns:\n//   - int64: The decimal representation of the octal value\nfunc toDecimal(v any) int64 {\n\tresult, err := strconv.ParseInt(fmt.Sprint(v), 8, 64)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn result\n}\n\n// atoi converts a string to an integer.\n// If the conversion fails, it returns 0.\n//\n// Parameters:\n//   - a: The string to convert\n//\n// Returns:\n//   - int: The integer value of the string\nfunc atoi(a string) int {\n\ti, _ := strconv.Atoi(a)\n\treturn i\n}\n\n// seq generates a sequence of integers and returns them as a space-delimited string.\n// The behavior depends on the number of parameters:\n// - 0 params: Returns an empty string\n// - 1 param: Generates sequence from 1 to param[0]\n// - 2 params: Generates sequence from param[0] to param[1]\n// - 3 params: Generates sequence from param[0] to param[2] with step param[1]\n//\n// If the end is less than the start, the sequence will be decreasing unless\n// a positive step is explicitly provided (which would result in an empty string).\n//\n// Parameters:\n//   - params: Variable number of integers defining the sequence\n//\n// Returns:\n//   - string: A space-delimited string of the generated sequence\nfunc seq(params ...int) string {\n\tincrement := 1\n\tswitch len(params) {\n\tcase 0:\n\t\treturn \"\"\n\tcase 1:\n\t\tstart := 1\n\t\tend := params[0]\n\t\tif end < start {\n\t\t\tincrement = -1\n\t\t}\n\t\treturn intArrayToString(untilStep(start, end+increment, increment), \" \")\n\tcase 3:\n\t\tstart := params[0]\n\t\tend := params[2]\n\t\tstep := params[1]\n\t\tif end < start {\n\t\t\tincrement = -1\n\t\t\tif step > 0 {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t}\n\t\treturn intArrayToString(untilStep(start, end+increment, step), \" \")\n\tcase 2:\n\t\tstart := params[0]\n\t\tend := params[1]\n\t\tstep := 1\n\t\tif end < start {\n\t\t\tstep = -1\n\t\t}\n\t\treturn intArrayToString(untilStep(start, end+step, step), \" \")\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// intArrayToString converts a slice of integers to a space-delimited string.\n// The function removes the square brackets that would normally appear when\n// converting a slice to a string.\n//\n// Parameters:\n//   - slice: The slice of integers to convert\n//   - delimiter: The delimiter to use between elements\n//\n// Returns:\n//   - string: A delimited string representation of the integer slice\nfunc intArrayToString(slice []int, delimiter string) string {\n\treturn strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), \"[]\")\n}\n"
  },
  {
    "path": "util/sprig/numeric_test.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"strconv\"\n\t\"testing\"\n)\n\nfunc TestUntil(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`:   \"0011223344\",\n\t\t`{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: \"00 1-1 2-2 3-3 4-4 \",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\nfunc TestUntilStep(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`:     \"0011223344\",\n\t\t`{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`:     \"031425\",\n\t\t`{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: \"00 1-2 2-4 3-6 4-8 \",\n\t\t`{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`:     \"\",\n\t\t`{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`:    \"\",\n\t\t`{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`:   \"\",\n\t\t`{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`:     \"\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n\n}\nfunc TestBiggest(t *testing.T) {\n\ttpl := `{{ biggest 1 2 3 345 5 6 7}}`\n\tif err := runt(tpl, `345`); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ max 345}}`\n\tif err := runt(tpl, `345`); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestMaxf(t *testing.T) {\n\ttpl := `{{ maxf 1 2 3 345.7 5 6 7}}`\n\tif err := runt(tpl, `345.7`); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ max 345 }}`\n\tif err := runt(tpl, `345`); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestMin(t *testing.T) {\n\ttpl := `{{ min 1 2 3 345 5 6 7}}`\n\tif err := runt(tpl, `1`); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ min 345}}`\n\tif err := runt(tpl, `345`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestMinf(t *testing.T) {\n\ttpl := `{{ minf 1.4 2 3 345.6 5 6 7}}`\n\tif err := runt(tpl, `1.4`); err != nil {\n\t\tt.Error(err)\n\t}\n\n\ttpl = `{{ minf 345 }}`\n\tif err := runt(tpl, `345`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToFloat64(t *testing.T) {\n\ttarget := float64(102)\n\tif target != toFloat64(int8(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(int(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(int32(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(int16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(int64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(\"102\") {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toFloat64(\"frankie\") != 0 {\n\t\tt.Errorf(\"Expected 0\")\n\t}\n\tif target != toFloat64(uint16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toFloat64(uint64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toFloat64(float64(102.1234)) != 102.1234 {\n\t\tt.Errorf(\"Expected 102.1234\")\n\t}\n\tif toFloat64(true) != 1 {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n}\nfunc TestToInt64(t *testing.T) {\n\ttarget := int64(102)\n\tif target != toInt64(int8(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(int(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(int32(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(int16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(int64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(\"102\") {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toInt64(\"frankie\") != 0 {\n\t\tt.Errorf(\"Expected 0\")\n\t}\n\tif target != toInt64(uint16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(uint64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt64(float64(102.1234)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toInt64(true) != 1 {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n}\n\nfunc TestToInt(t *testing.T) {\n\ttarget := int(102)\n\tif target != toInt(int8(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(int(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(int32(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(int16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(int64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(\"102\") {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toInt(\"frankie\") != 0 {\n\t\tt.Errorf(\"Expected 0\")\n\t}\n\tif target != toInt(uint16(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(uint64(102)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif target != toInt(float64(102.1234)) {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n\tif toInt(true) != 1 {\n\t\tt.Errorf(\"Expected 102\")\n\t}\n}\n\nfunc TestToDecimal(t *testing.T) {\n\ttests := map[any]int64{\n\t\t\"777\": 511,\n\t\t777:   511,\n\t\t770:   504,\n\t\t755:   493,\n\t}\n\n\tfor input, expectedResult := range tests {\n\t\tresult := toDecimal(input)\n\t\tif result != expectedResult {\n\t\t\tt.Errorf(\"Expected %v but got %v\", expectedResult, result)\n\t\t}\n\t}\n}\n\nfunc TestAdd1(t *testing.T) {\n\ttpl := `{{ 3 | add1 }}`\n\tif err := runt(tpl, `4`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestAdd(t *testing.T) {\n\ttpl := `{{ 3 | add 1 2}}`\n\tif err := runt(tpl, `6`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestDiv(t *testing.T) {\n\ttpl := `{{ 4 | div 5 }}`\n\tif err := runt(tpl, `1`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestMul(t *testing.T) {\n\ttpl := `{{ 1 | mul \"2\" 3 \"4\"}}`\n\tif err := runt(tpl, `24`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestSub(t *testing.T) {\n\ttpl := `{{ 3 | sub 14 }}`\n\tif err := runt(tpl, `11`); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestCeil(t *testing.T) {\n\tassert.Equal(t, 123.0, ceil(123))\n\tassert.Equal(t, 123.0, ceil(\"123\"))\n\tassert.Equal(t, 124.0, ceil(123.01))\n\tassert.Equal(t, 124.0, ceil(\"123.01\"))\n}\n\nfunc TestFloor(t *testing.T) {\n\tassert.Equal(t, 123.0, floor(123))\n\tassert.Equal(t, 123.0, floor(\"123\"))\n\tassert.Equal(t, 123.0, floor(123.9999))\n\tassert.Equal(t, 123.0, floor(\"123.9999\"))\n}\n\nfunc TestRound(t *testing.T) {\n\tassert.Equal(t, 123.556, round(123.5555, 3))\n\tassert.Equal(t, 123.556, round(\"123.55555\", 3))\n\tassert.Equal(t, 124.0, round(123.500001, 0))\n\tassert.Equal(t, 123.0, round(123.49999999, 0))\n\tassert.Equal(t, 123.23, round(123.2329999, 2, .3))\n\tassert.Equal(t, 123.24, round(123.233, 2, .3))\n}\n\nfunc TestRandomInt(t *testing.T) {\n\tvar tests = []struct {\n\t\tmin int\n\t\tmax int\n\t}{\n\t\t{10, 11},\n\t\t{10, 13},\n\t\t{0, 1},\n\t\t{5, 50},\n\t}\n\tfor _, v := range tests {\n\t\tx, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil)\n\t\tr, err := strconv.Atoi(x)\n\t\tassert.NoError(t, err)\n\t\tassert.True(t, func(min, max, r int) bool {\n\t\t\treturn r >= v.min && r < v.max\n\t\t}(v.min, v.max, r))\n\t}\n}\n\nfunc TestSeq(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{seq 0 1 3}}`:   \"0 1 2 3\",\n\t\t`{{seq 0 3 10}}`:  \"0 3 6 9\",\n\t\t`{{seq 3 3 2}}`:   \"\",\n\t\t`{{seq 3 -3 2}}`:  \"3\",\n\t\t`{{seq}}`:         \"\",\n\t\t`{{seq 0 4}}`:     \"0 1 2 3 4\",\n\t\t`{{seq 5}}`:       \"1 2 3 4 5\",\n\t\t`{{seq -5}}`:      \"1 0 -1 -2 -3 -4 -5\",\n\t\t`{{seq 0}}`:       \"1 0\",\n\t\t`{{seq 0 1 2 3}}`: \"\",\n\t\t`{{seq 0 -4}}`:    \"0 -1 -2 -3 -4\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tif err := runt(tpl, expect); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/sprig/reflect.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n)\n\n// typeIs returns true if the src is the type named in target.\n// It compares the type name of src with the target string.\n//\n// Parameters:\n//   - target: The type name to check against\n//   - src: The value whose type will be checked\n//\n// Returns:\n//   - bool: True if the type name of src matches target, false otherwise\nfunc typeIs(target string, src any) bool {\n\treturn target == typeOf(src)\n}\n\n// typeIsLike returns true if the src is the type named in target or a pointer to that type.\n// This is useful when you need to check for both a type and a pointer to that type.\n//\n// Parameters:\n//   - target: The type name to check against\n//   - src: The value whose type will be checked\n//\n// Returns:\n//   - bool: True if the type of src matches target or \"*\"+target, false otherwise\nfunc typeIsLike(target string, src any) bool {\n\tt := typeOf(src)\n\treturn target == t || \"*\"+target == t\n}\n\n// typeOf returns the type of a value as a string.\n// It uses fmt.Sprintf with the %T format verb to get the type name.\n//\n// Parameters:\n//   - src: The value whose type name will be returned\n//\n// Returns:\n//   - string: The type name of src\nfunc typeOf(src any) string {\n\treturn fmt.Sprintf(\"%T\", src)\n}\n\n// kindIs returns true if the kind of src matches the target kind.\n// This checks the underlying kind (e.g., \"string\", \"int\", \"map\") rather than the specific type.\n//\n// Parameters:\n//   - target: The kind name to check against\n//   - src: The value whose kind will be checked\n//\n// Returns:\n//   - bool: True if the kind of src matches target, false otherwise\nfunc kindIs(target string, src any) bool {\n\treturn target == kindOf(src)\n}\n\n// kindOf returns the kind of a value as a string.\n// The kind represents the specific Go type category (e.g., \"string\", \"int\", \"map\", \"slice\").\n//\n// Parameters:\n//   - src: The value whose kind will be returned\n//\n// Returns:\n//   - string: The kind of src as a string\nfunc kindOf(src any) string {\n\treturn reflect.ValueOf(src).Kind().String()\n}\n"
  },
  {
    "path": "util/sprig/reflect_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n)\n\ntype fixtureTO struct {\n\tName, Value string\n}\n\nfunc TestTypeOf(t *testing.T) {\n\tf := &fixtureTO{\"hello\", \"world\"}\n\ttpl := `{{typeOf .}}`\n\tif err := runtv(tpl, \"*sprig.fixtureTO\", f); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestKindOf(t *testing.T) {\n\ttpl := `{{kindOf .}}`\n\n\tf := fixtureTO{\"hello\", \"world\"}\n\tif err := runtv(tpl, \"struct\", f); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tf2 := []string{\"hello\"}\n\tif err := runtv(tpl, \"slice\", f2); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tvar f3 *fixtureTO\n\tif err := runtv(tpl, \"ptr\", f3); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestTypeIs(t *testing.T) {\n\tf := &fixtureTO{\"hello\", \"world\"}\n\ttpl := `{{if typeIs \"*sprig.fixtureTO\" .}}t{{else}}f{{end}}`\n\tif err := runtv(tpl, \"t\", f); err != nil {\n\t\tt.Error(err)\n\t}\n\n\tf2 := \"hello\"\n\tif err := runtv(tpl, \"f\", f2); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestTypeIsLike(t *testing.T) {\n\tf := \"foo\"\n\ttpl := `{{if typeIsLike \"string\" .}}t{{else}}f{{end}}`\n\tif err := runtv(tpl, \"t\", f); err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Now make a pointer. Should still match.\n\tf2 := &f\n\tif err := runtv(tpl, \"t\", f2); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestKindIs(t *testing.T) {\n\tf := &fixtureTO{\"hello\", \"world\"}\n\ttpl := `{{if kindIs \"ptr\" .}}t{{else}}f{{end}}`\n\tif err := runtv(tpl, \"t\", f); err != nil {\n\t\tt.Error(err)\n\t}\n\tf2 := \"hello\"\n\tif err := runtv(tpl, \"f\", f2); err != nil {\n\t\tt.Error(err)\n\t}\n}\n"
  },
  {
    "path": "util/sprig/regex.go",
    "content": "package sprig\n\nimport (\n\t\"regexp\"\n)\n\n// regexMatch checks if a string matches a regular expression pattern.\n// It ignores any errors that might occur during regex compilation.\n//\n// Parameters:\n//   - regex: The regular expression pattern to match against\n//   - s: The string to check\n//\n// Returns:\n//   - bool: True if the string matches the pattern, false otherwise\nfunc regexMatch(regex string, s string) bool {\n\tmatch, _ := regexp.MatchString(regex, s)\n\treturn match\n}\n\n// mustRegexMatch checks if a string matches a regular expression pattern.\n// Unlike regexMatch, this function returns any errors that occur during regex compilation.\n//\n// Parameters:\n//   - regex: The regular expression pattern to match against\n//   - s: The string to check\n//\n// Returns:\n//   - bool: True if the string matches the pattern, false otherwise\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexMatch(regex string, s string) (bool, error) {\n\treturn regexp.MatchString(regex, s)\n}\n\n// regexFindAll finds all matches of a regular expression in a string.\n// It panics if the regex pattern cannot be compiled.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - n: The maximum number of matches to return (negative means all matches)\n//\n// Returns:\n//   - []string: A slice containing all matched substrings\nfunc regexFindAll(regex string, s string, n int) []string {\n\tr := regexp.MustCompile(regex)\n\treturn r.FindAllString(s, n)\n}\n\n// mustRegexFindAll finds all matches of a regular expression in a string.\n// Unlike regexFindAll, this function returns any errors that occur during regex compilation.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - n: The maximum number of matches to return (negative means all matches)\n//\n// Returns:\n//   - []string: A slice containing all matched substrings\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexFindAll(regex string, s string, n int) ([]string, error) {\n\tr, err := regexp.Compile(regex)\n\tif err != nil {\n\t\treturn []string{}, err\n\t}\n\treturn r.FindAllString(s, n), nil\n}\n\n// regexFind finds the first match of a regular expression in a string.\n// It panics if the regex pattern cannot be compiled.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//\n// Returns:\n//   - string: The first matched substring, or an empty string if no match\nfunc regexFind(regex string, s string) string {\n\tr := regexp.MustCompile(regex)\n\treturn r.FindString(s)\n}\n\n// mustRegexFind finds the first match of a regular expression in a string.\n// Unlike regexFind, this function returns any errors that occur during regex compilation.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//\n// Returns:\n//   - string: The first matched substring, or an empty string if no match\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexFind(regex string, s string) (string, error) {\n\tr, err := regexp.Compile(regex)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn r.FindString(s), nil\n}\n\n// regexReplaceAll replaces all matches of a regular expression with a replacement string.\n// It panics if the regex pattern cannot be compiled.\n// The replacement string can contain $1, $2, etc. for submatches.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - repl: The replacement string (can contain $1, $2, etc. for submatches)\n//\n// Returns:\n//   - string: The resulting string after all replacements\nfunc regexReplaceAll(regex string, s string, repl string) string {\n\tr := regexp.MustCompile(regex)\n\treturn r.ReplaceAllString(s, repl)\n}\n\n// mustRegexReplaceAll replaces all matches of a regular expression with a replacement string.\n// Unlike regexReplaceAll, this function returns any errors that occur during regex compilation.\n// The replacement string can contain $1, $2, etc. for submatches.\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - repl: The replacement string (can contain $1, $2, etc. for submatches)\n//\n// Returns:\n//   - string: The resulting string after all replacements\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexReplaceAll(regex string, s string, repl string) (string, error) {\n\tr, err := regexp.Compile(regex)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn r.ReplaceAllString(s, repl), nil\n}\n\n// regexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string.\n// It panics if the regex pattern cannot be compiled.\n// Unlike regexReplaceAll, the replacement string is used literally (no $1, $2 processing).\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - repl: The literal replacement string\n//\n// Returns:\n//   - string: The resulting string after all replacements\nfunc regexReplaceAllLiteral(regex string, s string, repl string) string {\n\tr := regexp.MustCompile(regex)\n\treturn r.ReplaceAllLiteralString(s, repl)\n}\n\n// mustRegexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string.\n// Unlike regexReplaceAllLiteral, this function returns any errors that occur during regex compilation.\n// The replacement string is used literally (no $1, $2 processing).\n//\n// Parameters:\n//   - regex: The regular expression pattern to search for\n//   - s: The string to search within\n//   - repl: The literal replacement string\n//\n// Returns:\n//   - string: The resulting string after all replacements\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) {\n\tr, err := regexp.Compile(regex)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn r.ReplaceAllLiteralString(s, repl), nil\n}\n\n// regexSplit splits a string by a regular expression pattern.\n// It panics if the regex pattern cannot be compiled.\n//\n// Parameters:\n//   - regex: The regular expression pattern to split on\n//   - s: The string to split\n//   - n: The maximum number of substrings to return (negative means all substrings)\n//\n// Returns:\n//   - []string: A slice containing the substrings between regex matches\nfunc regexSplit(regex string, s string, n int) []string {\n\tr := regexp.MustCompile(regex)\n\treturn r.Split(s, n)\n}\n\n// mustRegexSplit splits a string by a regular expression pattern.\n// Unlike regexSplit, this function returns any errors that occur during regex compilation.\n//\n// Parameters:\n//   - regex: The regular expression pattern to split on\n//   - s: The string to split\n//   - n: The maximum number of substrings to return (negative means all substrings)\n//\n// Returns:\n//   - []string: A slice containing the substrings between regex matches\n//   - error: Any error that occurred during regex compilation\nfunc mustRegexSplit(regex string, s string, n int) ([]string, error) {\n\tr, err := regexp.Compile(regex)\n\tif err != nil {\n\t\treturn []string{}, err\n\t}\n\treturn r.Split(s, n), nil\n}\n\n// regexQuoteMeta escapes all regular expression metacharacters in a string.\n// This is useful when you want to use a string as a literal in a regular expression.\n//\n// Parameters:\n//   - s: The string to escape\n//\n// Returns:\n//   - string: The escaped string with all regex metacharacters quoted\nfunc regexQuoteMeta(s string) string {\n\treturn regexp.QuoteMeta(s)\n}\n"
  },
  {
    "path": "util/sprig/regex_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestRegexMatch(t *testing.T) {\n\tregex := \"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\\\.[A-Za-z]{2,}\"\n\n\tassert.True(t, regexMatch(regex, \"test@acme.com\"))\n\tassert.True(t, regexMatch(regex, \"Test@Acme.Com\"))\n\tassert.False(t, regexMatch(regex, \"test\"))\n\tassert.False(t, regexMatch(regex, \"test.com\"))\n\tassert.False(t, regexMatch(regex, \"test@acme\"))\n}\n\nfunc TestMustRegexMatch(t *testing.T) {\n\tregex := \"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\\\.[A-Za-z]{2,}\"\n\n\to, err := mustRegexMatch(regex, \"test@acme.com\")\n\tassert.True(t, o)\n\tassert.Nil(t, err)\n\n\to, err = mustRegexMatch(regex, \"Test@Acme.Com\")\n\tassert.True(t, o)\n\tassert.Nil(t, err)\n\n\to, err = mustRegexMatch(regex, \"test\")\n\tassert.False(t, o)\n\tassert.Nil(t, err)\n\n\to, err = mustRegexMatch(regex, \"test.com\")\n\tassert.False(t, o)\n\tassert.Nil(t, err)\n\n\to, err = mustRegexMatch(regex, \"test@acme\")\n\tassert.False(t, o)\n\tassert.Nil(t, err)\n}\n\nfunc TestRegexFindAll(t *testing.T) {\n\tregex := \"a{2}\"\n\tassert.Equal(t, 1, len(regexFindAll(regex, \"aa\", -1)))\n\tassert.Equal(t, 1, len(regexFindAll(regex, \"aaaaaaaa\", 1)))\n\tassert.Equal(t, 2, len(regexFindAll(regex, \"aaaa\", -1)))\n\tassert.Equal(t, 0, len(regexFindAll(regex, \"none\", -1)))\n}\n\nfunc TestMustRegexFindAll(t *testing.T) {\n\ttype args struct {\n\t\tregex, s string\n\t\tn        int\n\t}\n\tcases := []struct {\n\t\texpected int\n\t\targs     args\n\t}{\n\t\t{1, args{\"a{2}\", \"aa\", -1}},\n\t\t{1, args{\"a{2}\", \"aaaaaaaa\", 1}},\n\t\t{2, args{\"a{2}\", \"aaaa\", -1}},\n\t\t{0, args{\"a{2}\", \"none\", -1}},\n\t}\n\n\tfor _, c := range cases {\n\t\tres, err := mustRegexFindAll(c.args.regex, c.args.s, c.args.n)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"regexFindAll test case %v failed with err %s\", c, err)\n\t\t}\n\t\tassert.Equal(t, c.expected, len(res), \"case %#v\", c.args)\n\t}\n}\n\nfunc TestRegexFindl(t *testing.T) {\n\tregex := \"fo.?\"\n\tassert.Equal(t, \"foo\", regexFind(regex, \"foorbar\"))\n\tassert.Equal(t, \"foo\", regexFind(regex, \"foo foe fome\"))\n\tassert.Equal(t, \"\", regexFind(regex, \"none\"))\n}\n\nfunc TestMustRegexFindl(t *testing.T) {\n\ttype args struct{ regex, s string }\n\tcases := []struct {\n\t\texpected string\n\t\targs     args\n\t}{\n\t\t{\"foo\", args{\"fo.?\", \"foorbar\"}},\n\t\t{\"foo\", args{\"fo.?\", \"foo foe fome\"}},\n\t\t{\"\", args{\"fo.?\", \"none\"}},\n\t}\n\n\tfor _, c := range cases {\n\t\tres, err := mustRegexFind(c.args.regex, c.args.s)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"regexFind test case %v failed with err %s\", c, err)\n\t\t}\n\t\tassert.Equal(t, c.expected, res, \"case %#v\", c.args)\n\t}\n}\n\nfunc TestRegexReplaceAll(t *testing.T) {\n\tregex := \"a(x*)b\"\n\tassert.Equal(t, \"-T-T-\", regexReplaceAll(regex, \"-ab-axxb-\", \"T\"))\n\tassert.Equal(t, \"--xx-\", regexReplaceAll(regex, \"-ab-axxb-\", \"$1\"))\n\tassert.Equal(t, \"---\", regexReplaceAll(regex, \"-ab-axxb-\", \"$1W\"))\n\tassert.Equal(t, \"-W-xxW-\", regexReplaceAll(regex, \"-ab-axxb-\", \"${1}W\"))\n}\n\nfunc TestMustRegexReplaceAll(t *testing.T) {\n\ttype args struct{ regex, s, repl string }\n\tcases := []struct {\n\t\texpected string\n\t\targs     args\n\t}{\n\t\t{\"-T-T-\", args{\"a(x*)b\", \"-ab-axxb-\", \"T\"}},\n\t\t{\"--xx-\", args{\"a(x*)b\", \"-ab-axxb-\", \"$1\"}},\n\t\t{\"---\", args{\"a(x*)b\", \"-ab-axxb-\", \"$1W\"}},\n\t\t{\"-W-xxW-\", args{\"a(x*)b\", \"-ab-axxb-\", \"${1}W\"}},\n\t}\n\n\tfor _, c := range cases {\n\t\tres, err := mustRegexReplaceAll(c.args.regex, c.args.s, c.args.repl)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"regexReplaceAll test case %v failed with err %s\", c, err)\n\t\t}\n\t\tassert.Equal(t, c.expected, res, \"case %#v\", c.args)\n\t}\n}\n\nfunc TestRegexReplaceAllLiteral(t *testing.T) {\n\tregex := \"a(x*)b\"\n\tassert.Equal(t, \"-T-T-\", regexReplaceAllLiteral(regex, \"-ab-axxb-\", \"T\"))\n\tassert.Equal(t, \"-$1-$1-\", regexReplaceAllLiteral(regex, \"-ab-axxb-\", \"$1\"))\n\tassert.Equal(t, \"-${1}-${1}-\", regexReplaceAllLiteral(regex, \"-ab-axxb-\", \"${1}\"))\n}\n\nfunc TestMustRegexReplaceAllLiteral(t *testing.T) {\n\ttype args struct{ regex, s, repl string }\n\tcases := []struct {\n\t\texpected string\n\t\targs     args\n\t}{\n\t\t{\"-T-T-\", args{\"a(x*)b\", \"-ab-axxb-\", \"T\"}},\n\t\t{\"-$1-$1-\", args{\"a(x*)b\", \"-ab-axxb-\", \"$1\"}},\n\t\t{\"-${1}-${1}-\", args{\"a(x*)b\", \"-ab-axxb-\", \"${1}\"}},\n\t}\n\n\tfor _, c := range cases {\n\t\tres, err := mustRegexReplaceAllLiteral(c.args.regex, c.args.s, c.args.repl)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"regexReplaceAllLiteral test case %v failed with err %s\", c, err)\n\t\t}\n\t\tassert.Equal(t, c.expected, res, \"case %#v\", c.args)\n\t}\n}\n\nfunc TestRegexSplit(t *testing.T) {\n\tregex := \"a\"\n\tassert.Equal(t, 4, len(regexSplit(regex, \"banana\", -1)))\n\tassert.Equal(t, 0, len(regexSplit(regex, \"banana\", 0)))\n\tassert.Equal(t, 1, len(regexSplit(regex, \"banana\", 1)))\n\tassert.Equal(t, 2, len(regexSplit(regex, \"banana\", 2)))\n\n\tregex = \"z+\"\n\tassert.Equal(t, 2, len(regexSplit(regex, \"pizza\", -1)))\n\tassert.Equal(t, 0, len(regexSplit(regex, \"pizza\", 0)))\n\tassert.Equal(t, 1, len(regexSplit(regex, \"pizza\", 1)))\n\tassert.Equal(t, 2, len(regexSplit(regex, \"pizza\", 2)))\n}\n\nfunc TestMustRegexSplit(t *testing.T) {\n\ttype args struct {\n\t\tregex, s string\n\t\tn        int\n\t}\n\tcases := []struct {\n\t\texpected int\n\t\targs     args\n\t}{\n\t\t{4, args{\"a\", \"banana\", -1}},\n\t\t{0, args{\"a\", \"banana\", 0}},\n\t\t{1, args{\"a\", \"banana\", 1}},\n\t\t{2, args{\"a\", \"banana\", 2}},\n\t\t{2, args{\"z+\", \"pizza\", -1}},\n\t\t{0, args{\"z+\", \"pizza\", 0}},\n\t\t{1, args{\"z+\", \"pizza\", 1}},\n\t\t{2, args{\"z+\", \"pizza\", 2}},\n\t}\n\n\tfor _, c := range cases {\n\t\tres, err := mustRegexSplit(c.args.regex, c.args.s, c.args.n)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"regexSplit test case %v failed with err %s\", c, err)\n\t\t}\n\t\tassert.Equal(t, c.expected, len(res), \"case %#v\", c.args)\n\t}\n}\n\nfunc TestRegexQuoteMeta(t *testing.T) {\n\tassert.Equal(t, \"1\\\\.2\\\\.3\", regexQuoteMeta(\"1.2.3\"))\n\tassert.Equal(t, \"pretzel\", regexQuoteMeta(\"pretzel\"))\n}\n"
  },
  {
    "path": "util/sprig/strings.go",
    "content": "package sprig\n\nimport (\n\t\"encoding/base32\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/language\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// base64encode encodes a string to base64 using standard encoding.\n//\n// Parameters:\n//   - v: The string to encode\n//\n// Returns:\n//   - string: The base64 encoded string\nfunc base64encode(v string) string {\n\treturn base64.StdEncoding.EncodeToString([]byte(v))\n}\n\n// base64decode decodes a base64 encoded string.\n// If the input is not valid base64, it returns the error message as a string.\n//\n// Parameters:\n//   - v: The base64 encoded string to decode\n//\n// Returns:\n//   - string: The decoded string, or an error message if decoding fails\nfunc base64decode(v string) string {\n\tdata, err := base64.StdEncoding.DecodeString(v)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn string(data)\n}\n\n// base32encode encodes a string to base32 using standard encoding.\n//\n// Parameters:\n//   - v: The string to encode\n//\n// Returns:\n//   - string: The base32 encoded string\nfunc base32encode(v string) string {\n\treturn base32.StdEncoding.EncodeToString([]byte(v))\n}\n\n// base32decode decodes a base32 encoded string.\n// If the input is not valid base32, it returns the error message as a string.\n//\n// Parameters:\n//   - v: The base32 encoded string to decode\n//\n// Returns:\n//   - string: The decoded string, or an error message if decoding fails\nfunc base32decode(v string) string {\n\tdata, err := base32.StdEncoding.DecodeString(v)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn string(data)\n}\n\n// quote adds double quotes around each non-nil string in the input and joins them with spaces.\n// This uses Go's %q formatter which handles escaping special characters.\n//\n// Parameters:\n//   - str: A variadic list of values to quote\n//\n// Returns:\n//   - string: The quoted strings joined with spaces\nfunc quote(str ...any) string {\n\tout := make([]string, 0, len(str))\n\tfor _, s := range str {\n\t\tif s != nil {\n\t\t\tout = append(out, fmt.Sprintf(\"%q\", strval(s)))\n\t\t}\n\t}\n\treturn strings.Join(out, \" \")\n}\n\n// squote adds single quotes around each non-nil value in the input and joins them with spaces.\n// Unlike quote, this doesn't escape special characters.\n//\n// Parameters:\n//   - str: A variadic list of values to quote\n//\n// Returns:\n//   - string: The single-quoted values joined with spaces\nfunc squote(str ...any) string {\n\tout := make([]string, 0, len(str))\n\tfor _, s := range str {\n\t\tif s != nil {\n\t\t\tout = append(out, fmt.Sprintf(\"'%v'\", s))\n\t\t}\n\t}\n\treturn strings.Join(out, \" \")\n}\n\n// cat concatenates all non-nil values into a single string.\n// Nil values are removed before concatenation.\n//\n// Parameters:\n//   - v: A variadic list of values to concatenate\n//\n// Returns:\n//   - string: The concatenated string\nfunc cat(v ...any) string {\n\tv = removeNilElements(v)\n\tr := strings.TrimSpace(strings.Repeat(\"%v \", len(v)))\n\treturn fmt.Sprintf(r, v...)\n}\n\n// indent adds a specified number of spaces at the beginning of each line in a string.\n//\n// Parameters:\n//   - spaces: The number of spaces to add\n//   - v: The string to indent\n//\n// Returns:\n//   - string: The indented string\nfunc indent(spaces int, v string) string {\n\tpad := strings.Repeat(\" \", spaces)\n\treturn pad + strings.Replace(v, \"\\n\", \"\\n\"+pad, -1)\n}\n\n// nindent adds a newline followed by an indented string.\n// It's a shorthand for \"\\n\" + indent(spaces, v).\n//\n// Parameters:\n//   - spaces: The number of spaces to add\n//   - v: The string to indent\n//\n// Returns:\n//   - string: A newline followed by the indented string\nfunc nindent(spaces int, v string) string {\n\treturn \"\\n\" + indent(spaces, v)\n}\n\n// replace replaces all occurrences of a substring with another substring.\n//\n// Parameters:\n//   - old: The substring to replace\n//   - new: The replacement substring\n//   - src: The source string\n//\n// Returns:\n//   - string: The resulting string after all replacements\nfunc replace(old, new, src string) string {\n\treturn strings.Replace(src, old, new, -1)\n}\n\n// plural returns the singular or plural form of a word based on the count.\n// If count is 1, it returns the singular form, otherwise it returns the plural form.\n//\n// Parameters:\n//   - one: The singular form of the word\n//   - many: The plural form of the word\n//   - count: The count to determine which form to use\n//\n// Returns:\n//   - string: Either the singular or plural form based on the count\nfunc plural(one, many string, count int) string {\n\tif count == 1 {\n\t\treturn one\n\t}\n\treturn many\n}\n\n// strslice converts a value to a slice of strings.\n// It handles various input types:\n// - []string: returned as is\n// - []any: converted to []string, skipping nil values\n// - arrays and slices: converted to []string, skipping nil values\n// - nil: returns an empty slice\n// - anything else: returns a single-element slice with the string representation\n//\n// Parameters:\n//   - v: The value to convert to a string slice\n//\n// Returns:\n//   - []string: A slice of strings\nfunc strslice(v any) []string {\n\tswitch v := v.(type) {\n\tcase []string:\n\t\treturn v\n\tcase []any:\n\t\tb := make([]string, 0, len(v))\n\t\tfor _, s := range v {\n\t\t\tif s != nil {\n\t\t\t\tb = append(b, strval(s))\n\t\t\t}\n\t\t}\n\t\treturn b\n\tdefault:\n\t\tval := reflect.ValueOf(v)\n\t\tswitch val.Kind() {\n\t\tcase reflect.Array, reflect.Slice:\n\t\t\tl := val.Len()\n\t\t\tb := make([]string, 0, l)\n\t\t\tfor i := 0; i < l; i++ {\n\t\t\t\tvalue := val.Index(i).Interface()\n\t\t\t\tif value != nil {\n\t\t\t\t\tb = append(b, strval(value))\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn b\n\t\tdefault:\n\t\t\tif v == nil {\n\t\t\t\treturn []string{}\n\t\t\t}\n\n\t\t\treturn []string{strval(v)}\n\t\t}\n\t}\n}\n\n// removeNilElements creates a new slice with all nil elements removed.\n// This is a helper function used by other functions like cat.\n//\n// Parameters:\n//   - v: The slice to process\n//\n// Returns:\n//   - []any: A new slice with all nil elements removed\nfunc removeNilElements(v []any) []any {\n\tnewSlice := make([]any, 0, len(v))\n\tfor _, i := range v {\n\t\tif i != nil {\n\t\t\tnewSlice = append(newSlice, i)\n\t\t}\n\t}\n\treturn newSlice\n}\n\n// strval converts any value to a string.\n// It handles various types:\n// - string: returned as is\n// - []byte: converted to string\n// - error: returns the error message\n// - fmt.Stringer: calls the String() method\n// - anything else: uses fmt.Sprintf(\"%v\", v)\n//\n// Parameters:\n//   - v: The value to convert to a string\n//\n// Returns:\n//   - string: The string representation of the value\nfunc strval(v any) string {\n\tswitch v := v.(type) {\n\tcase string:\n\t\treturn v\n\tcase []byte:\n\t\treturn string(v)\n\tcase error:\n\t\treturn v.Error()\n\tcase fmt.Stringer:\n\t\treturn v.String()\n\tdefault:\n\t\treturn fmt.Sprintf(\"%v\", v)\n\t}\n}\n\n// trunc truncates a string to a specified length.\n// If c is positive, it returns the first c characters.\n// If c is negative, it returns the last |c| characters.\n// If the string is shorter than the requested length, it returns the original string.\n//\n// Parameters:\n//   - c: The number of characters to keep (positive from start, negative from end)\n//   - s: The string to truncate\n//\n// Returns:\n//   - string: The truncated string\nfunc trunc(c int, s string) string {\n\tif c < 0 && len(s)+c > 0 {\n\t\treturn s[len(s)+c:]\n\t}\n\tif c >= 0 && len(s) > c {\n\t\treturn s[:c]\n\t}\n\treturn s\n}\n\n// title converts a string to title case.\n// This uses the English language rules for capitalization.\n//\n// Parameters:\n//   - s: The string to convert\n//\n// Returns:\n//   - string: The string in title case\nfunc title(s string) string {\n\treturn cases.Title(language.English).String(s)\n}\n\n// join concatenates the elements of a slice with a separator.\n// The input is first converted to a string slice using strslice.\n//\n// Parameters:\n//   - sep: The separator to use between elements\n//   - v: The value to join (will be converted to a string slice)\n//\n// Returns:\n//   - string: The joined string\nfunc join(sep string, v any) string {\n\treturn strings.Join(strslice(v), sep)\n}\n\n// split splits a string by a separator and returns a map.\n// The keys in the map are \"_0\", \"_1\", etc., corresponding to the position of each part.\n//\n// Parameters:\n//   - sep: The separator to split on\n//   - orig: The string to split\n//\n// Returns:\n//   - map[string]string: A map with keys \"_0\", \"_1\", etc. and values being the split parts\nfunc split(sep, orig string) map[string]string {\n\tparts := strings.Split(orig, sep)\n\tres := make(map[string]string, len(parts))\n\tfor i, v := range parts {\n\t\tres[\"_\"+strconv.Itoa(i)] = v\n\t}\n\treturn res\n}\n\n// splitList splits a string by a separator and returns a slice.\n// This is a simple wrapper around strings.Split.\n//\n// Parameters:\n//   - sep: The separator to split on\n//   - orig: The string to split\n//\n// Returns:\n//   - []string: A slice containing the split parts\nfunc splitList(sep, orig string) []string {\n\treturn strings.Split(orig, sep)\n}\n\n// splitn splits a string by a separator with a limit and returns a map.\n// The keys in the map are \"_0\", \"_1\", etc., corresponding to the position of each part.\n// It will split the string into at most n parts.\n//\n// Parameters:\n//   - sep: The separator to split on\n//   - n: The maximum number of parts to return\n//   - orig: The string to split\n//\n// Returns:\n//   - map[string]string: A map with keys \"_0\", \"_1\", etc. and values being the split parts\nfunc splitn(sep string, n int, orig string) map[string]string {\n\tparts := strings.SplitN(orig, sep, n)\n\tres := make(map[string]string, len(parts))\n\tfor i, v := range parts {\n\t\tres[\"_\"+strconv.Itoa(i)] = v\n\t}\n\treturn res\n}\n\n// substring creates a substring of the given string.\n// It extracts a portion of a string based on start and end indices.\n//\n// Parameters:\n//   - start: The starting index (inclusive)\n//   - end: The ending index (exclusive)\n//   - s: The source string\n//\n// Behavior:\n//   - If start < 0, returns s[:end]\n//   - If start >= 0 and end < 0 or end > len(s), returns s[start:]\n//   - Otherwise, returns s[start:end]\n//\n// Returns:\n//   - string: The extracted substring\nfunc substring(start, end int, s string) string {\n\tif start < 0 {\n\t\treturn s[:end]\n\t}\n\tif end < 0 || end > len(s) {\n\t\treturn s[start:]\n\t}\n\treturn s[start:end]\n}\n\n// repeat creates a new string by repeating the input string a specified number of times.\n// It has safety limits to prevent excessive memory usage or infinite loops.\n//\n// Parameters:\n//   - count: The number of times to repeat the string\n//   - str: The string to repeat\n//\n// Returns:\n//   - string: The repeated string\n//\n// Panics:\n//   - If count exceeds loopExecutionLimit\n//   - If the resulting string length would exceed stringLengthLimit\nfunc repeat(count int, str string) string {\n\tif count > loopExecutionLimit {\n\t\tpanic(fmt.Sprintf(\"repeat count %d exceeds limit of %d\", count, loopExecutionLimit))\n\t} else if count*len(str) >= stringLengthLimit {\n\t\tpanic(fmt.Sprintf(\"repeat count %d with string length %d exceeds limit of %d\", count, len(str), stringLengthLimit))\n\t}\n\treturn strings.Repeat(str, count)\n}\n\n// trimAll removes all leading and trailing characters contained in the cutset.\n// Note that the parameter order is reversed from the standard strings.Trim function.\n//\n// Parameters:\n//   - a: The cutset of characters to remove\n//   - b: The string to trim\n//\n// Returns:\n//   - string: The trimmed string\nfunc trimAll(a, b string) string {\n\treturn strings.Trim(b, a)\n}\n\n// trimPrefix removes the specified prefix from a string.\n// If the string doesn't start with the prefix, it returns the original string.\n// Note that the parameter order is reversed from the standard strings.TrimPrefix function.\n//\n// Parameters:\n//   - a: The prefix to remove\n//   - b: The string to trim\n//\n// Returns:\n//   - string: The string with the prefix removed, or the original string if it doesn't start with the prefix\nfunc trimPrefix(a, b string) string {\n\treturn strings.TrimPrefix(b, a)\n}\n\n// trimSuffix removes the specified suffix from a string.\n// If the string doesn't end with the suffix, it returns the original string.\n// Note that the parameter order is reversed from the standard strings.TrimSuffix function.\n//\n// Parameters:\n//   - a: The suffix to remove\n//   - b: The string to trim\n//\n// Returns:\n//   - string: The string with the suffix removed, or the original string if it doesn't end with the suffix\nfunc trimSuffix(a, b string) string {\n\treturn strings.TrimSuffix(b, a)\n}\n\n// contains checks if a string contains a substring.\n//\n// Parameters:\n//   - substr: The substring to search for\n//   - str: The string to search in\n//\n// Returns:\n//   - bool: True if str contains substr, false otherwise\nfunc contains(substr string, str string) bool {\n\treturn strings.Contains(str, substr)\n}\n\n// hasPrefix checks if a string starts with a specified prefix.\n//\n// Parameters:\n//   - substr: The prefix to check for\n//   - str: The string to check\n//\n// Returns:\n//   - bool: True if str starts with substr, false otherwise\nfunc hasPrefix(substr string, str string) bool {\n\treturn strings.HasPrefix(str, substr)\n}\n\n// hasSuffix checks if a string ends with a specified suffix.\n//\n// Parameters:\n//   - substr: The suffix to check for\n//   - str: The string to check\n//\n// Returns:\n//   - bool: True if str ends with substr, false otherwise\nfunc hasSuffix(substr string, str string) bool {\n\treturn strings.HasSuffix(str, substr)\n}\n"
  },
  {
    "path": "util/sprig/strings_test.go",
    "content": "package sprig\n\nimport (\n\t\"encoding/base32\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSubstr(t *testing.T) {\n\ttpl := `{{\"fooo\" | substr 0 3 }}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestSubstr_shorterString(t *testing.T) {\n\ttpl := `{{\"foo\" | substr 0 10 }}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestTrunc(t *testing.T) {\n\ttpl := `{{ \"foooooo\" | trunc 3 }}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ \"baaaaaar\" | trunc -3 }}`\n\tif err := runt(tpl, \"aar\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ \"baaaaaar\" | trunc -999 }}`\n\tif err := runt(tpl, \"baaaaaar\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ \"baaaaaz\" | trunc 0 }}`\n\tif err := runt(tpl, \"\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestQuote(t *testing.T) {\n\ttpl := `{{quote \"a\" \"b\" \"c\"}}`\n\tif err := runt(tpl, `\"a\" \"b\" \"c\"`); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{quote \"\\\"a\\\"\" \"b\" \"c\"}}`\n\tif err := runt(tpl, `\"\\\"a\\\"\" \"b\" \"c\"`); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{quote 1 2 3 }}`\n\tif err := runt(tpl, `\"1\" \"2\" \"3\"`); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ .value | quote }}`\n\tvalues := map[string]any{\"value\": nil}\n\tif err := runtv(tpl, ``, values); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestSquote(t *testing.T) {\n\ttpl := `{{squote \"a\" \"b\" \"c\"}}`\n\tif err := runt(tpl, `'a' 'b' 'c'`); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{squote 1 2 3 }}`\n\tif err := runt(tpl, `'1' '2' '3'`); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ .value | squote }}`\n\tvalues := map[string]any{\"value\": nil}\n\tif err := runtv(tpl, ``, values); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestContains(t *testing.T) {\n\t// Mainly, we're just verifying the paramater order swap.\n\ttests := []string{\n\t\t`{{if contains \"cat\" \"fair catch\"}}1{{end}}`,\n\t\t`{{if hasPrefix \"cat\" \"catch\"}}1{{end}}`,\n\t\t`{{if hasSuffix \"cat\" \"ducat\"}}1{{end}}`,\n\t}\n\tfor _, tt := range tests {\n\t\tif err := runt(tt, \"1\"); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestTrim(t *testing.T) {\n\ttests := []string{\n\t\t`{{trim \"   5.00   \"}}`,\n\t\t`{{trimAll \"$\" \"$5.00$\"}}`,\n\t\t`{{trimPrefix \"$\" \"$5.00\"}}`,\n\t\t`{{trimSuffix \"$\" \"5.00$\"}}`,\n\t}\n\tfor _, tt := range tests {\n\t\tif err := runt(tt, \"5.00\"); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestSplit(t *testing.T) {\n\ttpl := `{{$v := \"foo$bar$baz\" | split \"$\"}}{{$v._0}}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestSplitn(t *testing.T) {\n\ttpl := `{{$v := \"foo$bar$baz\" | splitn \"$\" 2}}{{$v._0}}`\n\tif err := runt(tpl, \"foo\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestToString(t *testing.T) {\n\ttpl := `{{ toString 1 | kindOf }}`\n\tassert.NoError(t, runt(tpl, \"string\"))\n}\n\nfunc TestToStrings(t *testing.T) {\n\ttpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}`\n\tassert.NoError(t, runt(tpl, \"string\"))\n\ttpl = `{{ list 1 .value 2 | toStrings }}`\n\tvalues := map[string]any{\"value\": nil}\n\tif err := runtv(tpl, `[1 2]`, values); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestJoin(t *testing.T) {\n\tassert.NoError(t, runt(`{{ tuple \"a\" \"b\" \"c\" | join \"-\" }}`, \"a-b-c\"))\n\tassert.NoError(t, runt(`{{ tuple 1 2 3 | join \"-\" }}`, \"1-2-3\"))\n\tassert.NoError(t, runtv(`{{ join \"-\" .V }}`, \"a-b-c\", map[string]any{\"V\": []string{\"a\", \"b\", \"c\"}}))\n\tassert.NoError(t, runtv(`{{ join \"-\" .V }}`, \"abc\", map[string]any{\"V\": \"abc\"}))\n\tassert.NoError(t, runtv(`{{ join \"-\" .V }}`, \"1-2-3\", map[string]any{\"V\": []int{1, 2, 3}}))\n\tassert.NoError(t, runtv(`{{ join \"-\" .value }}`, \"1-2\", map[string]any{\"value\": []any{\"1\", nil, \"2\"}}))\n}\n\nfunc TestSortAlpha(t *testing.T) {\n\t// Named `append` in the function map\n\ttests := map[string]string{\n\t\t`{{ list \"c\" \"a\" \"b\" | sortAlpha | join \"\" }}`: \"abc\",\n\t\t`{{ list 2 1 4 3 | sortAlpha | join \"\" }}`:     \"1234\",\n\t}\n\tfor tpl, expect := range tests {\n\t\tassert.NoError(t, runt(tpl, expect))\n\t}\n}\nfunc TestBase64EncodeDecode(t *testing.T) {\n\tmagicWord := \"coffee\"\n\texpect := base64.StdEncoding.EncodeToString([]byte(magicWord))\n\n\tif expect == magicWord {\n\t\tt.Fatal(\"Encoder doesn't work.\")\n\t}\n\n\ttpl := `{{b64enc \"coffee\"}}`\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = fmt.Sprintf(\"{{b64dec %q}}\", expect)\n\tif err := runt(tpl, magicWord); err != nil {\n\t\tt.Error(err)\n\t}\n}\nfunc TestBase32EncodeDecode(t *testing.T) {\n\tmagicWord := \"coffee\"\n\texpect := base32.StdEncoding.EncodeToString([]byte(magicWord))\n\n\tif expect == magicWord {\n\t\tt.Fatal(\"Encoder doesn't work.\")\n\t}\n\n\ttpl := `{{b32enc \"coffee\"}}`\n\tif err := runt(tpl, expect); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = fmt.Sprintf(\"{{b32dec %q}}\", expect)\n\tif err := runt(tpl, magicWord); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestCat(t *testing.T) {\n\ttpl := `{{$b := \"b\"}}{{\"c\" | cat \"a\" $b}}`\n\tif err := runt(tpl, \"a b c\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{ .value | cat \"a\" \"b\"}}`\n\tvalues := map[string]any{\"value\": nil}\n\tif err := runtv(tpl, \"a b\", values); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestIndent(t *testing.T) {\n\ttpl := `{{indent 4 \"a\\nb\\nc\"}}`\n\tif err := runt(tpl, \"    a\\n    b\\n    c\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestNindent(t *testing.T) {\n\ttpl := `{{nindent 4 \"a\\nb\\nc\"}}`\n\tif err := runt(tpl, \"\\n    a\\n    b\\n    c\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestReplace(t *testing.T) {\n\ttpl := `{{\"I Am Henry VIII\" | replace \" \" \"-\"}}`\n\tif err := runt(tpl, \"I-Am-Henry-VIII\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestPlural(t *testing.T) {\n\ttpl := `{{$num := len \"two\"}}{{$num}} {{$num | plural \"1 char\" \"chars\"}}`\n\tif err := runt(tpl, \"3 chars\"); err != nil {\n\t\tt.Error(err)\n\t}\n\ttpl = `{{len \"t\" | plural \"cheese\" \"%d chars\"}}`\n\tif err := runt(tpl, \"cheese\"); err != nil {\n\t\tt.Error(err)\n\t}\n}\n"
  },
  {
    "path": "util/sprig/url.go",
    "content": "package sprig\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"reflect\"\n)\n\nfunc dictGetOrEmpty(dict map[string]any, key string) string {\n\tvalue, ok := dict[key]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\ttp := reflect.TypeOf(value).Kind()\n\tif tp != reflect.String {\n\t\tpanic(fmt.Sprintf(\"unable to parse %s key, must be of type string, but %s found\", key, tp.String()))\n\t}\n\treturn reflect.ValueOf(value).String()\n}\n\n// parses given URL to return dict object\nfunc urlParse(v string) map[string]any {\n\tdict := map[string]any{}\n\tparsedURL, err := url.Parse(v)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"unable to parse url: %s\", err))\n\t}\n\tdict[\"scheme\"] = parsedURL.Scheme\n\tdict[\"host\"] = parsedURL.Host\n\tdict[\"hostname\"] = parsedURL.Hostname()\n\tdict[\"path\"] = parsedURL.Path\n\tdict[\"query\"] = parsedURL.RawQuery\n\tdict[\"opaque\"] = parsedURL.Opaque\n\tdict[\"fragment\"] = parsedURL.Fragment\n\tif parsedURL.User != nil {\n\t\tdict[\"userinfo\"] = parsedURL.User.String()\n\t} else {\n\t\tdict[\"userinfo\"] = \"\"\n\t}\n\n\treturn dict\n}\n\n// join given dict to URL string\nfunc urlJoin(d map[string]any) string {\n\tresURL := url.URL{\n\t\tScheme:   dictGetOrEmpty(d, \"scheme\"),\n\t\tHost:     dictGetOrEmpty(d, \"host\"),\n\t\tPath:     dictGetOrEmpty(d, \"path\"),\n\t\tRawQuery: dictGetOrEmpty(d, \"query\"),\n\t\tOpaque:   dictGetOrEmpty(d, \"opaque\"),\n\t\tFragment: dictGetOrEmpty(d, \"fragment\"),\n\t}\n\tuserinfo := dictGetOrEmpty(d, \"userinfo\")\n\tvar user *url.Userinfo\n\tif userinfo != \"\" {\n\t\ttempURL, err := url.Parse(fmt.Sprintf(\"proto://%s@host\", userinfo))\n\t\tif err != nil {\n\t\t\tpanic(fmt.Sprintf(\"unable to parse userinfo in dict: %s\", err))\n\t\t}\n\t\tuser = tempURL.User\n\t}\n\tresURL.User = user\n\treturn resURL.String()\n}\n"
  },
  {
    "path": "util/sprig/url_test.go",
    "content": "package sprig\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar urlTests = map[string]map[string]any{\n\t\"proto://auth@host:80/path?query#fragment\": {\n\t\t\"fragment\": \"fragment\",\n\t\t\"host\":     \"host:80\",\n\t\t\"hostname\": \"host\",\n\t\t\"opaque\":   \"\",\n\t\t\"path\":     \"/path\",\n\t\t\"query\":    \"query\",\n\t\t\"scheme\":   \"proto\",\n\t\t\"userinfo\": \"auth\",\n\t},\n\t\"proto://host:80/path\": {\n\t\t\"fragment\": \"\",\n\t\t\"host\":     \"host:80\",\n\t\t\"hostname\": \"host\",\n\t\t\"opaque\":   \"\",\n\t\t\"path\":     \"/path\",\n\t\t\"query\":    \"\",\n\t\t\"scheme\":   \"proto\",\n\t\t\"userinfo\": \"\",\n\t},\n\t\"something\": {\n\t\t\"fragment\": \"\",\n\t\t\"host\":     \"\",\n\t\t\"hostname\": \"\",\n\t\t\"opaque\":   \"\",\n\t\t\"path\":     \"something\",\n\t\t\"query\":    \"\",\n\t\t\"scheme\":   \"\",\n\t\t\"userinfo\": \"\",\n\t},\n\t\"proto://user:passwor%20d@host:80/path\": {\n\t\t\"fragment\": \"\",\n\t\t\"host\":     \"host:80\",\n\t\t\"hostname\": \"host\",\n\t\t\"opaque\":   \"\",\n\t\t\"path\":     \"/path\",\n\t\t\"query\":    \"\",\n\t\t\"scheme\":   \"proto\",\n\t\t\"userinfo\": \"user:passwor%20d\",\n\t},\n\t\"proto://host:80/pa%20th?key=val%20ue\": {\n\t\t\"fragment\": \"\",\n\t\t\"host\":     \"host:80\",\n\t\t\"hostname\": \"host\",\n\t\t\"opaque\":   \"\",\n\t\t\"path\":     \"/pa th\",\n\t\t\"query\":    \"key=val%20ue\",\n\t\t\"scheme\":   \"proto\",\n\t\t\"userinfo\": \"\",\n\t},\n}\n\nfunc TestUrlParse(t *testing.T) {\n\t// testing that function is exported and working properly\n\tassert.NoError(t, runt(\n\t\t`{{ index ( urlParse \"proto://auth@host:80/path?query#fragment\" ) \"host\" }}`,\n\t\t\"host:80\"))\n\n\t// testing scenarios\n\tfor url, expected := range urlTests {\n\t\tassert.EqualValues(t, expected, urlParse(url))\n\t}\n}\n\nfunc TestUrlJoin(t *testing.T) {\n\ttests := map[string]string{\n\t\t`{{ urlJoin (dict \"fragment\" \"fragment\" \"host\" \"host:80\" \"path\" \"/path\" \"query\" \"query\" \"scheme\" \"proto\") }}`:       \"proto://host:80/path?query#fragment\",\n\t\t`{{ urlJoin (dict \"fragment\" \"fragment\" \"host\" \"host:80\" \"path\" \"/path\" \"scheme\" \"proto\" \"userinfo\" \"ASDJKJSD\") }}`: \"proto://ASDJKJSD@host:80/path#fragment\",\n\t}\n\tfor tpl, expected := range tests {\n\t\tassert.NoError(t, runt(tpl, expected))\n\t}\n\n\tfor expected, urlMap := range urlTests {\n\t\tassert.EqualValues(t, expected, urlJoin(urlMap))\n\t}\n\n}\n"
  },
  {
    "path": "util/time.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"github.com/olebedev/when\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar (\n\terrInvalidDuration = errors.New(\"unable to parse duration\")\n\tdurationStrRegex   = regexp.MustCompile(`(?i)^(\\d+)\\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)\n)\n\nconst (\n\ttimestampFormat = \"2006-01-02T15:04:05.999Z07:00\" // Like RFC3339, but with milliseconds\n)\n\n// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds\nfunc FormatTime(t time.Time) string {\n\treturn t.Format(timestampFormat)\n}\n\n// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence\n// of that time from the current time (in UTC).\nfunc NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {\n\thour, minute, seconds := timeOfDay.UTC().Clock()\n\tnow := base.UTC()\n\tnext := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)\n\tif next.Before(now) {\n\t\tnext = next.AddDate(0, 0, 1)\n\t}\n\treturn next\n}\n\n// ParseFutureTime parses a date/time string to a time.Time. It supports unix timestamps, durations\n// and natural language dates\nfunc ParseFutureTime(s string, now time.Time) (time.Time, error) {\n\ts = strings.TrimSpace(s)\n\tt, err := parseUnixTime(s, now)\n\tif err == nil {\n\t\treturn t, nil\n\t}\n\tt, err = parseFromDuration(s, now)\n\tif err == nil {\n\t\treturn t, nil\n\t}\n\tt, err = parseNaturalTime(s, now)\n\tif err == nil {\n\t\treturn t, nil\n\t}\n\treturn time.Time{}, errInvalidDuration\n}\n\n// ParseDuration is like time.ParseDuration, except that it also understands days (d), which\n// translates to 24 hours, e.g. \"2d\" or \"20h\".\nfunc ParseDuration(s string) (time.Duration, error) {\n\td, err := time.ParseDuration(s)\n\tif err == nil {\n\t\treturn d, nil\n\t}\n\tmatches := durationStrRegex.FindStringSubmatch(s)\n\tif matches != nil {\n\t\tnumber, err := strconv.Atoi(matches[1])\n\t\tif err != nil {\n\t\t\treturn 0, errInvalidDuration\n\t\t}\n\t\tswitch unit := matches[2][0:1]; unit {\n\t\tcase \"d\":\n\t\t\treturn time.Duration(number) * 24 * time.Hour, nil\n\t\tcase \"h\":\n\t\t\treturn time.Duration(number) * time.Hour, nil\n\t\tcase \"m\":\n\t\t\treturn time.Duration(number) * time.Minute, nil\n\t\tcase \"s\":\n\t\t\treturn time.Duration(number) * time.Second, nil\n\t\tdefault:\n\t\t\treturn 0, errInvalidDuration\n\t\t}\n\t}\n\treturn 0, errInvalidDuration\n}\n\n// FormatDuration formats a time.Duration into a human-readable string, e.g. \"2d\", \"20h\", \"30m\", \"40s\".\n// It rounds to the largest unit that is not zero, thereby effectively rounding down.\nfunc FormatDuration(d time.Duration) string {\n\tif d >= 24*time.Hour {\n\t\treturn strconv.Itoa(int(d/(24*time.Hour))) + \"d\"\n\t}\n\tif d >= time.Hour {\n\t\treturn strconv.Itoa(int(d/time.Hour)) + \"h\"\n\t}\n\tif d >= time.Minute {\n\t\treturn strconv.Itoa(int(d/time.Minute)) + \"m\"\n\t}\n\tif d >= time.Second {\n\t\treturn strconv.Itoa(int(d/time.Second)) + \"s\"\n\t}\n\treturn \"0s\"\n}\n\nfunc parseFromDuration(s string, now time.Time) (time.Time, error) {\n\td, err := ParseDuration(s)\n\tif err == nil {\n\t\treturn now.Add(d), nil\n\t}\n\treturn time.Time{}, errInvalidDuration\n}\n\nfunc parseUnixTime(s string, now time.Time) (time.Time, error) {\n\tt, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t} else if int64(t) < now.Unix() {\n\t\treturn time.Time{}, errInvalidDuration\n\t}\n\treturn time.Unix(int64(t), 0).UTC(), nil\n}\n\nfunc parseNaturalTime(s string, now time.Time) (time.Time, error) {\n\tr, err := when.EN.Parse(s, now) // returns \"nil, nil\" if no matches!\n\tif err != nil || r == nil {\n\t\treturn time.Time{}, errInvalidDuration\n\t} else if r.Time.After(now) {\n\t\treturn r.Time, nil\n\t}\n\t// Hack: If the time is parsable, but not in the future,\n\t// simply append \"tomorrow, \" to it.\n\tr, err = when.EN.Parse(\"tomorrow, \"+s, now) // returns \"nil, nil\" if no matches!\n\tif err != nil || r == nil {\n\t\treturn time.Time{}, errInvalidDuration\n\t} else if r.Time.After(now) {\n\t\treturn r.Time, nil\n\t}\n\treturn time.Time{}, errInvalidDuration\n}\n"
  },
  {
    "path": "util/time_test.go",
    "content": "package util\n\nimport (\n\t\"github.com/stretchr/testify/require\"\n\t\"testing\"\n\t\"time\"\n)\n\nvar (\n\t// 2021-12-10 10:17:23 (Friday)\n\tbase = time.Date(2021, 12, 10, 10, 17, 23, 0, time.UTC)\n)\n\nfunc TestNextOccurrenceUTC_NextDate(t *testing.T) {\n\tloc, err := time.LoadLocation(\"America/New_York\")\n\trequire.Nil(t, err)\n\n\ttimeOfDay := time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) // Run at midnight UTC\n\tnowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc)\n\tnextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT)\n\trequire.Equal(t, time.Date(2023, time.January, 12, 0, 0, 0, 0, time.UTC), nextRunTme)\n}\n\nfunc TestNextOccurrenceUTC_SameDay(t *testing.T) {\n\tloc, err := time.LoadLocation(\"America/New_York\")\n\trequire.Nil(t, err)\n\n\ttimeOfDay := time.Date(0, 0, 0, 4, 0, 0, 0, time.UTC) // Run at 4am UTC\n\tnowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc)\n\tnextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT)\n\trequire.Equal(t, time.Date(2023, time.January, 11, 4, 0, 0, 0, time.UTC), nextRunTme)\n}\n\nfunc TestParseFutureTime_11am_FutureTime(t *testing.T) {\n\td, err := ParseFutureTime(\"11am\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 10, 11, 0, 0, 0, time.UTC), d) // Same day\n}\n\nfunc TestParseFutureTime_9am_PastTime(t *testing.T) {\n\td, err := ParseFutureTime(\"9am\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 11, 9, 0, 0, 0, time.UTC), d) // Next day\n}\n\nfunc TestParseFutureTime_Monday_10_30pm_FutureTime(t *testing.T) {\n\td, err := ParseFutureTime(\"Monday, 10:30pm\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 13, 22, 30, 0, 0, time.UTC), d)\n}\n\nfunc TestParseFutureTime_30m(t *testing.T) {\n\td, err := ParseFutureTime(\"30m\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 10, 10, 47, 23, 0, time.UTC), d)\n}\n\nfunc TestParseFutureTime_30min(t *testing.T) {\n\td, err := ParseFutureTime(\"30min\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 10, 10, 47, 23, 0, time.UTC), d)\n}\n\nfunc TestParseFutureTime_3h(t *testing.T) {\n\td, err := ParseFutureTime(\"3h\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 10, 13, 17, 23, 0, time.UTC), d)\n}\n\nfunc TestParseFutureTime_1day(t *testing.T) {\n\td, err := ParseFutureTime(\"1 day\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 11, 10, 17, 23, 0, time.UTC), d)\n}\n\nfunc TestParseFutureTime_UnixTime(t *testing.T) {\n\td, err := ParseFutureTime(\"1639183911\", base)\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d)\n}\n\nfunc TestParseDuration(t *testing.T) {\n\td, err := ParseDuration(\"2d\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 48*time.Hour, d)\n\n\td, err = ParseDuration(\"2h\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, 2*time.Hour, d)\n\n\td, err = ParseDuration(\"0\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, time.Duration(0), d)\n}\n\nfunc TestFormatDuration(t *testing.T) {\n\tvalues := []struct {\n\t\tduration time.Duration\n\t\texpected string\n\t}{\n\t\t{24 * time.Second, \"24s\"},\n\t\t{56 * time.Minute, \"56m\"},\n\t\t{time.Hour, \"1h\"},\n\t\t{2 * time.Hour, \"2h\"},\n\t\t{24 * time.Hour, \"1d\"},\n\t\t{3 * 24 * time.Hour, \"3d\"},\n\t}\n\tfor _, value := range values {\n\t\trequire.Equal(t, value.expected, FormatDuration(value.duration))\n\t\td, err := ParseDuration(FormatDuration(value.duration))\n\t\trequire.Nil(t, err)\n\t\trequire.Equalf(t, value.duration, d, \"duration does not match: %v != %v\", value.duration, d)\n\t}\n}\n\nfunc TestFormatDuration_Rounded(t *testing.T) {\n\trequire.Equal(t, \"1d\", FormatDuration(47*time.Hour))\n}\n"
  },
  {
    "path": "util/timeout_writer.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"time\"\n)\n\n// ErrWriteTimeout is returned when a write timed out\nvar ErrWriteTimeout = errors.New(\"write operation failed due to timeout\")\n\n// TimeoutWriter wraps an io.Writer that will time out after the given timeout\ntype TimeoutWriter struct {\n\twriter  io.Writer\n\ttimeout time.Duration\n\tstart   time.Time\n}\n\n// NewTimeoutWriter creates a new TimeoutWriter\nfunc NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {\n\treturn &TimeoutWriter{\n\t\twriter:  w,\n\t\ttimeout: timeout,\n\t\tstart:   time.Now(),\n\t}\n}\n\n// Write implements the io.Writer interface, failing if called after the timeout period from creation.\nfunc (tw *TimeoutWriter) Write(p []byte) (n int, err error) {\n\tif time.Since(tw.start) > tw.timeout {\n\t\treturn 0, ErrWriteTimeout\n\t}\n\treturn tw.writer.Write(p)\n}\n"
  },
  {
    "path": "util/util.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math\"\n\t\"math/rand\"\n\t\"net/netip\"\n\t\"os\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/gabriel-vasile/mimetype\"\n\t\"golang.org/x/term\"\n\t\"golang.org/x/time/rate\"\n)\n\nconst (\n\trandomStringCharset          = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\trandomStringLowerCaseCharset = \"abcdefghijklmnopqrstuvwxyz0123456789\"\n)\n\nvar (\n\trandom             = rand.New(rand.NewSource(time.Now().UnixNano()))\n\trandomMutex        = sync.Mutex{}\n\tsizeStrRegex       = regexp.MustCompile(`(?i)^(\\d+)([gmkb])?$`)\n\terrInvalidPriority = errors.New(\"invalid priority\")\n\tnoQuotesRegex      = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)\n)\n\n// Errors for UnmarshalJSON and UnmarshalJSONWithLimit functions\nvar (\n\tErrUnmarshalJSON = errors.New(\"unmarshalling JSON failed\")\n\tErrTooLargeJSON  = errors.New(\"too large JSON\")\n)\n\n// FileExists checks if a file exists, and returns true if it does\nfunc FileExists(filename string) bool {\n\tstat, _ := os.Stat(filename)\n\treturn stat != nil\n}\n\n// Contains returns true if needle is contained in haystack\nfunc Contains[T comparable](haystack []T, needle T) bool {\n\treturn slices.Contains(haystack, needle)\n}\n\n// ContainsIP returns true if any one of the of prefixes contains the ip.\nfunc ContainsIP(haystack []netip.Prefix, needle netip.Addr) bool {\n\tfor _, s := range haystack {\n\t\tif s.Contains(needle) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ContainsAll returns true if all needles are contained in haystack\nfunc ContainsAll[T comparable](haystack []T, needles []T) bool {\n\tfor _, needle := range needles {\n\t\tif !Contains(haystack, needle) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// SplitNoEmpty splits a string using strings.Split, but filters out empty strings\nfunc SplitNoEmpty(s string, sep string) []string {\n\tres := make([]string, 0)\n\tfor _, r := range strings.Split(s, sep) {\n\t\tif r != \"\" {\n\t\t\tres = append(res, r)\n\t\t}\n\t}\n\treturn res\n}\n\n// SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator\n// is not found, key is empty.\nfunc SplitKV(s string, sep string) (key string, value string) {\n\tkv := strings.SplitN(strings.TrimSpace(s), sep, 2)\n\tif len(kv) == 2 {\n\t\treturn strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])\n\t}\n\treturn \"\", strings.TrimSpace(kv[0])\n}\n\n// Map applies a function to each element of a slice and returns a new slice with the results\n// Example: Map([]int{1, 2, 3}, func(i int) int { return i * 2 }) -> []int{2, 4, 6}\nfunc Map[T any, U any](slice []T, f func(T) U) []U {\n\tresult := make([]U, len(slice))\n\tfor i, v := range slice {\n\t\tresult[i] = f(v)\n\t}\n\treturn result\n}\n\n// Filter returns a new slice containing only the elements of the original slice for which the\n// given function returns true.\nfunc Filter[T any](slice []T, f func(T) bool) []T {\n\tresult := make([]T, 0)\n\tfor _, v := range slice {\n\t\tif f(v) {\n\t\t\tresult = append(result, v)\n\t\t}\n\t}\n\treturn result\n}\n\n// Find returns the first element in the slice that satisfies the given function, and a boolean indicating\n// whether such an element was found. If no element is found, it returns the zero value of T and false.\nfunc Find[T any](slice []T, f func(T) bool) (T, bool) {\n\tfor _, v := range slice {\n\t\tif f(v) {\n\t\t\treturn v, true\n\t\t}\n\t}\n\tvar zero T\n\treturn zero, false\n}\n\n// RandomString returns a random string with a given length\nfunc RandomString(length int) string {\n\treturn RandomStringPrefix(\"\", length)\n}\n\n// RandomStringPrefix returns a random string with a given length, with a prefix\nfunc RandomStringPrefix(prefix string, length int) string {\n\treturn randomStringPrefixWithCharset(prefix, length, randomStringCharset)\n}\n\n// RandomLowerStringPrefix returns a random lowercase-only string with a given length, with a prefix\nfunc RandomLowerStringPrefix(prefix string, length int) string {\n\treturn randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)\n}\n\nfunc randomStringPrefixWithCharset(prefix string, length int, charset string) string {\n\trandomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!\n\tdefer randomMutex.Unlock()\n\tb := make([]byte, length-len(prefix))\n\tfor i := range b {\n\t\tb[i] = charset[random.Intn(len(charset))]\n\t}\n\treturn prefix + string(b)\n}\n\n// ValidRandomString returns true if the given string matches the format created by RandomString\nfunc ValidRandomString(s string, length int) bool {\n\tif len(s) != length {\n\t\treturn false\n\t}\n\tfor _, c := range strings.Split(s, \"\") {\n\t\tif !strings.Contains(randomStringCharset, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ParsePriority parses a priority string into its equivalent integer value\nfunc ParsePriority(priority string) (int, error) {\n\tp := strings.TrimSpace(strings.ToLower(priority))\n\tswitch p {\n\tcase \"\":\n\t\treturn 0, nil\n\tcase \"1\", \"min\":\n\t\treturn 1, nil\n\tcase \"2\", \"low\":\n\t\treturn 2, nil\n\tcase \"3\", \"default\":\n\t\treturn 3, nil\n\tcase \"4\", \"high\":\n\t\treturn 4, nil\n\tcase \"5\", \"max\", \"urgent\":\n\t\treturn 5, nil\n\tdefault:\n\t\treturn 0, errInvalidPriority\n\t}\n}\n\n// PriorityString converts a priority number to a string\nfunc PriorityString(priority int) (string, error) {\n\tswitch priority {\n\tcase 0:\n\t\treturn \"default\", nil\n\tcase 1:\n\t\treturn \"min\", nil\n\tcase 2:\n\t\treturn \"low\", nil\n\tcase 3:\n\t\treturn \"default\", nil\n\tcase 4:\n\t\treturn \"high\", nil\n\tcase 5:\n\t\treturn \"max\", nil\n\tdefault:\n\t\treturn \"\", errInvalidPriority\n\t}\n}\n\n// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://\nfunc ShortTopicURL(s string) string {\n\treturn strings.TrimPrefix(strings.TrimPrefix(s, \"https://\"), \"http://\")\n}\n\n// DetectContentType probes the byte array b and returns mime type and file extension.\n// The filename is only used to override certain special cases.\nfunc DetectContentType(b []byte, filename string) (mimeType string, ext string) {\n\tif strings.HasSuffix(strings.ToLower(filename), \".apk\") {\n\t\treturn \"application/vnd.android.package-archive\", \".apk\"\n\t}\n\tm := mimetype.Detect(b)\n\tmimeType, ext = m.String(), m.Extension()\n\tif ext == \"\" {\n\t\text = \".bin\"\n\t}\n\treturn\n}\n\n// ParseSize parses a size string like 2K or 2M into bytes. If no unit is found, e.g. 123, bytes is assumed.\nfunc ParseSize(s string) (int64, error) {\n\tmatches := sizeStrRegex.FindStringSubmatch(s)\n\tif matches == nil {\n\t\treturn -1, fmt.Errorf(\"invalid size %s\", s)\n\t}\n\tvalue, err := strconv.Atoi(matches[1])\n\tif err != nil {\n\t\treturn -1, fmt.Errorf(\"cannot convert number %s\", matches[1])\n\t}\n\tswitch strings.ToUpper(matches[2]) {\n\tcase \"T\":\n\t\treturn int64(value) * 1024 * 1024 * 1024 * 1024, nil\n\tcase \"G\":\n\t\treturn int64(value) * 1024 * 1024 * 1024, nil\n\tcase \"M\":\n\t\treturn int64(value) * 1024 * 1024, nil\n\tcase \"K\":\n\t\treturn int64(value) * 1024, nil\n\tdefault:\n\t\treturn int64(value), nil\n\t}\n}\n\n// FormatSize formats the size in a way that it can be parsed by ParseSize.\n// It does not include decimal places. Uneven sizes are rounded down.\nfunc FormatSize(b int64) string {\n\tconst unit = 1024\n\tif b < unit {\n\t\treturn fmt.Sprintf(\"%d\", b)\n\t}\n\tdiv, exp := int64(unit), 0\n\tfor n := b / unit; n >= unit; n /= unit {\n\t\tdiv *= unit\n\t\texp++\n\t}\n\treturn fmt.Sprintf(\"%d%c\", int(math.Floor(float64(b)/float64(div))), \"KMGT\"[exp])\n}\n\n// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB\nfunc FormatSizeHuman(b int64) string {\n\tconst unit = 1024\n\tif b < unit {\n\t\treturn fmt.Sprintf(\"%d bytes\", b)\n\t}\n\tdiv, exp := int64(unit), 0\n\tfor n := b / unit; n >= unit; n /= unit {\n\t\tdiv *= unit\n\t\texp++\n\t}\n\treturn fmt.Sprintf(\"%.1f %cB\", float64(b)/float64(div), \"KMGT\"[exp])\n}\n\n// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the\n// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).\nfunc ReadPassword(in io.Reader) ([]byte, error) {\n\t// If in is a file and a character device (a TTY), use term.ReadPassword\n\tif f, ok := in.(*os.File); ok {\n\t\tstat, err := f.Stat()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif (stat.Mode() & os.ModeCharDevice) == os.ModeCharDevice {\n\t\t\tpassword, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t} else if len(password) == 0 {\n\t\t\t\treturn nil, errors.New(\"password cannot be empty\")\n\t\t\t}\n\t\t\treturn password, nil\n\t\t}\n\t}\n\n\t// Fallback: Manually read util \\n if found, see #69 for details why this is so manual\n\tpassword := make([]byte, 0)\n\tbuf := make([]byte, 1)\n\tfor {\n\t\t_, err := in.Read(buf)\n\t\tif err == io.EOF || buf[0] == '\\n' {\n\t\t\tbreak\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t} else if len(password) > 10240 {\n\t\t\treturn nil, errors.New(\"passwords this long are not supported\")\n\t\t}\n\t\tpassword = append(password, buf[0])\n\t}\n\tif len(password) == 0 {\n\t\treturn nil, errors.New(\"password cannot be empty\")\n\t}\n\treturn password, nil\n}\n\n// BasicAuth encodes the Authorization header value for basic auth\nfunc BasicAuth(user, pass string) string {\n\treturn fmt.Sprintf(\"Basic %s\", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(\"%s:%s\", user, pass))))\n}\n\n// BearerAuth encodes the Authorization header value for a bearer/token auth\nfunc BearerAuth(token string) string {\n\treturn fmt.Sprintf(\"Bearer %s\", token)\n}\n\n// MaybeMarshalJSON returns a JSON string of the given object, or \"<cannot serialize>\" if serialization failed.\n// This is useful for logging purposes where a failure doesn't matter that much.\nfunc MaybeMarshalJSON(v any) string {\n\tjsonBytes, err := json.MarshalIndent(v, \"\", \"  \")\n\tif err != nil {\n\t\treturn \"<cannot serialize>\"\n\t}\n\tif len(jsonBytes) > 5000 {\n\t\treturn string(jsonBytes)[:5000]\n\t}\n\treturn string(jsonBytes)\n}\n\n// QuoteCommand combines a command array to a string, quoting arguments that need quoting.\n// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.\n//\n// Warning: Never use this function with the intent to run the resulting command.\n//\n// Example:\n//\n//\t[]string{\"ls\", \"-al\", \"Document Folder\"} -> ls -al \"Document Folder\"\nfunc QuoteCommand(command []string) string {\n\tvar quoted []string\n\tfor _, c := range command {\n\t\tif noQuotesRegex.MatchString(c) {\n\t\t\tquoted = append(quoted, c)\n\t\t} else {\n\t\t\tquoted = append(quoted, fmt.Sprintf(`\"%s\"`, c))\n\t\t}\n\t}\n\treturn strings.Join(quoted, \" \")\n}\n\n// UnmarshalJSON reads the given io.ReadCloser into a struct\nfunc UnmarshalJSON[T any](body io.ReadCloser) (*T, error) {\n\tvar obj T\n\tif err := json.NewDecoder(body).Decode(&obj); err != nil {\n\t\treturn nil, ErrUnmarshalJSON\n\t}\n\treturn &obj, nil\n}\n\n// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached\nfunc UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {\n\tdefer r.Close()\n\tp, err := Peek(r, limit)\n\tif err != nil {\n\t\treturn nil, err\n\t} else if p.LimitReached {\n\t\treturn nil, ErrTooLargeJSON\n\t}\n\tvar obj T\n\tif len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty {\n\t\treturn &obj, nil\n\t} else if err := json.NewDecoder(p).Decode(&obj); err != nil {\n\t\treturn nil, ErrUnmarshalJSON\n\t}\n\treturn &obj, nil\n}\n\n// Retry executes function f until if succeeds, and then returns t. If f fails, it sleeps\n// and tries again. The sleep durations are passed as the after params.\nfunc Retry[T any](f func() (*T, error), after ...time.Duration) (t *T, err error) {\n\tfor _, delay := range after {\n\t\tif t, err = f(); err == nil {\n\t\t\treturn t, nil\n\t\t}\n\t\ttime.Sleep(delay)\n\t}\n\treturn nil, err\n}\n\n// MinMax returns value if it is between min and max, or either\n// min or max if it is out of range\nfunc MinMax[T int | int64](value, min, max T) T {\n\tif value < min {\n\t\treturn min\n\t} else if value > max {\n\t\treturn max\n\t}\n\treturn value\n}\n\n// Max returns the maximum value of the two given values\nfunc Max[T int | int64 | rate.Limit](a, b T) T {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// String turns a string into a pointer of a string\nfunc String(v string) *string {\n\treturn &v\n}\n\n// Int turns an int into a pointer of an int\nfunc Int(v int) *int {\n\treturn &v\n}\n\n// Time turns a time.Time into a pointer\nfunc Time(v time.Time) *time.Time {\n\treturn &v\n}\n\n// SanitizeUTF8 ensures a string is safe to store in PostgreSQL by handling two cases:\n//\n//  1. Invalid UTF-8 sequences: Some clients send Latin-1/ISO-8859-1 encoded text (e.g. accented\n//     characters like é, ñ, ß) in HTTP headers or SMTP messages. Go treats these as raw bytes in\n//     strings, but PostgreSQL rejects them. Any invalid UTF-8 byte is replaced with the Unicode\n//     replacement character (U+FFFD, \"�\") so the message is still delivered rather than lost.\n//\n//  2. NUL bytes (0x00): These are valid in UTF-8 but PostgreSQL TEXT columns reject them.\n//     They are stripped entirely.\nfunc SanitizeUTF8(s string) string {\n\tif !utf8.ValidString(s) {\n\t\ts = strings.ToValidUTF8(s, \"\\xef\\xbf\\xbd\") // U+FFFD\n\t}\n\tif strings.ContainsRune(s, 0) {\n\t\ts = strings.ReplaceAll(s, \"\\x00\", \"\")\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "util/util_test.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/netip\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"golang.org/x/time/rate\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestRandomString(t *testing.T) {\n\ts1 := RandomString(10)\n\ts2 := RandomString(10)\n\ts3 := RandomString(12)\n\trequire.Equal(t, 10, len(s1))\n\trequire.Equal(t, 10, len(s2))\n\trequire.Equal(t, 12, len(s3))\n\trequire.NotEqual(t, s1, s2)\n}\n\nfunc TestFileExists(t *testing.T) {\n\tfilename := filepath.Join(t.TempDir(), \"somefile.txt\")\n\trequire.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))\n\trequire.True(t, FileExists(filename))\n\trequire.False(t, FileExists(filename+\".doesnotexist\"))\n}\n\nfunc TestInStringList(t *testing.T) {\n\ts := []string{\"one\", \"two\"}\n\trequire.True(t, Contains(s, \"two\"))\n\trequire.False(t, Contains(s, \"three\"))\n}\n\nfunc TestInStringListAll(t *testing.T) {\n\ts := []string{\"one\", \"two\", \"three\", \"four\"}\n\trequire.True(t, ContainsAll(s, []string{\"two\", \"four\"}))\n\trequire.False(t, ContainsAll(s, []string{\"three\", \"five\"}))\n}\n\nfunc TestContains(t *testing.T) {\n\ts := []int{1, 2}\n\trequire.True(t, Contains(s, 2))\n\trequire.False(t, Contains(s, 3))\n}\n\nfunc TestContainsAll(t *testing.T) {\n\trequire.True(t, ContainsAll([]int{1, 2, 3}, []int{2, 3}))\n\trequire.False(t, ContainsAll([]int{1, 1}, []int{1, 2}))\n}\n\nfunc TestContainsIP(t *testing.T) {\n\trequire.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix(\"fd00::/8\"), netip.MustParsePrefix(\"1.1.0.0/16\")}, netip.MustParseAddr(\"1.1.1.1\")))\n\trequire.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix(\"fd00::/8\"), netip.MustParsePrefix(\"1.1.0.0/16\")}, netip.MustParseAddr(\"fd12:1234:5678::9876\")))\n\trequire.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix(\"fd00::/8\"), netip.MustParsePrefix(\"1.1.0.0/16\")}, netip.MustParseAddr(\"1.2.0.1\")))\n\trequire.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix(\"fd00::/8\"), netip.MustParsePrefix(\"1.1.0.0/16\")}, netip.MustParseAddr(\"fc00::1\")))\n}\n\nfunc TestSplitNoEmpty(t *testing.T) {\n\trequire.Equal(t, []string{}, SplitNoEmpty(\"\", \",\"))\n\trequire.Equal(t, []string{}, SplitNoEmpty(\",,,\", \",\"))\n\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, SplitNoEmpty(\"tag1,tag2\", \",\"))\n\trequire.Equal(t, []string{\"tag1\", \"tag2\"}, SplitNoEmpty(\"tag1,tag2,\", \",\"))\n}\n\nfunc TestParsePriority(t *testing.T) {\n\tpriorities := []string{\"\", \"1\", \"2\", \"3\", \"4\", \"5\", \"min\", \"LOW\", \"   default \", \"HIgh\", \"max\", \"urgent\"}\n\texpected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}\n\tfor i, priority := range priorities {\n\t\tactual, err := ParsePriority(priority)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, expected[i], actual)\n\t}\n}\n\nfunc TestParsePriority_Invalid(t *testing.T) {\n\tpriorities := []string{\"-1\", \"6\", \"aa\", \"-\", \"o=1\"}\n\tfor _, priority := range priorities {\n\t\t_, err := ParsePriority(priority)\n\t\trequire.Equal(t, errInvalidPriority, err)\n\t}\n}\n\nfunc TestPriorityString(t *testing.T) {\n\tpriorities := []int{0, 1, 2, 3, 4, 5}\n\texpected := []string{\"default\", \"min\", \"low\", \"default\", \"high\", \"max\"}\n\tfor i, priority := range priorities {\n\t\tactual, err := PriorityString(priority)\n\t\trequire.Nil(t, err)\n\t\trequire.Equal(t, expected[i], actual)\n\t}\n}\n\nfunc TestPriorityString_Invalid(t *testing.T) {\n\t_, err := PriorityString(99)\n\trequire.Equal(t, err, errInvalidPriority)\n}\n\nfunc TestShortTopicURL(t *testing.T) {\n\trequire.Equal(t, \"ntfy.sh/mytopic\", ShortTopicURL(\"https://ntfy.sh/mytopic\"))\n\trequire.Equal(t, \"ntfy.sh/mytopic\", ShortTopicURL(\"http://ntfy.sh/mytopic\"))\n\trequire.Equal(t, \"lalala\", ShortTopicURL(\"lalala\"))\n}\n\nfunc TestParseSize_10GSuccess(t *testing.T) {\n\ts, err := ParseSize(\"10G\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, int64(10*1024*1024*1024), s)\n}\n\nfunc TestParseSize_10MUpperCaseSuccess(t *testing.T) {\n\ts, err := ParseSize(\"10M\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, int64(10*1024*1024), s)\n}\n\nfunc TestParseSize_10kLowerCaseSuccess(t *testing.T) {\n\ts, err := ParseSize(\"10k\")\n\trequire.Nil(t, err)\n\trequire.Equal(t, int64(10*1024), s)\n}\n\nfunc TestParseSize_FailureInvalid(t *testing.T) {\n\t_, err := ParseSize(\"not a size\")\n\trequire.Error(t, err)\n}\n\nfunc TestFormatSize(t *testing.T) {\n\tvalues := []struct {\n\t\tsize     int64\n\t\texpected string\n\t}{\n\t\t{10, \"10\"},\n\t\t{10 * 1024, \"10K\"},\n\t\t{10 * 1024 * 1024, \"10M\"},\n\t\t{10 * 1024 * 1024 * 1024, \"10G\"},\n\t}\n\tfor _, value := range values {\n\t\trequire.Equal(t, value.expected, FormatSize(value.size))\n\t\ts, err := ParseSize(FormatSize(value.size))\n\t\trequire.Nil(t, err)\n\t\trequire.Equalf(t, value.size, s, \"size does not match: %d != %d\", value.size, s)\n\t}\n}\n\nfunc TestFormatSize_Rounded(t *testing.T) {\n\trequire.Equal(t, \"10K\", FormatSize(10*1024+999))\n}\n\nfunc TestSplitKV(t *testing.T) {\n\tkey, value := SplitKV(\" key = value \", \"=\")\n\trequire.Equal(t, \"key\", key)\n\trequire.Equal(t, \"value\", value)\n\n\tkey, value = SplitKV(\" value \", \"=\")\n\trequire.Equal(t, \"\", key)\n\trequire.Equal(t, \"value\", value)\n\n\tkey, value = SplitKV(\"mykey=value=with=separator \", \"=\")\n\trequire.Equal(t, \"mykey\", key)\n\trequire.Equal(t, \"value=with=separator\", value)\n}\n\nfunc TestQuoteCommand(t *testing.T) {\n\trequire.Equal(t, `ls -al \"Document Folder\"`, QuoteCommand([]string{\"ls\", \"-al\", \"Document Folder\"}))\n\trequire.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{\"rsync\", \"-av\", \"/home/phil/\", \"root@example.com:/home/phil/\"}))\n\trequire.Equal(t, `/home/sweet/home \"Äöü this is a test\" \"\\a\\b\"`, QuoteCommand([]string{\"/home/sweet/home\", \"Äöü this is a test\", \"\\\\a\\\\b\"}))\n}\n\nfunc TestBasicAuth(t *testing.T) {\n\trequire.Equal(t, \"Basic cGhpbDpwaGls\", BasicAuth(\"phil\", \"phil\"))\n}\n\nfunc TestBearerAuth(t *testing.T) {\n\trequire.Equal(t, \"Bearer sometoken\", BearerAuth(\"sometoken\"))\n}\n\ntype testJSON struct {\n\tName      string `json:\"name\"`\n\tSomething int    `json:\"something\"`\n}\n\nfunc TestReadJSON_Success(t *testing.T) {\n\tv, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{\"name\":\"some name\",\"something\":99}`)))\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some name\", v.Name)\n\trequire.Equal(t, 99, v.Something)\n}\n\nfunc TestReadJSON_Failure(t *testing.T) {\n\t_, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{\"na`)))\n\trequire.Equal(t, ErrUnmarshalJSON, err)\n}\n\nfunc TestReadJSONWithLimit_Success(t *testing.T) {\n\tv, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{\"name\":\"some name\",\"something\":99}`)), 100, false)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"some name\", v.Name)\n\trequire.Equal(t, 99, v.Something)\n}\n\nfunc TestReadJSONWithLimit_FailureTooLong(t *testing.T) {\n\t_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{\"name\":\"some name\",\"something\":99}`)), 10, false)\n\trequire.Equal(t, ErrTooLargeJSON, err)\n}\n\nfunc TestReadJSONWithLimit_AllowEmpty(t *testing.T) {\n\tv, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true)\n\trequire.Nil(t, err)\n\trequire.Equal(t, \"\", v.Name)\n\trequire.Equal(t, 0, v.Something)\n}\n\nfunc TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) {\n\t_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false)\n\trequire.Equal(t, ErrUnmarshalJSON, err)\n}\n\nfunc TestRetry_Succeeds(t *testing.T) {\n\tstart := time.Now()\n\tdelays, i := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second}, 0\n\tfn := func() (*int, error) {\n\t\ti++\n\t\tif i < len(delays) {\n\t\t\treturn nil, errors.New(\"error\")\n\t\t}\n\t\treturn Int(99), nil\n\t}\n\tresult, err := Retry[int](fn, delays...)\n\trequire.Nil(t, err)\n\trequire.Equal(t, 99, *result)\n\trequire.True(t, time.Since(start).Milliseconds() > 150)\n}\n\nfunc TestRetry_Fails(t *testing.T) {\n\tfn := func() (*int, error) {\n\t\treturn nil, errors.New(\"fails\")\n\t}\n\t_, err := Retry[int](fn, 10*time.Millisecond)\n\trequire.Error(t, err)\n}\n\nfunc TestMinMax(t *testing.T) {\n\trequire.Equal(t, 10, MinMax(9, 10, 99))\n\trequire.Equal(t, 99, MinMax(100, 10, 99))\n\trequire.Equal(t, 50, MinMax(50, 10, 99))\n}\n\nfunc TestMax(t *testing.T) {\n\trequire.Equal(t, 9, Max(1, 9))\n\trequire.Equal(t, 9, Max(9, 1))\n\trequire.Equal(t, rate.Every(time.Minute), Max(rate.Every(time.Hour), rate.Every(time.Minute)))\n}\n\nfunc TestPointerFunctions(t *testing.T) {\n\ti, s, ti := Int(99), String(\"abc\"), Time(time.Unix(99, 0))\n\trequire.Equal(t, 99, *i)\n\trequire.Equal(t, \"abc\", *s)\n\trequire.Equal(t, time.Unix(99, 0), *ti)\n}\n\nfunc TestMaybeMarshalJSON(t *testing.T) {\n\trequire.Equal(t, `\"aa\"`, MaybeMarshalJSON(\"aa\"))\n\trequire.Equal(t, `[\n  \"aa\",\n  \"bb\"\n]`, MaybeMarshalJSON([]string{\"aa\", \"bb\"}))\n\trequire.Equal(t, \"<cannot serialize>\", MaybeMarshalJSON(func() {}))\n\trequire.Equal(t, `\"`+strings.Repeat(\"x\", 4999), MaybeMarshalJSON(strings.Repeat(\"x\", 6000)))\n\n}\n"
  },
  {
    "path": "web/.eslintignore",
    "content": "src/app/emojis.js"
  },
  {
    "path": "web/.eslintrc",
    "content": "{\n  \"extends\": [\"airbnb\", \"prettier\"],\n  \"env\": {\n    \"browser\": true\n  },\n  \"globals\": {\n    \"config\": \"readonly\"\n  },\n  \"parserOptions\": {\n    \"ecmaVersion\": 2023\n  },\n  \"rules\": {\n    \"no-console\": \"off\",\n    \"class-methods-use-this\": \"off\",\n    \"func-style\": [\"error\", \"expression\"],\n    \"no-restricted-syntax\": [\"error\", \"ForInStatement\", \"LabeledStatement\", \"WithStatement\"],\n    \"no-await-in-loop\": \"error\",\n    \"import/no-cycle\": \"warn\",\n    \"react/prop-types\": \"off\",\n    \"react/destructuring-assignment\": \"off\",\n    \"react/jsx-no-useless-fragment\": \"off\",\n    \"react/jsx-props-no-spreading\": \"off\",\n    \"react/jsx-no-duplicate-props\": [\n      \"error\",\n      {\n        \"ignoreCase\": false // For <TextField>'s [iI]nputProps\n      }\n    ],\n    \"react/function-component-definition\": [\n      \"error\",\n      {\n        \"namedComponents\": \"arrow-function\",\n        \"unnamedComponents\": \"arrow-function\"\n      }\n    ]\n  },\n  \"overrides\": [{ \"files\": [\"./public/sw.js\"], \"rules\": { \"no-restricted-globals\": \"off\" } }]\n}\n"
  },
  {
    "path": "web/.prettierignore",
    "content": "build/\ndist/\npublic/static/langs/\nsrc/app/emojis.js\n"
  },
  {
    "path": "web/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>ntfy web</title>\n\n    <!-- Mobile view -->\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\" />\n    <meta name=\"HandheldFriendly\" content=\"true\" />\n\n    <!-- Mobile browsers, background color -->\n    <meta name=\"theme-color\" content=\"#317f6f\" />\n    <meta name=\"msapplication-navbutton-color\" content=\"#317f6f\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"#317f6f\" />\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\" sizes=\"180x180\" />\n    <link rel=\"mask-icon\" href=\"/static/images/mask-icon.svg\" color=\"#317f6f\" />\n\n    <!-- Favicon, see favicon.io -->\n    <link rel=\"icon\" type=\"image/png\" href=\"/static/images/favicon.ico\" />\n\n    <!-- Previews in Google, Slack, WhatsApp, etc. -->\n    <meta\n      name=\"description\"\n      content=\"ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy.\"\n    />\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:locale\" content=\"en_US\" />\n    <meta property=\"og:site_name\" content=\"ntfy web\" />\n    <meta property=\"og:title\" content=\"ntfy web\" />\n    <meta\n      property=\"og:description\"\n      content=\"ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy.\"\n    />\n    <meta property=\"og:image\" content=\"/static/images/ntfy.png\" />\n    <meta property=\"og:url\" content=\"https://ntfy.sh\" />\n\n    <!-- Never index -->\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n\n    <!-- Style overrides & fonts -->\n    <link rel=\"stylesheet\" href=\"/static/css/app.css\" type=\"text/css\" />\n    <link rel=\"stylesheet\" href=\"/static/css/fonts.css\" type=\"text/css\" />\n\n    <!-- PWA -->\n    <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n  </head>\n  <body>\n    <noscript>\n      ntfy web requires JavaScript, but you can also use the\n      <a href=\"https://ntfy.sh/docs/subscribe/cli/\">CLI</a> or <a href=\"https://ntfy.sh/docs/subscribe/phone/\">Android/iOS app</a> to\n      subscribe.\n    </noscript>\n    <div id=\"root\"></div>\n    <script src=\"/config.js\"></script>\n    <script type=\"module\" src=\"/src/index.jsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"ntfy\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"start\": \"NODE_OPTIONS=\\\"--enable-source-maps\\\" vite\",\n    \"build\": \"vite build\",\n    \"serve\": \"vite preview\",\n    \"format\": \"prettier . --write\",\n    \"format:check\": \"prettier . --check\",\n    \"lint\": \"eslint --report-unused-disable-directives --ext .js,.jsx ./src/\"\n  },\n  \"dependencies\": {\n    \"@emotion/cache\": \"^11.11.0\",\n    \"@emotion/react\": \"^11.11.0\",\n    \"@emotion/styled\": \"^11.11.0\",\n    \"@mui/icons-material\": \"^5.4.2\",\n    \"@mui/material\": \"latest\",\n    \"dexie\": \"^3.2.1\",\n    \"dexie-react-hooks\": \"^1.1.1\",\n    \"humanize-duration\": \"^3.27.3\",\n    \"i18next\": \"^21.6.14\",\n    \"i18next-browser-languagedetector\": \"^6.1.4\",\n    \"i18next-http-backend\": \"^1.4.0\",\n    \"js-base64\": \"^3.7.2\",\n    \"react\": \"latest\",\n    \"react-dom\": \"latest\",\n    \"react-i18next\": \"^11.16.2\",\n    \"react-infinite-scroll-component\": \"^6.1.0\",\n    \"react-remark\": \"^2.1.0\",\n    \"react-router-dom\": \"^6.2.2\",\n    \"stacktrace-gps\": \"^3.0.4\",\n    \"stacktrace-js\": \"^2.0.2\",\n    \"stylis\": \"^4.3.0\",\n    \"stylis-plugin-rtl\": \"^2.1.1\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^4.0.0\",\n    \"eslint\": \"^8.41.0\",\n    \"eslint-config-airbnb\": \"^19.0.4\",\n    \"eslint-config-prettier\": \"^8.8.0\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-jsx-a11y\": \"^6.7.1\",\n    \"eslint-plugin-react\": \"^7.32.2\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"prettier\": \"^2.8.8\",\n    \"vite\": \"^6.3.5\",\n    \"vite-plugin-pwa\": \"^1.0.0\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"prettier\": {\n    \"printWidth\": 140\n  }\n}\n"
  },
  {
    "path": "web/public/config.js",
    "content": "// THIS FILE IS JUST AN EXAMPLE\n//\n// It is removed during the build process. The actual config is dynamically\n// generated server-side and served by the ntfy server.\n//\n// During web development, you may change values here for rapid testing.\n\nvar config = {\n  base_url: window.location.origin, // Change to test against a different server\n  app_root: \"/\",\n  enable_login: true,\n  require_login: false,\n  enable_signup: true,\n  enable_payments: false,\n  enable_reservations: true,\n  enable_emails: true,\n  enable_calls: true,\n  enable_web_push: true,\n  billing_contact: \"\",\n  web_push_public_key: \"\",\n  disallowed_topics: [\"docs\", \"static\", \"file\", \"app\", \"account\", \"settings\", \"signup\", \"login\", \"v1\"],\n  config_hash: \"dev\", // Placeholder for development; actual value is generated server-side\n};\n"
  },
  {
    "path": "web/public/static/css/app.css",
    "content": "/* web app styling overrides */\n\na,\na:visited {\n  color: #338574;\n}\n\na:hover {\n  text-decoration: none;\n  color: #317f6f;\n}\n"
  },
  {
    "path": "web/public/static/css/fonts.css",
    "content": "/* Roboto font, embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/roboto?subsets=latin */\n\n/* roboto-300 - latin */\n@font-face {\n  font-family: \"Roboto\";\n  font-style: normal;\n  font-weight: 300;\n  src: local(\"\"), url(\"../fonts/roboto-v29-latin-300.woff2\") format(\"woff2\");\n}\n\n/* roboto-regular - latin */\n@font-face {\n  font-family: \"Roboto\";\n  font-style: normal;\n  font-weight: 400;\n  src: local(\"\"), url(\"../fonts/roboto-v29-latin-regular.woff2\") format(\"woff2\");\n}\n\n/* roboto-500 - latin */\n@font-face {\n  font-family: \"Roboto\";\n  font-style: normal;\n  font-weight: 500;\n  src: local(\"\"), url(\"../fonts/roboto-v29-latin-500.woff2\") format(\"woff2\");\n}\n\n/* roboto-700 - latin */\n@font-face {\n  font-family: \"Roboto\";\n  font-style: normal;\n  font-weight: 700;\n  src: local(\"\"), url(\"../fonts/roboto-v29-latin-700.woff2\") format(\"woff2\");\n}\n"
  },
  {
    "path": "web/public/static/langs/ar.json",
    "content": "{\n    \"action_bar_logo_alt\": \"شعار ntfy\",\n    \"action_bar_settings\": \"اﻹعدادات\",\n    \"action_bar_clear_notifications\": \"امحُ كل الإشعارات\",\n    \"action_bar_unsubscribe\": \"إلغاء الاشتراك\",\n    \"message_bar_show_dialog\": \"إظهار مربع حوار النشر\",\n    \"message_bar_publish\": \"نشر الرسالة\",\n    \"nav_topics_title\": \"المواضيع المشترك فيها\",\n    \"nav_button_all_notifications\": \"كل الإشعارات\",\n    \"nav_button_settings\": \"اﻹعدادات\",\n    \"nav_button_documentation\": \"الدليل\",\n    \"nav_button_publish_message\": \"نشر الإشعار\",\n    \"nav_button_subscribe\": \"اشترك في الموضوع\",\n    \"nav_button_connecting\": \"جارٍ الاتصال\",\n    \"alert_notification_permission_required_title\": \"عُطّلت الإشعارات\",\n    \"alert_notification_permission_required_description\": \"امنح متصفحك الإذن لعرض إشعارات سطح المكتب.\",\n    \"notifications_list\": \"قائمة الإشعارات\",\n    \"notifications_list_item\": \"إشعار\",\n    \"notifications_mark_read\": \"وضع علامة كمقروء\",\n    \"notifications_tags\": \"الوسوم\",\n    \"notifications_priority_x\": \"الأولوية {{priority}}\",\n    \"notifications_new_indicator\": \"إشعار جديد\",\n    \"notifications_attachment_image\": \"صورة مرفقة\",\n    \"notifications_attachment_copy_url_button\": \"انسخ عنوان URL\",\n    \"notifications_attachment_open_title\": \"انتقل إلى {{url}}\",\n    \"notifications_attachment_link_expires\": \"تنتهي صلاحية الرابط {{date}}\",\n    \"notifications_attachment_link_expired\": \"انتهت صلاحية رابط التنزيل\",\n    \"notifications_attachment_file_image\": \"ملف الصورة\",\n    \"notifications_attachment_file_video\": \"ملف فيديو\",\n    \"notifications_attachment_file_audio\": \"ملف صوتي\",\n    \"notifications_attachment_file_app\": \"ملف تطبيق Android\",\n    \"notifications_attachment_file_document\": \"وثيقة أخرى\",\n    \"notifications_click_copy_url_button\": \"انسخ الرابط\",\n    \"notifications_click_open_button\": \"فتح الرابط\",\n    \"notifications_actions_open_url_title\": \"انتقل إلى {{url}}\",\n    \"notifications_actions_not_supported\": \"هذا الإجراء غير مدعوم في تطبيق الويب\",\n    \"action_bar_send_test_notification\": \"إرسال إشعار للاختبار\",\n    \"action_bar_show_menu\": \"اعرض القائمة\",\n    \"message_bar_type_message\": \"اكتب رسالة هنا\",\n    \"alert_not_supported_title\": \"الإشعارات غير مدعومة\",\n    \"alert_not_supported_description\": \"الإشعارات غير مدعومة في متصفحك.\",\n    \"message_bar_error_publishing\": \"خطأ خلال نشر الإشعار\",\n    \"notifications_delete\": \"حذف\",\n    \"notifications_copied_to_clipboard\": \"نُسخ إلى الحافظة\",\n    \"action_bar_toggle_mute\": \"اكتم / ألغِ كتم الإشعارات\",\n    \"action_bar_toggle_action_menu\": \"فتح/إغلاق قائمة الإجراءات\",\n    \"alert_notification_permission_required_button\": \"امنح الآن\",\n    \"notifications_attachment_open_button\": \"فتح المرفق\",\n    \"notifications_attachment_copy_url_title\": \"انسخ عنوان URL للمرفق إلى الحافظة\",\n    \"notifications_click_copy_url_title\": \"انسخ رابط URL إلى الحافظة\",\n    \"notifications_none_for_topic_title\": \"لم تتلق بعد أية إشعارات حول هذا الموضوع.\",\n    \"notifications_none_for_any_title\": \"لم تتلق أية إشعارات.\",\n    \"notifications_no_subscriptions_title\": \"يبدو أنك لا تملك أي اشتراكات بعد.\",\n    \"notifications_example\": \"مثال\",\n    \"notifications_loading\": \"تحميل الإشعارات…\",\n    \"publish_dialog_title_topic\": \"أنشُر إلى {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"انشُر الإشعار\",\n    \"publish_dialog_emoji_picker_show\": \"اختر رمزًا تعبيريًا\",\n    \"publish_dialog_priority_min\": \"أولوية دنيا\",\n    \"publish_dialog_priority_low\": \"أولوية منخفضة\",\n    \"publish_dialog_priority_default\": \"الأولوية الافتراضية\",\n    \"publish_dialog_priority_high\": \"أولوية عالية\",\n    \"publish_dialog_base_url_label\": \"عنوان URL للخدمة\",\n    \"publish_dialog_priority_max\": \"أولوية قصوى\",\n    \"publish_dialog_topic_placeholder\": \"اسم الموضوع، على سبيل المثال phil_alerts\",\n    \"publish_dialog_title_label\": \"العنوان\",\n    \"publish_dialog_title_placeholder\": \"عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص\",\n    \"publish_dialog_message_label\": \"الرسالة\",\n    \"publish_dialog_message_placeholder\": \"اكتب رسالة هنا\",\n    \"publish_dialog_tags_label\": \"الوسوم\",\n    \"publish_dialog_priority_label\": \"الأولوية\",\n    \"publish_dialog_click_placeholder\": \"العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار\",\n    \"publish_dialog_email_label\": \"البريد الإلكتروني\",\n    \"publish_dialog_filename_label\": \"اسم الملف\",\n    \"publish_dialog_attach_label\": \"الرابط التشعبي URL للمرفق\",\n    \"publish_dialog_filename_placeholder\": \"اسم ملف المرفق\",\n    \"publish_dialog_delay_label\": \"تأخير\",\n    \"publish_dialog_delay_reset\": \"أزل تأخر التوصيل\",\n    \"publish_dialog_chip_click_label\": \"انقر على عنوان URL\",\n    \"publish_dialog_chip_email_label\": \"إعادة التوجيه إلى البريد الإلكتروني\",\n    \"publish_dialog_chip_attach_file_label\": \"إرفاق ملف محلي\",\n    \"publish_dialog_chip_topic_label\": \"تغيير الموضوع\",\n    \"publish_dialog_button_cancel_sending\": \"ألغِ الإرسال\",\n    \"publish_dialog_button_send\": \"أرسل\",\n    \"publish_dialog_checkbox_publish_another\": \"نشر آخر\",\n    \"publish_dialog_attached_file_title\": \"الملف المرفق:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"اسم الملف المرفق\",\n    \"publish_dialog_attached_file_remove\": \"أزل الملف المرفق\",\n    \"publish_dialog_drop_file_here\": \"قم بإسقاط ملف هنا\",\n    \"emoji_picker_search_placeholder\": \"البحث عن رمز تعبيري\",\n    \"emoji_picker_search_clear\": \"امحُ البحث\",\n    \"subscribe_dialog_subscribe_title\": \"الإشتراك في الموضوع\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"استخدام خادم آخر\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"الرابط التشعبي URL للخدمة\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"اشترِك\",\n    \"subscribe_dialog_login_title\": \"تسجيل الدخول مطلوب\",\n    \"subscribe_dialog_login_username_label\": \"اسم المستخدم، على سبيل المثال phil\",\n    \"subscribe_dialog_login_password_label\": \"كلمة السر\",\n    \"subscribe_dialog_login_button_login\": \"الولوج\",\n    \"subscribe_dialog_error_user_anonymous\": \"مجهول\",\n    \"prefs_notifications_title\": \"الإشعارات\",\n    \"prefs_notifications_sound_title\": \"صوت الإشعار\",\n    \"prefs_notifications_sound_no_sound\": \"لا صوت\",\n    \"prefs_notifications_min_priority_description_any\": \"عرض جميع الإشعارات، بغض النظر عن الأولوية\",\n    \"prefs_notifications_delete_after_title\": \"حذف الإشعارات\",\n    \"prefs_notifications_delete_after_never\": \"أبداً\",\n    \"prefs_notifications_delete_after_three_hours\": \"بعد ثلاث ساعات\",\n    \"prefs_notifications_delete_after_one_day\": \"بعد يوم واحد\",\n    \"prefs_notifications_delete_after_one_month\": \"بعد شهر واحد\",\n    \"prefs_notifications_delete_after_never_description\": \"لا تُحذف الإشعارات تلقائيًا مطلقًا\",\n    \"prefs_notifications_delete_after_one_week_description\": \"تُحذف الإشعارات تلقائيًا بعد أسبوع واحد\",\n    \"prefs_notifications_delete_after_one_month_description\": \"تُحذف الإشعارات تلقائيًا بعد شهر واحد\",\n    \"prefs_users_table\": \"قائمة المستخدمين\",\n    \"prefs_users_edit_button\": \"تعديل المستخدم\",\n    \"prefs_users_table_user_header\": \"المستخدم\",\n    \"prefs_users_table_base_url_header\": \"الرابط التشعبي للخدمة\",\n    \"priority_default\": \"افتراضية\",\n    \"prefs_users_dialog_username_label\": \"اسم المستخدم، على سبيل المثال phil\",\n    \"prefs_users_dialog_button_cancel\": \"إلغاء\",\n    \"prefs_users_dialog_button_add\": \"اضافة\",\n    \"prefs_users_dialog_button_save\": \"حفظ\",\n    \"prefs_appearance_title\": \"المظهر\",\n    \"prefs_appearance_language_title\": \"اللغة\",\n    \"error_boundary_gathering_info\": \"جمع مزيد من المعلومات …\",\n    \"error_boundary_unsupported_indexeddb_title\": \"التصفح الخاص غير مدعوم\",\n    \"priority_high\": \"عالية\",\n    \"priority_max\": \"قصوى\",\n    \"error_boundary_title\": \"أوه لا ، لقد تحطم ntfy\",\n    \"prefs_users_delete_button\": \"حذف المستخدم\",\n    \"prefs_users_add_button\": \"أضف مستخدم\",\n    \"prefs_notifications_min_priority_any\": \"مهما كانت الأولوية\",\n    \"prefs_notifications_delete_after_one_week\": \"بعد أسبوع واحد\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"تُحذف الإشعارات تلقائيًا بعد ثلاث ساعات\",\n    \"prefs_notifications_delete_after_one_day_description\": \"تُحذف الإشعارات تلقائيًا بعد يوم واحد\",\n    \"prefs_users_title\": \"إدارة المستخدمين\",\n    \"prefs_users_dialog_title_add\": \"أضف مستخدم\",\n    \"prefs_users_dialog_title_edit\": \"تعديل المستخدم\",\n    \"prefs_users_dialog_base_url_label\": \"عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh\",\n    \"publish_dialog_button_cancel\": \"ألغِ\",\n    \"publish_dialog_message_published\": \"تم نشر الإشعار\",\n    \"prefs_users_dialog_password_label\": \"كلمة السر\",\n    \"publish_dialog_base_url_placeholder\": \"عنوان URL للخدمة، على سبيل المثال، https://example.com\",\n    \"publish_dialog_progress_uploading\": \"جارٍ التحميل…\",\n    \"publish_dialog_topic_label\": \"اسم الموضوع\",\n    \"publish_dialog_topic_reset\": \"إعادة تعيين الموضوع\",\n    \"publish_dialog_email_reset\": \"أزل إعادة توجيه البريد الإلكتروني\",\n    \"publish_dialog_email_placeholder\": \"عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com\",\n    \"publish_dialog_other_features\": \"ميزات أخرى:\",\n    \"publish_dialog_chip_attach_url_label\": \"إرفاق ملف عن طريق عنوان URL\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"اسم الموضوع، على سبيل المثال phil_alerts\",\n    \"prefs_notifications_sound_description_none\": \"لا تصدر الإشعارات أي صوت عند وصولها\",\n    \"publish_dialog_chip_delay_label\": \"تأخير التوصيل\",\n    \"subscribe_dialog_login_description\": \"هذا الموضوع محمي بكلمة سر. الرجاء إدخال اسم المستخدم وكلمة السر للاشتراك.\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"ألغِ\",\n    \"common_back\": \"ارجع\",\n    \"prefs_notifications_sound_play\": \"تشغيل الصوت المحدد\",\n    \"prefs_notifications_min_priority_title\": \"أولوية دنيا\",\n    \"prefs_notifications_min_priority_max_only\": \"الأولوية القصوى فقط\",\n    \"notifications_no_subscriptions_description\": \"انقر فوق الرابط \\\"{{linktext}}\\\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.\",\n    \"publish_dialog_click_label\": \"الرابط التشعبي URL للنقر\",\n    \"publish_dialog_tags_placeholder\": \"قائمة العلامات مفصولة بفواصل، على سبيل المثال: تحذير، srv1-backup\",\n    \"publish_dialog_attach_placeholder\": \"إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"أزل عنوان URL للمرفق\",\n    \"subscribe_dialog_error_user_not_authorized\": \"المستخدم {{username}} غير مصرح به\",\n    \"common_save\": \"احفظ\",\n    \"common_add\": \"أضف\",\n    \"signup_form_username\": \"اسم المستخدم\",\n    \"signup_form_confirm_password\": \"أكِّد كلمة السر\",\n    \"login_title\": \"تسجيل الدخول إلى حسابك ntfy\",\n    \"login_form_button_submit\": \"الولوج\",\n    \"login_link_signup\": \"إنشاء حساب\",\n    \"login_disabled\": \"تم تعطيل تسجيل الدخول\",\n    \"action_bar_account\": \"الحساب\",\n    \"action_bar_change_display_name\": \"غيّر الإسم المعروض\",\n    \"signup_error_creation_limit_reached\": \"تم بلوغ حد إنشاء الحسابات\",\n    \"action_bar_reservation_add\": \"حجز الموضوع\",\n    \"action_bar_reservation_edit\": \"تغيير الحجز\",\n    \"action_bar_profile_title\": \"الملف التعريفي\",\n    \"action_bar_profile_settings\": \"اﻹعدادات\",\n    \"action_bar_profile_logout\": \"اخرج\",\n    \"action_bar_sign_in\": \"الولوج\",\n    \"action_bar_sign_up\": \"أنشئ حساب\",\n    \"nav_button_account\": \"الحساب\",\n    \"nav_upgrade_banner_label\": \"قم بالترقية إلى NTFY Pro\",\n    \"reserve_dialog_checkbox_label\": \"حجز الموضوع وإعداد الوصول\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"ولِّد اسم\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"الموضوع محجوز بالفعل\",\n    \"account_basics_title\": \"الحساب\",\n    \"account_basics_username_title\": \"إسم المستخدم\",\n    \"account_basics_username_description\": \"مرحبًا، هذا أنت ❤\",\n    \"account_basics_username_admin_tooltip\": \"أنت مدير\",\n    \"account_basics_password_title\": \"كلمة السر\",\n    \"account_basics_password_description\": \"غيّر كلمة سر حسابك\",\n    \"account_basics_password_dialog_title\": \"غيّر كلمة السر\",\n    \"account_basics_password_dialog_current_password_label\": \"كلمة السر الحالية\",\n    \"account_basics_password_dialog_new_password_label\": \"كلمة السر جديدة\",\n    \"account_basics_password_dialog_confirm_password_label\": \"أكِّد كلمة السر\",\n    \"account_basics_password_dialog_button_submit\": \"غيّر كلمة السر\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"كلمة السر غير صحيحة\",\n    \"account_usage_title\": \"الإستخدام\",\n    \"account_usage_of_limit\": \"من {{limit}}\",\n    \"account_usage_unlimited\": \"غير محدود\",\n    \"account_basics_tier_title\": \"نوع الحساب\",\n    \"account_basics_tier_description\": \"مستوى قوة حسابك\",\n    \"account_basics_tier_admin\": \"مدير\",\n    \"account_basics_tier_free\": \"مجاني\",\n    \"account_basics_tier_upgrade_button\": \"الترقية إلى Pro\",\n    \"account_basics_tier_change_button\": \"تغيير\",\n    \"account_basics_tier_manage_billing_button\": \"إدارة الفوترة\",\n    \"account_usage_messages_title\": \"الرسائل المنشورة\",\n    \"account_usage_reservations_title\": \"المواضيع المحجوزة\",\n    \"account_usage_attachment_storage_title\": \"تخزين المرفقات\",\n    \"account_delete_title\": \"حذف الحساب\",\n    \"account_delete_description\": \"احذف حسابك نهائيا\",\n    \"account_delete_dialog_label\": \"كلمة السر\",\n    \"account_upgrade_dialog_title\": \"تغيير فئة الحساب\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} رسائل يومية\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} من رسائل البريد الإلكتروني اليومية\",\n    \"account_upgrade_dialog_button_cancel\": \"ألغِ\",\n    \"account_upgrade_dialog_button_pay_now\": \"ادفع الآن واشترك\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"ألغِ الاشتراك\",\n    \"account_tokens_title\": \"رموز الوصول\",\n    \"account_tokens_table_token_header\": \"الرمز المميز\",\n    \"account_tokens_table_last_access_header\": \"آخر وصول\",\n    \"account_tokens_table_expires_header\": \"تنتهي مدة صلاحيته في\",\n    \"account_tokens_table_never_expires\": \"لا تنتهي صلاحيتها أبدا\",\n    \"account_tokens_table_current_session\": \"جلسة المتصفح الحالية\",\n    \"common_copy_to_clipboard\": \"انسخ إلى الحافظة\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية\",\n    \"account_tokens_table_create_token_button\": \"إنشاء رمز مميز للوصول\",\n    \"account_tokens_table_last_origin_tooltip\": \"من عنوان IP {{ip}}، انقر للبحث\",\n    \"account_tokens_dialog_title_create\": \"إنشاء رمز مميز للوصول\",\n    \"account_tokens_dialog_title_edit\": \"تعديل الرمز المميز للوصول\",\n    \"account_tokens_dialog_title_delete\": \"حذف الرمز المميز للوصول\",\n    \"account_tokens_dialog_label\": \"التسمية، على سبيل المثال إشعارات الرادار\",\n    \"account_tokens_dialog_button_create\": \"إنشاء رمز مميز\",\n    \"account_tokens_dialog_button_update\": \"تحديث الرمز المميز\",\n    \"account_tokens_dialog_button_cancel\": \"ألغِ\",\n    \"account_tokens_dialog_expires_label\": \"تنتهي صلاحية الرمز المميز للوصول في\",\n    \"account_tokens_dialog_expires_unchanged\": \"اترك تاريخ انتهاء الصلاحية دون تغيير\",\n    \"account_tokens_dialog_expires_x_hours\": \"تنتهي صلاحية الرمز المميز في {{hours}} ساعات\",\n    \"account_tokens_dialog_expires_never\": \"لا تنتهي صلاحية الرمز المميز أبدًا\",\n    \"account_tokens_delete_dialog_title\": \"حذف الرمز المميز للوصول\",\n    \"account_tokens_delete_dialog_submit_button\": \"حذف الرمز المميز نهائيا\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول\",\n    \"prefs_reservations_add_button\": \"أضف موضوع محجوز\",\n    \"prefs_reservations_table\": \"جدول المواضيع المحجوزة\",\n    \"prefs_reservations_table_topic_header\": \"الموضوع\",\n    \"prefs_reservations_table_access_header\": \"الوصول\",\n    \"prefs_reservations_table_everyone_deny_all\": \"أنا فقط من يستطيع النشر والاشتراك\",\n    \"prefs_reservations_table_everyone_write_only\": \"يمكنني النشر والاشتراك ، ويمكن للجميع النشر\",\n    \"prefs_reservations_table_everyone_read_write\": \"يمكن للجميع النشر والاشتراك\",\n    \"prefs_reservations_table_not_subscribed\": \"غير مشترك\",\n    \"prefs_reservations_dialog_title_edit\": \"تحرير الموضوع المحجوز\",\n    \"prefs_reservations_dialog_topic_label\": \"الموضوع\",\n    \"prefs_reservations_dialog_access_label\": \"الوصول\",\n    \"reservation_delete_dialog_action_delete_title\": \"حذف الرسائل والمرفقات المخزنة مؤقتا\",\n    \"reservation_delete_dialog_submit_button\": \"حذف الحجز\",\n    \"signup_title\": \"أنشئ حساب ntfy\",\n    \"common_cancel\": \"ألغِ\",\n    \"signup_form_password\": \"كلمة السر\",\n    \"signup_already_have_account\": \"هل لديك حساب؟ قم بتسجيل الدخول!\",\n    \"signup_form_button_submit\": \"أنشئ حساب\",\n    \"signup_disabled\": \"عُطّل التسجيل\",\n    \"display_name_dialog_placeholder\": \"الإسم المعروض\",\n    \"display_name_dialog_title\": \"تغيير الإسم المعروض\",\n    \"account_basics_tier_basic\": \"أساسي\",\n    \"account_usage_emails_title\": \"رسائل البريد الإلكتروني المرسلة\",\n    \"account_usage_reservations_none\": \"لا توجد مواضيع محجوزة لهذا الحساب\",\n    \"account_usage_cannot_create_portal_session\": \"تعذر فتح بوابة الفوترة\",\n    \"account_delete_dialog_button_cancel\": \"ألغِ\",\n    \"account_delete_dialog_button_submit\": \"حذف الحساب نهائيا\",\n    \"account_upgrade_dialog_button_update_subscription\": \"تحديث الاشتراك\",\n    \"account_tokens_table_copied_to_clipboard\": \"تم نسخ الرمز المميز للوصول\",\n    \"prefs_reservations_title\": \"المواضيع المحجوزة\",\n    \"prefs_reservations_table_everyone_read_only\": \"يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك\",\n    \"prefs_reservations_table_click_to_subscribe\": \"انقر للاشتراك\",\n    \"reservation_delete_dialog_action_keep_title\": \"الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا\",\n    \"action_bar_reservation_delete\": \"أزل الحجز\",\n    \"display_name_dialog_description\": \"قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.\",\n    \"prefs_users_description\": \"إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة السر في التخزين المحلي للمتصفح.\",\n    \"notifications_more_details\": \"لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.\",\n    \"publish_dialog_details_examples_description\": \"للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.\",\n    \"subscribe_dialog_subscribe_description\": \"قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \\\"PUT/POST\\\".\",\n    \"prefs_notifications_sound_description_some\": \"تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها\",\n    \"notifications_none_for_topic_description\": \"لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.\",\n    \"priority_low\": \"منخفضة\",\n    \"signup_form_toggle_password_visibility\": \"تبديل رؤية كلمة السر\",\n    \"account_usage_limits_reset_daily\": \"يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)\",\n    \"account_tokens_table_label_header\": \"المُلصَقة\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"تسجيل فوري\",\n    \"account_upgrade_dialog_tier_current_label\": \"الحالي\",\n    \"account_tokens_dialog_expires_x_days\": \"تنتهي صلاحية الرمز المميز في غضون {{days}} أيام\",\n    \"prefs_reservations_dialog_title_add\": \"احجز موضوع\",\n    \"prefs_reservations_description\": \"يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.\",\n    \"prefs_users_description_no_sync\": \"لا تتم مزامنة المستخدمين وكلمات السر مع حسابك.\",\n    \"reservation_delete_dialog_action_delete_description\": \"سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.\",\n    \"notifications_actions_http_request_title\": \"إرسال طلب HTTP {{method}} إلى {{url}}\",\n    \"notifications_none_for_any_description\": \"لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.\",\n    \"error_boundary_description\": \"من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا. <br/> إن كان لديك دقيقة، يرجى <githubLink> الإبلاغ عن ذلك على GitHub </githubLink> ، أو إعلامنا عبر <discordLink> Discord </discordLink> أو <matrixLink> Matrix </matrixLink>.\",\n    \"nav_button_muted\": \"الإشعارات المكتومة\",\n    \"priority_min\": \"دنيا\",\n    \"signup_error_username_taken\": \"تم حجز اسم المستخدم {{username}} بالفعل\",\n    \"action_bar_reservation_limit_reached\": \"بلغت الحد الأقصى\",\n    \"prefs_reservations_delete_button\": \"إعادة تعيين الوصول إلى الموضوع\",\n    \"prefs_reservations_edit_button\": \"تعديل الوصول إلى موضوع\",\n    \"prefs_reservations_limit_reached\": \"لقد بلغت الحد الأقصى من المواضيع المحجوزة.\",\n    \"reservation_delete_dialog_action_keep_description\": \"ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.\",\n    \"reservation_delete_dialog_description\": \"تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.\",\n    \"prefs_reservations_dialog_description\": \"يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"توفير ما يصل إلى {{discount}}٪\",\n    \"account_upgrade_dialog_interval_monthly\": \"شهريا\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"إجمالي مساحة التخزين {{totalsize}}\",\n    \"publish_dialog_progress_uploading_detail\": \"تحميل {{loaded}}/{{total}} ({{percent}}٪) …\",\n    \"account_basics_tier_interval_monthly\": \"شهريا\",\n    \"account_basics_tier_interval_yearly\": \"سنويا\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} مواضيع محجوزة\",\n    \"account_upgrade_dialog_billing_contact_website\": \"للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى <Link>موقعنا على الويب</Link>.\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى\",\n    \"account_upgrade_dialog_billing_contact_email\": \"للأسئلة المتعلقة بالفوترة، الرجاء <Link>الاتصال بنا</Link> مباشرة.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"المحدد\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} لكل ملف\",\n    \"account_upgrade_dialog_interval_yearly\": \"سنويا\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"لا توجد مواضيع محجوزة\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"وفر {{discount}}٪\",\n    \"publish_dialog_click_reset\": \"أزل الرابط URL للنقر\",\n    \"prefs_notifications_min_priority_description_max\": \"إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)\",\n    \"publish_dialog_attachment_limits_file_reached\": \"يتجاوز الحد الأقصى للملف {{fileSizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"يتجاوز الحصة، {{remainingBytes}} متبقية\",\n    \"account_basics_tier_paid_until\": \"تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا\",\n    \"account_basics_tier_canceled_subscription\": \"تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.\",\n    \"account_delete_dialog_billing_warning\": \"إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن.\",\n    \"nav_upgrade_banner_description\": \"حجز المواضيع والمزيد من الرسائل ورسائل البريد الإلكتروني والمرفقات الأكبر حجمًا\",\n    \"prefs_appearance_theme_dark\": \"الوضع الليلي\",\n    \"prefs_appearance_theme_light\": \"الوضع النهاري\",\n    \"publish_dialog_checkbox_markdown\": \"تنسيق على هيئة ماركداون\",\n    \"alert_not_supported_context_description\": \"الإشعارات مسموحة فقط على بروتوكول HTTPS المأمن, هذه القيود <mdnLink>خصائص الإشعارات</mdnLink>\",\n    \"publish_dialog_call_reset\": \"احذف اتصال بالهاتف\",\n    \"publish_dialog_call_label\": \"اتصال هاتفي\",\n    \"publish_dialog_chip_call_label\": \"اتصال هاتفي\",\n    \"publish_dialog_delay_placeholder\": \"تأخير التوصيل، مثال {{unixTimestamp}}، {{relativeTime}}، أو \\\"{{naturalLanguage}}\\\" (اللغة الإنجليزية فقط)\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"تجاوز حجم {{fileSizeLimit}} الملف, {{remainingBytes}} متبقي\",\n    \"prefs_reservations_dialog_title_delete\": \"حذف حجز موضوع\",\n    \"publish_dialog_call_item\": \"اتصل برقم الهاتف {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"لا يوجد ارقام هواتف معرفة\",\n    \"action_bar_mute_notifications\": \"كتم الإشعارات\",\n    \"action_bar_unmute_notifications\": \"ألغِ كتم الإشعارات\",\n    \"alert_notification_ios_install_required_description\": \"اضغط على زر المشاركة ثم إضافة إلى الصفحة الرئيسية لتستقبل الإشعارات على أجهزة أبل\",\n    \"alert_notification_ios_install_required_title\": \"يجب تثبيت الصفحة\",\n    \"alert_notification_permission_denied_description\": \"الرجاء اعادة منح الصلاحيات في المتصفح\",\n    \"alert_notification_permission_denied_title\": \"الإشعارات مغلقة\",\n    \"notifications_actions_failed_notification\": \"حدث غير منفذ\",\n    \"prefs_notifications_web_push_disabled\": \"ملغي\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"اتصل\",\n    \"account_basics_phone_numbers_title\": \"أرقام الهواتف\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"رسالة نصية قصيرة\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"رمز التأكيد\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"رقم الهاتف\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"اتصل بي\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"رمز التحقّق\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"شهر\",\n    \"prefs_appearance_theme_title\": \"السمة\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً\",\n    \"prefs_appearance_theme_system\": \"النظام (الافتراضي)\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"أولوية منخفضة وأعلى\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"الأولوية الافتراضية وما فوقها\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"أولوية عالية وأعلى\"\n}\n"
  },
  {
    "path": "web/public/static/langs/bg.json",
    "content": "{\n    \"action_bar_clear_notifications\": \"Премахване на известия\",\n    \"alert_notification_permission_required_description\": \"Разрешете на мрежовия четец да показва известия\",\n    \"notifications_attachment_copy_url_title\": \"Копиране на адреса на прикачения файл\",\n    \"notifications_example\": \"Пример\",\n    \"notifications_no_subscriptions_title\": \"Липсват абонаменти\",\n    \"nav_topics_title\": \"Абонаменти\",\n    \"action_bar_send_test_notification\": \"Пробно известие\",\n    \"action_bar_unsubscribe\": \"Отписване\",\n    \"nav_button_all_notifications\": \"Всички известия\",\n    \"action_bar_settings\": \"Настройки\",\n    \"publish_dialog_title_topic\": \"Публикуване в темата {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Изпращане\",\n    \"publish_dialog_progress_uploading\": \"Изпращане…\",\n    \"publish_dialog_progress_uploading_detail\": \"Изпращане {{loaded}}/{{total}} ({{percent}}%)…\",\n    \"publish_dialog_message_published\": \"Известието е публикувано\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"надвишава ограничението от {{fileSizeLimit}} за размер на файл и квотата, остават {{remainingBytes}}\",\n    \"publish_dialog_message_label\": \"Съобщение\",\n    \"publish_dialog_message_placeholder\": \"Въведете съобщение\",\n    \"publish_dialog_other_features\": \"Други възможности:\",\n    \"publish_dialog_chip_click_label\": \"Адрес\",\n    \"publish_dialog_chip_email_label\": \"Препращане към ел. поща\",\n    \"publish_dialog_chip_attach_url_label\": \"Прикачване на файл от адрес\",\n    \"publish_dialog_chip_attach_file_label\": \"Прикачване местен файл\",\n    \"publish_dialog_chip_delay_label\": \"Отложено изпращане\",\n    \"publish_dialog_chip_topic_label\": \"Промяна на темата\",\n    \"publish_dialog_button_cancel_sending\": \"Отменяне на изпращането\",\n    \"publish_dialog_button_cancel\": \"Отказ\",\n    \"subscribe_dialog_error_user_anonymous\": \"анонимен\",\n    \"prefs_notifications_title\": \"Известия\",\n    \"prefs_notifications_sound_title\": \"Звук при получаване\",\n    \"prefs_notifications_sound_no_sound\": \"Без звук\",\n    \"prefs_notifications_min_priority_title\": \"Най-нисък приоритет\",\n    \"prefs_notifications_min_priority_any\": \"Всички\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Нисък приоритет и по-висок\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Подразбиран приоритет и по-висок\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Висок приоритет и по-висок\",\n    \"prefs_notifications_min_priority_max_only\": \"Само най-висок приоритет\",\n    \"prefs_notifications_delete_after_never\": \"Никога\",\n    \"prefs_users_add_button\": \"Добавяне\",\n    \"prefs_users_dialog_password_label\": \"Парола\",\n    \"alert_not_supported_description\": \"Мрежовият четец не поддържа известия\",\n    \"message_bar_type_message\": \"Въведете съобщение\",\n    \"message_bar_error_publishing\": \"Грешка при изпращане на известието\",\n    \"notifications_copied_to_clipboard\": \"Копирано в междинната памет\",\n    \"notifications_attachment_link_expired\": \"препратката за изтегляне е с изтекла давност\",\n    \"nav_button_settings\": \"Настройки\",\n    \"nav_button_documentation\": \"Ръководство\",\n    \"nav_button_subscribe\": \"Абониране за тема\",\n    \"alert_notification_permission_required_title\": \"Известията са изключени\",\n    \"alert_notification_permission_required_button\": \"Разрешаване\",\n    \"notifications_tags\": \"Етикети\",\n    \"nav_button_publish_message\": \"Изпращане\",\n    \"alert_not_supported_title\": \"Не се поддържат известия\",\n    \"notifications_attachment_open_title\": \"Към {{url}}\",\n    \"notifications_attachment_copy_url_button\": \"Копиране на адреса\",\n    \"notifications_attachment_open_button\": \"Отваряне на прикачения файл\",\n    \"notifications_attachment_link_expires\": \"давността на препратката изтича на {{date}}\",\n    \"notifications_actions_open_url_title\": \"Към {{url}}\",\n    \"notifications_click_copy_url_button\": \"Копиране на препратка\",\n    \"notifications_click_open_button\": \"Отваряне\",\n    \"notifications_click_copy_url_title\": \"Копиране на препратката в междинната памет\",\n    \"notifications_none_for_topic_title\": \"Темата е все още празна\",\n    \"notifications_none_for_any_title\": \"Липсват известия\",\n    \"notifications_none_for_topic_description\": \"За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.\",\n    \"notifications_none_for_any_description\": \"За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.\",\n    \"notifications_no_subscriptions_description\": \"Щракнете върху „{{linktext}}“, за да създадете или да се абонирате за тема. След това като изпратите съобщение с методите PUT или POST ще го получите тук.\",\n    \"notifications_more_details\": \"За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.\",\n    \"publish_dialog_priority_min\": \"Най-нисък приоритет\",\n    \"publish_dialog_attachment_limits_file_reached\": \"надвишава ограничението от {{fileSizeLimit}} за размер на файл\",\n    \"publish_dialog_base_url_label\": \"Адрес на услугата\",\n    \"publish_dialog_base_url_placeholder\": \"Адрес на услугата, напр. https://example.com\",\n    \"publish_dialog_topic_placeholder\": \"Име на темата, напр. phils_alerts\",\n    \"publish_dialog_priority_low\": \"Нисък приоритет\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"надвишава квотата, остават {{remainingBytes}}\",\n    \"publish_dialog_priority_high\": \"Висок приоритет\",\n    \"publish_dialog_priority_default\": \"Подразбиран приоритет\",\n    \"publish_dialog_title_placeholder\": \"Заглавие на известието, напр. Предупреждение за дисково пространство\",\n    \"publish_dialog_tags_label\": \"Етикети\",\n    \"publish_dialog_email_label\": \"Адрес на електронна поща\",\n    \"publish_dialog_priority_max\": \"Най-висок приоритет\",\n    \"publish_dialog_tags_placeholder\": \"Разделени със запетая етикети, напр. warning, srv1-backup\",\n    \"publish_dialog_click_label\": \"Адрес\",\n    \"publish_dialog_topic_label\": \"Име на темата\",\n    \"publish_dialog_title_label\": \"Заглавие\",\n    \"publish_dialog_priority_label\": \"Приоритет\",\n    \"publish_dialog_click_placeholder\": \"Адрес, който се отваря при докосване на известието\",\n    \"publish_dialog_email_placeholder\": \"Адрес, към който да бъдат препращани известия, напр. phil@example.com\",\n    \"publish_dialog_attach_label\": \"Адрес на прикачения файл\",\n    \"publish_dialog_filename_placeholder\": \"Име на прикачения файл\",\n    \"publish_dialog_attach_placeholder\": \"Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk\",\n    \"prefs_notifications_delete_after_three_hours\": \"След три часа\",\n    \"publish_dialog_filename_label\": \"Име на файла\",\n    \"publish_dialog_delay_label\": \"Отлагане\",\n    \"publish_dialog_details_examples_description\": \"За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.\",\n    \"publish_dialog_button_send\": \"Изпращане\",\n    \"publish_dialog_checkbox_publish_another\": \"Изпращане на повече\",\n    \"publish_dialog_attached_file_title\": \"Прикачен файл:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Име на прикачения файл\",\n    \"publish_dialog_drop_file_here\": \"Пуснете файла тук\",\n    \"subscribe_dialog_subscribe_description\": \"Възможно е темите да не са защитени с парола, затова изберете име, което е трудно за отгатване. След като се абонирате, можете да изпращате известия чрез методите PUT или POST.\",\n    \"emoji_picker_search_placeholder\": \"Търсете емоция\",\n    \"subscribe_dialog_subscribe_title\": \"Абониране за тема\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Име на темата, напр. phils_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Използване на друг сървър\",\n    \"subscribe_dialog_login_username_label\": \"Потребител, напр. phil\",\n    \"common_back\": \"Назад\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Отказ\",\n    \"subscribe_dialog_login_description\": \"Темата е защитена. За да се абонирате въведете потребител и парола.\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Абониране\",\n    \"subscribe_dialog_login_title\": \"Изисква се вход\",\n    \"prefs_notifications_delete_after_title\": \"Автоматично премахване\",\n    \"prefs_notifications_delete_after_one_day\": \"След един ден\",\n    \"prefs_users_table_user_header\": \"Потребител\",\n    \"prefs_users_dialog_title_edit\": \"Промяна на потребител\",\n    \"prefs_users_dialog_base_url_label\": \"Адрес на услугата, e.g. https://ntfy.sh\",\n    \"common_cancel\": \"Отказ\",\n    \"common_save\": \"Запазване\",\n    \"prefs_appearance_language_title\": \"Език\",\n    \"subscribe_dialog_login_password_label\": \"Парола\",\n    \"subscribe_dialog_login_button_login\": \"Вход\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Потребителят {{username}} няма достъп\",\n    \"prefs_appearance_title\": \"Външен вид\",\n    \"publish_dialog_delay_placeholder\": \"Отложено изпращане, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)\",\n    \"prefs_notifications_delete_after_one_week\": \"След една седмица\",\n    \"prefs_users_title\": \"Управление на потребители\",\n    \"prefs_users_table_base_url_header\": \"Адрес на услугата\",\n    \"prefs_users_dialog_title_add\": \"Добавяне на потребител\",\n    \"prefs_notifications_delete_after_one_month\": \"След един месец\",\n    \"prefs_users_dialog_username_label\": \"Потребител, напр. phil\",\n    \"common_add\": \"Добавяне\",\n    \"error_boundary_title\": \"О, не, ntfy се срина\",\n    \"error_boundary_description\": \"Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_stack_trace\": \"Следа от стека\",\n    \"error_boundary_gathering_info\": \"Събиране на допълнителна информация…\",\n    \"notifications_loading\": \"Зареждане на известия…\",\n    \"error_boundary_button_copy_stack_trace\": \"Копиране на следата от стека\",\n    \"prefs_users_description\": \"Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.\",\n    \"prefs_notifications_sound_description_none\": \"Известията не са съпроводени със звук\",\n    \"prefs_notifications_sound_description_some\": \"При пристигане известията са съпроводени от звука „{{sound}}“\",\n    \"prefs_notifications_delete_after_never_description\": \"Известията никога не се премахват автоматично\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Известията се премахват автоматично след три часа\",\n    \"priority_min\": \"най-нисък\",\n    \"priority_low\": \"нисък\",\n    \"priority_high\": \"висок\",\n    \"priority_max\": \"най-висок\",\n    \"priority_default\": \"подразбиран\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Известията се премахват автоматично след една седмица\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Известията се премахват автоматично след един ден\",\n    \"prefs_notifications_min_priority_description_max\": \"Показват се известията с приоритет 5 (най-висок)\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Известията се премахват автоматично след един месец\",\n    \"prefs_notifications_min_priority_description_any\": \"Показват се всички известия, независимо от приоритета\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Показват се известията с приоритет {{number}} ({{name}}) или по-висок\",\n    \"notifications_actions_http_request_title\": \"Изпращане на HTTP {{method}} до {{url}}\",\n    \"notifications_actions_not_supported\": \"Действието не се поддържа от приложението за интернет\",\n    \"action_bar_show_menu\": \"Показване на менюто\",\n    \"action_bar_logo_alt\": \"Логотип на ntfy\",\n    \"action_bar_toggle_mute\": \"Заглушаване или пускане на известията\",\n    \"action_bar_toggle_action_menu\": \"Отваряне или затваряне на менюто с действията\",\n    \"nav_button_muted\": \"Известията са заглушени\",\n    \"notifications_list\": \"Списък с известия\",\n    \"notifications_list_item\": \"Известие\",\n    \"notifications_delete\": \"Премахване\",\n    \"notifications_mark_read\": \"Отбелязване като прочетено\",\n    \"nav_button_connecting\": \"свързване\",\n    \"message_bar_show_dialog\": \"Показване на диалога за публикуване\",\n    \"message_bar_publish\": \"Публикуване на съобщение\",\n    \"notifications_priority_x\": \"Приоритет {{priority}}\",\n    \"notifications_new_indicator\": \"Ново известие\",\n    \"notifications_attachment_image\": \"Прикачено изображение\",\n    \"notifications_attachment_file_image\": \"файл на изображение\",\n    \"notifications_attachment_file_video\": \"видео\",\n    \"notifications_attachment_file_audio\": \"аудио\",\n    \"notifications_attachment_file_app\": \"инсталационен файл на приложение за Android\",\n    \"notifications_attachment_file_document\": \"друг документ\",\n    \"publish_dialog_emoji_picker_show\": \"Избор на емоция\",\n    \"publish_dialog_topic_reset\": \"Нулиране на тема\",\n    \"publish_dialog_click_reset\": \"Премахване на адрес\",\n    \"publish_dialog_email_reset\": \"Премахване на препращането към ел. поща\",\n    \"publish_dialog_delay_reset\": \"Премахва отложеното на изпращане\",\n    \"publish_dialog_attached_file_remove\": \"Премахване на прикачения файл\",\n    \"emoji_picker_search_clear\": \"Изчистване на търсенето\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Адрес на услугата\",\n    \"prefs_notifications_sound_play\": \"Възпроизвеждане на избрания звук\",\n    \"publish_dialog_attach_reset\": \"Премахване на адреса на файла за прикачане\",\n    \"prefs_users_delete_button\": \"Премахване\",\n    \"prefs_users_table\": \"Таблица с потребители\",\n    \"prefs_users_edit_button\": \"Промяна на потребител\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Поверително разглеждане не се поддържа\",\n    \"error_boundary_unsupported_indexeddb_description\": \"За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.\",\n    \"signup_title\": \"Създаване на профил в ntfy\",\n    \"signup_form_username\": \"Потребител\",\n    \"signup_form_password\": \"Парола\",\n    \"signup_form_button_submit\": \"Регистриране\",\n    \"signup_form_toggle_password_visibility\": \"Превключване видимостта на паролата\",\n    \"signup_already_have_account\": \"Имате профил? Впишете се!\",\n    \"signup_error_username_taken\": \"Потребителското име {{username}} е заето\",\n    \"login_title\": \"Впишете се в профила си в ntfy\",\n    \"login_form_button_submit\": \"Вписване\",\n    \"login_link_signup\": \"Регистриране\",\n    \"login_disabled\": \"Вписването е изключено\",\n    \"action_bar_account\": \"Профил\",\n    \"action_bar_change_display_name\": \"Промяна на показваното име\",\n    \"action_bar_reservation_add\": \"Резервиране на тема\",\n    \"action_bar_reservation_delete\": \"Премахване на резервацията\",\n    \"action_bar_reservation_limit_reached\": \"Ограничението е достигнато\",\n    \"action_bar_profile_title\": \"Профил\",\n    \"action_bar_profile_settings\": \"Настройки\",\n    \"action_bar_profile_logout\": \"Изход\",\n    \"action_bar_sign_in\": \"Вписване\",\n    \"nav_button_account\": \"Профил\",\n    \"nav_upgrade_banner_label\": \"Надграждане до ntfy Pro\",\n    \"signup_form_confirm_password\": \"Парола отново\",\n    \"signup_disabled\": \"Регистрациите са затворени\",\n    \"signup_error_creation_limit_reached\": \"Достигнато е ограничението за създаване на профили\",\n    \"display_name_dialog_title\": \"Промяна на показваното име\",\n    \"action_bar_reservation_edit\": \"Промяна на резервацията\",\n    \"action_bar_sign_up\": \"Регистриране\",\n    \"account_basics_title\": \"Профил\",\n    \"alert_not_supported_context_description\": \"Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.\",\n    \"display_name_dialog_description\": \"Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Темата вече е резервирана\",\n    \"nav_upgrade_banner_description\": \"Резервиране на теми, повече съобщения и писма, по-големи прикачени файлове\",\n    \"display_name_dialog_placeholder\": \"Наименование\",\n    \"reserve_dialog_checkbox_label\": \"Резервиране на тема и настройки за достъп\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Произволно име\",\n    \"account_basics_username_title\": \"Потребител\",\n    \"account_basics_username_description\": \"Хей, това сте вие ❤\",\n    \"account_basics_username_admin_tooltip\": \"Вие сте администратор\",\n    \"account_basics_password_title\": \"Парола\",\n    \"account_delete_dialog_label\": \"Парола\",\n    \"account_basics_password_dialog_title\": \"Смяна на парола\",\n    \"account_basics_password_dialog_current_password_label\": \"Текуща парола\",\n    \"account_basics_password_dialog_new_password_label\": \"Нова парола\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Парола отново\",\n    \"account_basics_password_dialog_button_submit\": \"Смяна на парола\",\n    \"account_usage_title\": \"Употреба\",\n    \"account_usage_of_limit\": \"от {{limit}}\",\n    \"account_usage_unlimited\": \"Неограничено\",\n    \"account_usage_limits_reset_daily\": \"Ограниченията се нулират всеки ден в полунощ (UTC)\",\n    \"account_basics_tier_interval_monthly\": \"месечно\",\n    \"account_basics_tier_interval_yearly\": \"годишно\",\n    \"account_basics_password_description\": \"Промяна на паролата на профила\",\n    \"account_basics_tier_title\": \"Вид на профила\",\n    \"account_basics_tier_admin\": \"Администратор\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(с {{tier}} ниво)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(без ниво)\",\n    \"account_basics_tier_free\": \"безплатен\",\n    \"account_basics_tier_basic\": \"базов\",\n    \"account_basics_tier_change_button\": \"Променяне\",\n    \"account_basics_tier_paid_until\": \"Абонаментът е платен до {{date}} и автоматично ще се поднови\",\n    \"account_usage_attachment_storage_title\": \"Хранилище за прикачени файлове\",\n    \"account_delete_dialog_button_cancel\": \"Отказ\",\n    \"account_upgrade_dialog_interval_monthly\": \"Месечно\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} резервирани теми\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Без резервирани теми\",\n    \"account_tokens_dialog_button_cancel\": \"Отказ\",\n    \"account_delete_title\": \"Премахване на профила\",\n    \"account_upgrade_dialog_title\": \"Промяна нивото на профила\",\n    \"account_usage_emails_title\": \"Изпратени електронни писма\",\n    \"account_usage_reservations_title\": \"Резервирани теми\",\n    \"account_usage_reservations_none\": \"Няма резервирани теми\",\n    \"account_usage_cannot_create_portal_session\": \"Порталът за разплащане не може да бъде отворен\",\n    \"account_upgrade_dialog_interval_yearly\": \"Годишно\",\n    \"account_delete_description\": \"Безвъзвратно премахване на профила\",\n    \"account_delete_dialog_button_submit\": \"Безвъзвратно премахване на профила\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"отстъпка {{discount}}%\",\n    \"account_upgrade_dialog_button_cancel\": \"Отказ\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Регистриране\",\n    \"account_tokens_table_label_header\": \"Етикет\",\n    \"prefs_reservations_edit_button\": \"Настройки на достъпа\",\n    \"prefs_reservations_table_topic_header\": \"Тема\",\n    \"prefs_reservations_table_access_header\": \"Достъп\",\n    \"prefs_reservations_dialog_topic_label\": \"Тема\",\n    \"prefs_reservations_dialog_access_label\": \"Достъп\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Грешна парола\",\n    \"account_basics_tier_description\": \"Ниво на профила\",\n    \"account_basics_tier_upgrade_button\": \"Надграждане до Pro\",\n    \"account_usage_messages_title\": \"Публикувани съобщения\",\n    \"account_tokens_table_last_access_header\": \"Последен достъп\",\n    \"account_basics_tier_payment_overdue\": \"Имате просрочено задължение. Обновете начина на плащане, защото в противен случай скоро профилът ви ще загуби предимствата на абонамента.\",\n    \"account_usage_basis_ip_description\": \"Статистиката и ограниченията на използване се отчитат по IP адрес, така че може да бъдат споделени с други потребители. Показаните по-горе ограничения са приблизителни и се основават на съществуващите ограничения на използване.\",\n    \"account_delete_dialog_description\": \"Това действие ще доведе до безвъзвратното изтриване на профила ви, включително на всички данни, които се съхраняват на сървъра. След изтриването потребителското ви име няма да бъде достъпно в продължение на 7 дни. Ако наистина искате да продължите, потвърдете с паролата си в полето по-долу.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} резервирана тема\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"спестете до {{discount}}%\",\n    \"account_delete_dialog_billing_warning\": \"Изтриването на профила незабавно отменя и платения абонамент. Няма да имате достъп до таблото за плащания.\",\n    \"account_upgrade_dialog_cancel_warning\": \"Това действие ще <strong>прекрати абонамента</strong> и ще промени профила ви на неплатен на {{date}}. На тази дата резервираните теми, както и пазените на сървъра съобщения, <strong> ще бъдат премахнати</strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Преизчисляване на плащания</strong>: При надграждане между платени планове разликата в цената ще бъде <strong>начислена незабавно</strong>. При преминаване към по-евтин план надплатената сума ще бъде използвана за плащане за бъдещи периоди.\",\n    \"account_basics_tier_manage_billing_button\": \"Управление на плащанията\",\n    \"account_basics_tier_canceled_subscription\": \"Абонаментът е прекратен и профилът ще бъде променен на неплатен на {{date}}.\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Изпращане на SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Обаждане до мен\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} телефонни обаждания на ден\",\n    \"common_copy_to_clipboard\": \"Копиране в междинната памет\",\n    \"publish_dialog_call_label\": \"Телефонно обаждане\",\n    \"publish_dialog_call_reset\": \"Премахване на телефонно обаждане\",\n    \"publish_dialog_chip_call_label\": \"Телефонно обаждане\",\n    \"account_basics_phone_numbers_dialog_description\": \"За да възползвате от услугата известяване чрез телефонно обаждане, трябва да добавите и потвърдите поне един телефонен номер. Проверката може да бъде извършена чрез SMS или телефонно обаждане.\",\n    \"account_basics_phone_numbers_title\": \"Телефонни номера\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"напр. +1222333444\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Телефонен номер\",\n    \"account_basics_phone_numbers_dialog_title\": \"Добавяне на телефонен номер\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Телефонният номер е копиран в междинната памет\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Все още няма телефонни номера\",\n    \"account_basics_phone_numbers_description\": \"За известяване чрез телефонно обаждане\",\n    \"publish_dialog_call_item\": \"Обаждане на телефонен номер {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Няма потвърдени телефонни номера\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Обаждане\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Код за потвърждаване\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"напр. 123456\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Код за потвърждение\",\n    \"account_usage_calls_none\": \"С този профил не могат да се извършват телефонни обаждания\",\n    \"account_usage_calls_title\": \"Извършени телефонни обаждания\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Без телефонни обаждания\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} съобщение на ден\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} съобщения на ден\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} ел. писмо на ден\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} ел. писма на ден\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} телефонни обаждания на ден\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} на файл, изтриване след {{expiry}}\",\n    \"account_upgrade_dialog_billing_contact_email\": \"За въпроси относно плащанията <Link>се свържете с нас</Link>.\",\n    \"account_upgrade_dialog_tier_current_label\": \"Текущо\",\n    \"account_upgrade_dialog_billing_contact_website\": \"За въпроси относно плащанията се обърнете към <Link>страницата</Link>.\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Прекратяване на абонамент\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} на файл\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко една резервирана тема</strong>. Можете да премахвате теми в <Link>Настройки</Link>.\",\n    \"account_tokens_title\": \"Кодове за достъп\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} на година. Плаща се всеки месец.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} плащане на година. Спестявате {{save}}.\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} общ обем\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"на месец\",\n    \"account_upgrade_dialog_button_pay_now\": \"Плащане и абониране\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Избрано\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Промяна на абонамент\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.\",\n    \"account_tokens_table_expires_header\": \"Изтича\",\n    \"account_tokens_table_never_expires\": \"Никога\",\n    \"prefs_reservations_title\": \"Резервирани теми\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Докоснете, за да се абонирате\",\n    \"prefs_reservations_dialog_title_delete\": \"Премахване на резервирането\",\n    \"prefs_reservations_table_everyone_read_only\": \"Аз мога да публикувам и да се абонирам, всички останали могат да се абонират\",\n    \"prefs_reservations_table_not_subscribed\": \"Без абонамент\",\n    \"account_tokens_table_token_header\": \"Код за достъп\",\n    \"account_tokens_table_create_token_button\": \"Създаване на код за достъп\",\n    \"account_tokens_dialog_expires_x_days\": \"Кодът за достъп изтича след {{days}} дена\",\n    \"account_tokens_dialog_expires_never\": \"Кодът за достъп не изтича\",\n    \"account_tokens_delete_dialog_title\": \"Премахване на код за достъп\",\n    \"prefs_reservations_limit_reached\": \"Достигнахте ограничението за брой резервирани теми.\",\n    \"prefs_reservations_add_button\": \"Добавяне на тема\",\n    \"prefs_reservations_delete_button\": \"Нулиране на правата за достъп\",\n    \"prefs_reservations_table\": \"Списък с резервирани теми\",\n    \"prefs_reservations_dialog_title_add\": \"Резервиране на тема\",\n    \"prefs_reservations_dialog_title_edit\": \"Променяне на резервирана тема\",\n    \"account_tokens_table_current_session\": \"Текущ сеанс на четеца\",\n    \"account_tokens_table_copied_to_clipboard\": \"Кодът за достъп е копиран\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Не можете да променяте или премахвате кода за достъп на текущия сеанс\",\n    \"account_tokens_table_last_origin_tooltip\": \"От адрес по IP {{ip}}, щракнете за подробности\",\n    \"account_tokens_dialog_title_create\": \"Създаване на код за достъп\",\n    \"account_tokens_dialog_title_edit\": \"Променяне на код за достъп\",\n    \"account_tokens_dialog_title_delete\": \"Премахване на код за достъп\",\n    \"account_tokens_dialog_label\": \"Етикет, напр. Известия от Radarr\",\n    \"account_tokens_dialog_button_create\": \"Създаване на код за достъп\",\n    \"account_tokens_dialog_button_update\": \"Променяне на код за достъп\",\n    \"account_tokens_dialog_expires_label\": \"Кодът за достъп изтича след\",\n    \"account_tokens_dialog_expires_x_hours\": \"Кодът за достъп изтича след {{hours}} часа\",\n    \"account_tokens_dialog_expires_unchanged\": \"Без промяна на давността\",\n    \"account_tokens_delete_dialog_submit_button\": \"Безвъзвратно премахване на код за достъп\",\n    \"prefs_users_description_no_sync\": \"Потребителите и паролите не се синхронизират заедно с профила.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Влезлият потребител не може да бъде премахнат\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Само аз мога да публикувам и да се абонирам\",\n    \"prefs_reservations_table_everyone_write_only\": \"Аз мога да публикувам и да се абонирам, всички останали могат да публикуват\",\n    \"prefs_reservations_table_everyone_read_write\": \"Всички могат да публикуват и да се абонират\",\n    \"reservation_delete_dialog_submit_button\": \"Премахване на резервирането\",\n    \"account_tokens_description\": \"Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.\",\n    \"account_tokens_delete_dialog_description\": \"Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>\",\n    \"prefs_reservations_dialog_description\": \"Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Пазене на съобщения и прикачени файлове\",\n    \"reservation_delete_dialog_action_keep_description\": \"Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Премахване на съобщения и прикачени файлове\",\n    \"reservation_delete_dialog_action_delete_description\": \"Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.\",\n    \"prefs_reservations_description\": \"Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.\",\n    \"reservation_delete_dialog_description\": \"С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове.\",\n    \"alert_notification_permission_denied_description\": \"Включете ги от мрежовия четец\",\n    \"alert_notification_permission_denied_title\": \"Известията са изключени\",\n    \"notifications_actions_failed_notification\": \"Действието е неуспешно\",\n    \"publish_dialog_checkbox_markdown\": \"Съобщението е Markdown\",\n    \"prefs_notifications_web_push_disabled_description\": \"Известията ще бъдат получавани докато приложението за уеб работи (чрез WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Включено за {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Изключено\",\n    \"prefs_appearance_theme_dark\": \"Тъмна\",\n    \"prefs_appearance_theme_light\": \"Светла\",\n    \"error_boundary_button_reload_ntfy\": \"Презареждне на ntfy\",\n    \"web_push_unknown_notification_title\": \"Получено е неочаквано известие\",\n    \"web_push_unknown_notification_body\": \"Вероятно ще трябва да обновите ntfy като отворите приложението за уеб\",\n    \"alert_notification_ios_install_required_title\": \"Необходимо е инсталиране за iOS\",\n    \"alert_notification_ios_install_required_description\": \"Докоснете бутона Споделяне и Добавяне към началния екран, за да включите известията под iOS\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Известията от други сървъри няма да бъдат получавани ако приложението за уеб не е отворено\",\n    \"action_bar_mute_notifications\": \"Заглушаване на известия\",\n    \"prefs_notifications_web_push_title\": \"Известия във фонов режим\",\n    \"prefs_notifications_web_push_enabled_description\": \"Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)\",\n    \"prefs_appearance_theme_title\": \"Цветова тема\",\n    \"prefs_appearance_theme_system\": \"Системна (подразбирана)\",\n    \"web_push_subscription_expiring_title\": \"Известията временно ще бъдат спрени\",\n    \"web_push_subscription_expiring_body\": \"За да продължите да получавате известия, отворете ntfy\",\n    \"action_bar_unmute_notifications\": \"Включване звука на известията\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Кодът за защита от външна система не може да бъде променян или премахван\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Потребител от външна система не може да бъде променян или премахван\"\n}\n"
  },
  {
    "path": "web/public/static/langs/bn.json",
    "content": "{}\n"
  },
  {
    "path": "web/public/static/langs/ca.json",
    "content": "{\n    \"nav_button_documentation\": \"Documentació\",\n    \"action_bar_profile_title\": \"Perfil\",\n    \"action_bar_settings\": \"Configuració\",\n    \"action_bar_account\": \"Compte\",\n    \"common_add\": \"Afegir\",\n    \"common_cancel\": \"Cancel·la\",\n    \"common_save\": \"Desa\",\n    \"common_back\": \"Enrere\",\n    \"common_copy_to_clipboard\": \"Copia al portaretalls\",\n    \"signup_title\": \"Crea un compte ntfy\",\n    \"signup_form_username\": \"Nom d'usuari\",\n    \"signup_form_password\": \"Contrasenya\",\n    \"signup_form_confirm_password\": \"Confirma la contrasenya\",\n    \"signup_form_button_submit\": \"Dona't d'alta\"\n}\n"
  },
  {
    "path": "web/public/static/langs/cs.json",
    "content": "{\n    \"action_bar_settings\": \"Nastavení\",\n    \"action_bar_send_test_notification\": \"Odeslání testovacího oznámení\",\n    \"action_bar_clear_notifications\": \"Vymazat všechna oznámení\",\n    \"action_bar_unsubscribe\": \"Odhlásit odběr\",\n    \"message_bar_type_message\": \"Zde napište zprávu\",\n    \"message_bar_error_publishing\": \"Chyba při odesílání oznámení\",\n    \"nav_topics_title\": \"Odebíraná témata\",\n    \"nav_button_all_notifications\": \"Všechna oznámení\",\n    \"nav_button_settings\": \"Nastavení\",\n    \"nav_button_documentation\": \"Dokumentace\",\n    \"nav_button_publish_message\": \"Odeslat oznámení\",\n    \"nav_button_subscribe\": \"Přihlásit se k odběru tématu\",\n    \"alert_notification_permission_required_title\": \"Oznámení jsou zakázána\",\n    \"alert_notification_permission_required_description\": \"Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.\",\n    \"alert_notification_permission_required_button\": \"Udělit nyní\",\n    \"alert_not_supported_title\": \"Oznámení nejsou podporována\",\n    \"alert_not_supported_description\": \"Oznámení nejsou ve vašem prohlížeči podporována\",\n    \"notifications_copied_to_clipboard\": \"Zkopírováno do schránky\",\n    \"notifications_tags\": \"Značky\",\n    \"notifications_attachment_copy_url_title\": \"Kopírovat URL přílohy do schránky\",\n    \"notifications_attachment_copy_url_button\": \"Kopírovat URL\",\n    \"notifications_attachment_open_title\": \"Přejít na {{url}}\",\n    \"notifications_attachment_open_button\": \"Otevřít přílohu\",\n    \"notifications_attachment_link_expires\": \"platnost odkazu končí {{date}}\",\n    \"notifications_attachment_link_expired\": \"platnost odkazu ke stažení vypršela\",\n    \"notifications_click_copy_url_title\": \"Kopírovat URL odkazu do schránky\",\n    \"notifications_click_copy_url_button\": \"Kopírovat odkaz\",\n    \"notifications_click_open_button\": \"Otevřít odkaz\",\n    \"notifications_none_for_topic_title\": \"K tomuto tématu jste zatím neobdrželi žádné oznámení.\",\n    \"notifications_none_for_topic_description\": \"Pro odeslání oznámení k tomuto tématu, odešlete PUT nebo POST požadavek na URL tématu.\",\n    \"notifications_example\": \"Příklad\",\n    \"publish_dialog_base_url_placeholder\": \"URL služby, např. https://example.com\",\n    \"publish_dialog_topic_label\": \"Název tématu\",\n    \"publish_dialog_topic_placeholder\": \"Název tématu, např. phil_alerts\",\n    \"publish_dialog_priority_default\": \"Výchozí priorita\",\n    \"publish_dialog_priority_high\": \"Vysoká priorita\",\n    \"publish_dialog_priority_max\": \"Nevyšší priorita\",\n    \"publish_dialog_base_url_label\": \"URL služby\",\n    \"prefs_users_dialog_password_label\": \"Heslo\",\n    \"prefs_users_dialog_title_add\": \"Přidat uživatele\",\n    \"prefs_users_dialog_title_edit\": \"Upravit uživatele\",\n    \"prefs_users_dialog_base_url_label\": \"URL služby, např. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Uživatelské jméno, např. phil\",\n    \"notifications_actions_open_url_title\": \"Přejít na {{url}}\",\n    \"notifications_none_for_any_title\": \"Neobdrželi jste žádná oznámení.\",\n    \"notifications_none_for_any_description\": \"Pro odeslání oznámení k tématu stačí na URL tématu odeslat PUT nebo POST požadavek. Zde je příklad s použitím jednoho z vašich témat.\",\n    \"notifications_no_subscriptions_description\": \"Kliknutím na \\\"{{linktext}}\\\" vytvoříte téma nebo se k němu přihlásíte. Poté můžete odesílat zprávy prostřednictvím PUT nebo POST požadavků a zde budete dostávat oznámení.\",\n    \"notifications_more_details\": \"Další informace naleznete na <websiteLink>webových stránkách</websiteLink> nebo v <docsLink>dokumentaci</docsLink>.\",\n    \"publish_dialog_title_topic\": \"Odeslat do {{téma}}\",\n    \"publish_dialog_title_no_topic\": \"Odeslat oznámení\",\n    \"publish_dialog_progress_uploading\": \"Nahrávání …\",\n    \"publish_dialog_message_published\": \"Oznámení odesláno\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"překračuje {{fileSizeLimit}} limit souboru a kvótu, {{remainingBytes}} zbývá\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"překračuje kvótu, {{remainingBytes}} zbývá\",\n    \"publish_dialog_priority_min\": \"Nejnižší priorita\",\n    \"publish_dialog_title_label\": \"Název\",\n    \"publish_dialog_title_placeholder\": \"Název oznámení, např. Upozornění na volné místo na disku\",\n    \"publish_dialog_message_placeholder\": \"Zde napište zprávu\",\n    \"publish_dialog_tags_label\": \"Značky\",\n    \"publish_dialog_priority_label\": \"Priorita\",\n    \"publish_dialog_click_label\": \"Klikněte na URL\",\n    \"publish_dialog_click_placeholder\": \"Adresa URL, která se otevře po kliknutí na oznámení\",\n    \"publish_dialog_email_label\": \"E-mail\",\n    \"publish_dialog_email_placeholder\": \"Adresa pro odeslání oznámení, např. phil@example.com\",\n    \"publish_dialog_attach_label\": \"URL přílohy\",\n    \"publish_dialog_filename_label\": \"Název souboru\",\n    \"publish_dialog_filename_placeholder\": \"Název souboru přílohy\",\n    \"publish_dialog_delay_label\": \"Zpoždění\",\n    \"publish_dialog_delay_placeholder\": \"Zpožděné doručení, např. {{unixTimestamp}}, {{relativeTime}} nebo \\\"{{naturalLanguage}}\\\". (pouze v angličtině)\",\n    \"publish_dialog_chip_click_label\": \"Klikněte na URL\",\n    \"publish_dialog_chip_email_label\": \"Přeposlat na e-mail\",\n    \"publish_dialog_chip_delay_label\": \"Zpožděné doručení\",\n    \"publish_dialog_chip_topic_label\": \"Změnit téma\",\n    \"publish_dialog_details_examples_description\": \"Příklady a podrobný popis všech funkcí odesílání naleznete v <docsLink>dokumentaci</docsLink>.\",\n    \"publish_dialog_chip_attach_url_label\": \"Připojit soubor pomocí URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Připojit místní soubor\",\n    \"publish_dialog_button_send\": \"Odeslat\",\n    \"publish_dialog_checkbox_publish_another\": \"Odeslat další\",\n    \"publish_dialog_attached_file_title\": \"Přiložený soubor:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Název souboru přílohy\",\n    \"publish_dialog_drop_file_here\": \"Přetáhněte soubor sem\",\n    \"emoji_picker_search_placeholder\": \"Hledat emodži\",\n    \"subscribe_dialog_subscribe_title\": \"Přihlásit odběr tématu\",\n    \"subscribe_dialog_subscribe_description\": \"Témata nemusí být chráněna heslem, proto zvolte název, který není snadné uhodnout. Jakmile se přihlásíte k odběru, můžete odesílat oznámení pomocí PUT/POST požadavků.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Název tématu, např. phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Použít jiný server\",\n    \"subscribe_dialog_login_title\": \"Vyžadováno přihlášení\",\n    \"subscribe_dialog_login_description\": \"Toto téma je chráněno heslem. Pro přihlášení k odběru zadejte prosím uživatelské jméno a heslo.\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Zrušit\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Přihlásit odběr\",\n    \"subscribe_dialog_login_username_label\": \"Uživatelské jméno, např. phil\",\n    \"subscribe_dialog_login_password_label\": \"Heslo\",\n    \"common_back\": \"Zpět\",\n    \"subscribe_dialog_login_button_login\": \"Přihlásit se\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Uživatel {{username}} není autorizován\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonymně\",\n    \"prefs_notifications_title\": \"Oznámení\",\n    \"prefs_notifications_sound_description_some\": \"Oznámení přehrají při doručení zvuk {{sound}}\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Zobrazit oznámení, pokud je priorita {{number}} ({{name}}) nebo vyšší\",\n    \"prefs_notifications_min_priority_description_max\": \"Zobrazit oznámení, pokud je priorita 5 (nejvyšší)\",\n    \"prefs_notifications_min_priority_any\": \"Jakákoli priorita\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Nízká priorita a vyšší\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Výchozí priorita a vyšší\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Vysoká priorita a vyšší\",\n    \"prefs_notifications_delete_after_three_hours\": \"Po třech hodinách\",\n    \"prefs_notifications_delete_after_one_day\": \"Po jednom dni\",\n    \"prefs_notifications_delete_after_one_week\": \"Po jednom týdnu\",\n    \"prefs_notifications_delete_after_one_month\": \"Po jednom měsíci\",\n    \"prefs_notifications_delete_after_never_description\": \"Oznámení nejsou nikdy automaticky mazána\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Oznámení se automaticky odstraní po třech hodinách\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Oznámení se automaticky odstraní po jednom dni\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Oznámení se automaticky odstraní po jednom týdnu\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Oznámení se automaticky odstraní po jednom měsíci\",\n    \"prefs_users_title\": \"Správa uživatelů\",\n    \"prefs_users_add_button\": \"Přidat uživatele\",\n    \"prefs_users_table_user_header\": \"Uživatel\",\n    \"prefs_users_table_base_url_header\": \"URL služby\",\n    \"common_cancel\": \"Zrušit\",\n    \"common_add\": \"Přidat\",\n    \"common_save\": \"Uložit\",\n    \"priority_min\": \"nejnižší\",\n    \"priority_low\": \"nízká\",\n    \"priority_default\": \"výchozí\",\n    \"priority_high\": \"vysoká\",\n    \"priority_max\": \"nejvyšší\",\n    \"error_boundary_title\": \"Ale ne, ntfy havaroval\",\n    \"error_boundary_description\": \"K tomu by samozřejmě nemělo docházet. Velmi se za to omlouváme.<br/>Pokud máte chvilku, nahlaste to prosím <githubLink>na GitHubu</githubLink> nebo nám dejte vědět prostřednictvím <discordLink>Discordu</discordLink> nebo <matrixLink>Matrixu</matrixLink>.\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopírovat výpis zásobníku\",\n    \"error_boundary_stack_trace\": \"Výpis zásobníku\",\n    \"publish_dialog_tags_placeholder\": \"Seznam značek oddělených čárkou, např. warning, srv1-backup\",\n    \"notifications_actions_not_supported\": \"Akce není podporována ve webové aplikaci\",\n    \"notifications_actions_http_request_title\": \"Odeslat HTTP {{metoda}} na {{url}}\",\n    \"notifications_no_subscriptions_title\": \"Vypadá to, že ještě nemáte žádné odběry.\",\n    \"prefs_notifications_min_priority_description_any\": \"Zobrazit všechna oznámení bez ohledu na prioritu\",\n    \"publish_dialog_priority_low\": \"Nízká priorita\",\n    \"publish_dialog_message_label\": \"Zpráva\",\n    \"publish_dialog_button_cancel\": \"Zrušit\",\n    \"notifications_loading\": \"Načítání oznámení …\",\n    \"publish_dialog_progress_uploading_detail\": \"Nahrávání {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_attachment_limits_file_reached\": \"překračuje {{fileSizeLimit}} limit souboru\",\n    \"publish_dialog_attach_placeholder\": \"Připojit soubor pomocí URL, např. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_button_cancel_sending\": \"Zrušit odesílání\",\n    \"publish_dialog_other_features\": \"Další funkce:\",\n    \"prefs_notifications_sound_title\": \"Zvuk oznámení\",\n    \"prefs_notifications_min_priority_max_only\": \"Pouze nejvyšší priorita\",\n    \"prefs_notifications_min_priority_title\": \"Nejnižší priorita\",\n    \"prefs_notifications_delete_after_title\": \"Odstranit oznámení\",\n    \"prefs_notifications_delete_after_never\": \"Nikdy\",\n    \"prefs_notifications_sound_no_sound\": \"Žádný zvuk\",\n    \"prefs_notifications_sound_description_none\": \"Oznámení nepřehrají při doručení žádný zvuk\",\n    \"prefs_users_description\": \"Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.\",\n    \"error_boundary_gathering_info\": \"Získejte více informací …\",\n    \"prefs_appearance_language_title\": \"Jazyk\",\n    \"prefs_appearance_title\": \"Vzhled\",\n    \"action_bar_show_menu\": \"Zobrazit nabídku\",\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_toggle_mute\": \"Ztlumení/zrušení ztlumení oznámení\",\n    \"action_bar_toggle_action_menu\": \"Otevřít/zavřít nabídku akcí\",\n    \"message_bar_show_dialog\": \"Zobrazit okno pro odesílání oznámení\",\n    \"message_bar_publish\": \"Odeslat zprávu\",\n    \"nav_button_muted\": \"Oznámení ztlumena\",\n    \"nav_button_connecting\": \"připojování\",\n    \"notifications_list\": \"Seznam oznámení\",\n    \"notifications_list_item\": \"Oznámení\",\n    \"notifications_mark_read\": \"Označit jako přečtené\",\n    \"notifications_delete\": \"Smazat\",\n    \"notifications_new_indicator\": \"Nové oznámení\",\n    \"notifications_attachment_image\": \"Obrázek přílohy\",\n    \"notifications_attachment_file_image\": \"soubor s obrázkem\",\n    \"notifications_attachment_file_video\": \"video soubor\",\n    \"notifications_attachment_file_audio\": \"zvukový soubor\",\n    \"notifications_attachment_file_app\": \"Soubor s aplikací pro Android\",\n    \"publish_dialog_emoji_picker_show\": \"Vybrat emoji\",\n    \"publish_dialog_topic_reset\": \"Obnovení tématu\",\n    \"publish_dialog_click_reset\": \"Odebrat URL kliknutím\",\n    \"publish_dialog_email_reset\": \"Odebrat přeposlání e-mailu\",\n    \"publish_dialog_attach_reset\": \"Odebrat URL přílohy\",\n    \"publish_dialog_attached_file_remove\": \"Odebrat přiložený soubor\",\n    \"emoji_picker_search_clear\": \"Vyčistit vyhledávání\",\n    \"prefs_users_edit_button\": \"Upravit uživatele\",\n    \"prefs_users_delete_button\": \"Odstranit uživatele\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Soukromé prohlížení není podporováno\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Webová aplikace ntfy potřebuje ke svému fungování databázi IndexedDB a váš prohlížeč v režimu soukromého prohlížení databázi IndexedDB nepodporuje.<br/><br/>To je sice nepříjemné, ale používat webovou aplikaci ntfy v režimu soukromého prohlížení stejně nemá smysl, protože vše je uloženo v úložišti prohlížeče. Více se o tom můžete dočíst <githubLink>v tomto tématu na GitHubu</githubLink>, nebo se na nás obrátit pomocí služeb <discordLink>Discord</discordLink> nebo <matrixLink>Matrix</matrixLink>.\",\n    \"notifications_priority_x\": \"Priorita {{priority}}\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL služby\",\n    \"prefs_notifications_sound_play\": \"Přehrát vybraný zvuk\",\n    \"prefs_users_table\": \"Tabulka uživatelů\",\n    \"notifications_attachment_file_document\": \"jiný dokument\",\n    \"publish_dialog_delay_reset\": \"Odebrat odložené doručení\",\n    \"signup_form_confirm_password\": \"Potvrdit heslo\",\n    \"signup_form_button_submit\": \"Zaregistrovat se\",\n    \"signup_form_username\": \"Uživatelské jméno\",\n    \"signup_form_toggle_password_visibility\": \"Přepnout viditelnost hesla\",\n    \"signup_already_have_account\": \"Už máte účet? Přihlašte se!\",\n    \"signup_error_username_taken\": \"Uživatelské jméno {{username}} je již obsazeno\",\n    \"signup_error_creation_limit_reached\": \"Dosažen limit pro vytvoření účtu\",\n    \"login_title\": \"Přihlaste se do svého ntfy účtu\",\n    \"login_form_button_submit\": \"Přihlásit se\",\n    \"login_link_signup\": \"Zaregistrovat se\",\n    \"login_disabled\": \"Přihlašování je zakázáno\",\n    \"action_bar_account\": \"Účet\",\n    \"action_bar_reservation_add\": \"Rezervovat téma\",\n    \"action_bar_reservation_edit\": \"Změnit rezervaci\",\n    \"action_bar_reservation_delete\": \"Odstranit rezervaci\",\n    \"action_bar_reservation_limit_reached\": \"Limit dosažen\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Nastavení\",\n    \"action_bar_profile_logout\": \"Odhlásit se\",\n    \"action_bar_sign_up\": \"Zaregistrovat se\",\n    \"nav_button_account\": \"Účet\",\n    \"nav_upgrade_banner_label\": \"Upgradovat na nfty Pro\",\n    \"nav_upgrade_banner_description\": \"Rezervace témat, více zpráv a emailů a větší přílohy\",\n    \"signup_title\": \"Vytvořit nfty účet\",\n    \"signup_form_password\": \"Heslo\",\n    \"display_name_dialog_description\": \"Nastaví alternativní název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.\",\n    \"action_bar_change_display_name\": \"Změnit zobrazovaný název\",\n    \"action_bar_sign_in\": \"Přihlásit se\",\n    \"alert_not_supported_context_description\": \"Oznámení jsou podporována pouze přes HTTPS. Toto je limitace <mdnLink>Notifications API</mdnLink>.\",\n    \"display_name_dialog_title\": \"Změnit zobrazovaný název\",\n    \"account_basics_password_title\": \"Heslo\",\n    \"account_basics_password_dialog_title\": \"Změna hesla\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Téma již rezervováno\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generovat název\",\n    \"account_delete_dialog_description\": \"Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(s úrovní {{tier}})\",\n    \"account_basics_tier_admin\": \"Administrátor\",\n    \"account_basics_tier_basic\": \"Základní\",\n    \"account_basics_tier_free\": \"Zdarma\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(žádná úroveň)\",\n    \"account_basics_tier_upgrade_button\": \"Přejít na verzi Pro\",\n    \"account_upgrade_dialog_cancel_warning\": \"Vaše <strong>předplatné se tímto zruší</strong> a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou <strong>smazány</strong> rezervace témat i zprávy uložené v mezipaměti serveru.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně <strong>odstraňte alespoň {{počet}} rezervací</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.\",\n    \"reservation_delete_dialog_description\": \"Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.\",\n    \"account_tokens_description\": \"Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v <Link>dokumentaci</Link>.\",\n    \"account_tokens_table_copied_to_clipboard\": \"Přístupový token zkopírován\",\n    \"account_tokens_table_last_origin_tooltip\": \"Z IP adresy {{ip}}, klikněte pro vyhledání\",\n    \"account_tokens_dialog_button_cancel\": \"Zrušit\",\n    \"account_tokens_dialog_expires_never\": \"Token nikdy nevyprší\",\n    \"account_tokens_delete_dialog_description\": \"Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. <strong>Tuto akci nelze vrátit zpět</strong>.\",\n    \"prefs_users_description_no_sync\": \"Uživatelé a hesla nejsou synchronizováni s vaším účtem.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Nelze odstranit ani upravit přihlášeného uživatele\",\n    \"prefs_reservations_title\": \"Rezervovaná témata\",\n    \"prefs_reservations_description\": \"Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Kliknutím se přihlásíte k odběru\",\n    \"prefs_reservations_dialog_description\": \"Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.\",\n    \"prefs_reservations_dialog_access_label\": \"Přístup\",\n    \"reservation_delete_dialog_action_keep_title\": \"Zachovat zprávy a přílohy v mezipaměti\",\n    \"signup_disabled\": \"Přihlášení je zakázáno\",\n    \"display_name_dialog_placeholder\": \"Zobrazovaný název\",\n    \"reserve_dialog_checkbox_label\": \"Rezervace tématu a nastavení přístupu\",\n    \"account_basics_title\": \"Účet\",\n    \"account_basics_username_title\": \"Uživatelské jméno\",\n    \"account_basics_username_description\": \"Hej, to jsi ty ❤\",\n    \"account_basics_username_admin_tooltip\": \"Jste správce\",\n    \"account_basics_password_description\": \"Změna hesla k účtu\",\n    \"account_basics_password_dialog_current_password_label\": \"Současné heslo\",\n    \"account_basics_password_dialog_new_password_label\": \"Nové heslo\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Potvrzení hesla\",\n    \"account_basics_password_dialog_button_submit\": \"Změnit heslo\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Nesprávné heslo\",\n    \"account_usage_title\": \"Použití\",\n    \"account_usage_of_limit\": \"z {{limit}}\",\n    \"account_usage_unlimited\": \"Neomezeně\",\n    \"account_usage_limits_reset_daily\": \"Limity používání se resetují denně o půlnoci (UTC)\",\n    \"account_basics_tier_title\": \"Typ účtu\",\n    \"account_basics_tier_description\": \"Úroveň oprávnění vašeho účtu\",\n    \"account_basics_tier_change_button\": \"Změnit\",\n    \"account_basics_tier_paid_until\": \"Předplatné zaplaceno do {{date}} a bude automaticky obnoveno\",\n    \"account_basics_tier_payment_overdue\": \"Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.\",\n    \"account_basics_tier_canceled_subscription\": \"Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.\",\n    \"account_basics_tier_manage_billing_button\": \"Správa vyúčtování\",\n    \"account_usage_messages_title\": \"Zveřejněné zprávy\",\n    \"account_usage_emails_title\": \"Odeslané e-maily\",\n    \"account_usage_reservations_title\": \"Rezervovaná témata\",\n    \"account_usage_reservations_none\": \"Žádná rezervovaná témata pro tento účet\",\n    \"account_usage_attachment_storage_title\": \"Úložiště příloh\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} na soubor, maže se po {{expiry}}\",\n    \"account_usage_basis_ip_description\": \"Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.\",\n    \"account_usage_cannot_create_portal_session\": \"Nelze otevřít portál pro fakturaci\",\n    \"account_delete_title\": \"Odstranit účet\",\n    \"account_delete_description\": \"Trvale odstranit účet\",\n    \"account_delete_dialog_label\": \"Heslo\",\n    \"account_delete_dialog_button_cancel\": \"Zrušit\",\n    \"account_delete_dialog_button_submit\": \"Trvale odstranit účet\",\n    \"account_delete_dialog_billing_warning\": \"Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.\",\n    \"account_upgrade_dialog_title\": \"Změna úrovně účtu\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně <strong>zaúčtován okamžitě</strong>. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, <strong>odstraňte alespoň jednu rezervaci</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} rezervovaných témat\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} denních zpráv\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} denních e-mailů\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} na soubor\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} celkový úložný prostor\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Vybráno\",\n    \"account_upgrade_dialog_tier_current_label\": \"Současné\",\n    \"account_upgrade_dialog_button_cancel\": \"Zrušit\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Zaregistrovat se nyní\",\n    \"account_upgrade_dialog_button_pay_now\": \"Zaplatit a předplatit si\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Zrušit předplatné\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Aktualizovat předplatné\",\n    \"account_tokens_title\": \"Přístupové tokeny\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_last_access_header\": \"Poslední přístup\",\n    \"account_tokens_table_expires_header\": \"Vyprší\",\n    \"account_tokens_table_never_expires\": \"Nikdy nevyprší\",\n    \"account_tokens_table_current_session\": \"Současná relace prohlížeče\",\n    \"common_copy_to_clipboard\": \"Kopírování do schránky\",\n    \"account_tokens_table_label_header\": \"Popisek\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Nelze upravit nebo odstranit aktuální token relace\",\n    \"account_tokens_table_create_token_button\": \"Vytvořit přístupový token\",\n    \"account_tokens_dialog_title_create\": \"Vytvoření přístupového tokenu\",\n    \"account_tokens_dialog_title_edit\": \"Úprava přístupového tokenu\",\n    \"account_tokens_dialog_title_delete\": \"Odstranění přístupového tokenu\",\n    \"account_tokens_dialog_label\": \"Popisek, např. Radarr notifications\",\n    \"account_tokens_dialog_button_create\": \"Vytvořit token\",\n    \"account_tokens_dialog_button_update\": \"Aktualizovat token\",\n    \"account_tokens_dialog_expires_label\": \"Platnost přístupového tokenu vyprší za\",\n    \"account_tokens_dialog_expires_unchanged\": \"Ponechat datum vypršení platnosti beze změny\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token vyprší za {{hours}} hodin\",\n    \"account_tokens_dialog_expires_x_days\": \"Token vyprší za {{days}} dní\",\n    \"account_tokens_delete_dialog_title\": \"Odstranění přístupového tokenu\",\n    \"account_tokens_delete_dialog_submit_button\": \"Trvale odstranit token\",\n    \"prefs_reservations_limit_reached\": \"Dosáhli jste limitu rezervovaných témat.\",\n    \"prefs_reservations_add_button\": \"Přidat rezervované téma\",\n    \"prefs_reservations_edit_button\": \"Upravit přístup k tématu\",\n    \"prefs_reservations_delete_button\": \"Resetovat přístup k tématu\",\n    \"prefs_reservations_table\": \"Tabulka rezervovaných témat\",\n    \"prefs_reservations_table_topic_header\": \"Téma\",\n    \"prefs_reservations_table_access_header\": \"Přístup\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Pouze já mohu publikovat a přihlásit se k odběru\",\n    \"prefs_reservations_table_everyone_read_only\": \"Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru\",\n    \"prefs_reservations_table_everyone_write_only\": \"Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat\",\n    \"prefs_reservations_table_everyone_read_write\": \"Kdokoli může publikovat a přihlásit se k odběru\",\n    \"prefs_reservations_table_not_subscribed\": \"Odběr není přihlášen\",\n    \"prefs_reservations_dialog_title_add\": \"Rezervovat téma\",\n    \"prefs_reservations_dialog_title_edit\": \"Úprava rezervovaného tématu\",\n    \"prefs_reservations_dialog_title_delete\": \"Odstranění rezervovaného tématu\",\n    \"prefs_reservations_dialog_topic_label\": \"Téma\",\n    \"reservation_delete_dialog_action_keep_description\": \"Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Odstranění zpráv a příloh uložených v mezipaměti\",\n    \"reservation_delete_dialog_action_delete_description\": \"Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.\",\n    \"reservation_delete_dialog_submit_button\": \"Odstranit rezervaci\",\n    \"account_basics_tier_interval_yearly\": \"roční\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"ušetříte {{discount}}%\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"měsíc\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Žádná rezervovaná témata\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"ušetříte až {{discount}}%\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} účtováno ročně. Ušetříte {{save}}.\",\n    \"account_basics_tier_interval_monthly\": \"měsíční\",\n    \"account_upgrade_dialog_interval_monthly\": \"Měsíční\",\n    \"account_upgrade_dialog_interval_yearly\": \"Roční\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} za rok. Účtuje se měsíčně.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"V případě dotazů týkajících se fakturace nás prosím <Link>kontaktujte</Link> přímo.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} rezervované téma\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} denní zpráva\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} denní e-mail\",\n    \"publish_dialog_call_label\": \"Telefonát\",\n    \"publish_dialog_call_reset\": \"Odstranit telefonát\",\n    \"publish_dialog_chip_call_label\": \"Telefonát\",\n    \"account_basics_phone_numbers_title\": \"Telefonní čísla\",\n    \"account_basics_phone_numbers_dialog_description\": \"Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.\",\n    \"account_basics_phone_numbers_description\": \"K oznámení telefonátem\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Zatím žádná telefonní čísla\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefonní číslo zkopírováno do schránky\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Žádná ověřená telefonní čísla\",\n    \"publish_dialog_call_item\": \"Vytočit číslo {{number}}\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_title\": \"Přidat telefonní číslo\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefonní číslo\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"např. 123456\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Ověřovací kód\",\n    \"account_usage_calls_none\": \"S tímto účtem nelze uskutečňovat žádné telefonní hovory\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Potvrdit kód\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"např. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Odeslat SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Zavolat mi\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Zavolat\",\n    \"account_usage_calls_title\": \"Uskutečněné telefonáty\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Žádné telefonní hovory\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} denní telefonní hovor\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} denních telefonních hovorů\",\n    \"prefs_notifications_web_push_enabled\": \"Povoleno pro {{server}}\",\n    \"error_boundary_button_reload_ntfy\": \"Znovu načíst ntfy\",\n    \"web_push_subscription_expiring_body\": \"Otevřete ntfy a pokračujte v přijímání oznámení\",\n    \"action_bar_mute_notifications\": \"Ztlumit oznámení\",\n    \"action_bar_unmute_notifications\": \"Zrušit ztlumení oznámení\",\n    \"alert_notification_permission_denied_title\": \"Oznámení jsou blokována\",\n    \"alert_notification_permission_denied_description\": \"Prosím, znovu je povolte ve svém prohlížeči\",\n    \"alert_notification_ios_install_required_title\": \"Je vyžadována instalace iOS\",\n    \"alert_notification_ios_install_required_description\": \"Kliknutím na ikonu Sdílet a Přidat na domovskou obrazovku povolíte oznámení v systému iOS\",\n    \"notifications_actions_failed_notification\": \"Neúspěšná akce\",\n    \"publish_dialog_checkbox_markdown\": \"Formátovat jako Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Oznámení z jiných serverů nebudou přijímána, pokud není otevřena webová aplikace\",\n    \"prefs_notifications_web_push_title\": \"Oznámení na pozadí\",\n    \"prefs_notifications_web_push_enabled_description\": \"Oznámení jsou přijímána, i když webová aplikace není spuštěna (prostřednictvím Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Oznámení jsou přijímána, když je webová aplikace spuštěna (přes WebSocket)\",\n    \"prefs_notifications_web_push_disabled\": \"Zakázáno\",\n    \"prefs_appearance_theme_title\": \"Motiv\",\n    \"prefs_appearance_theme_system\": \"Systém (výchozí)\",\n    \"prefs_appearance_theme_dark\": \"Tmavý režim\",\n    \"prefs_appearance_theme_light\": \"Světlý režim\",\n    \"web_push_subscription_expiring_title\": \"Oznámení budou pozastavena\",\n    \"web_push_unknown_notification_title\": \"Neznámé oznámení přijaté ze serveru\",\n    \"web_push_unknown_notification_body\": \"Možná bude nutné aktualizovat ntfy otevřením webové aplikace\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Přiděleného uživatele nelze upravovat ani odstranit\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Nelze upravit ani odstranit přidělený token\"\n}\n"
  },
  {
    "path": "web/public/static/langs/cu.json",
    "content": "{\n    \"common_cancel\": \"Отмѣнити\",\n    \"common_save\": \"Сохрани\",\n    \"common_add\": \"Приложити\",\n    \"common_back\": \"Назадъ\",\n    \"login_form_button_submit\": \"Въниди\",\n    \"signup_form_password\": \"Таино слово\"\n}\n"
  },
  {
    "path": "web/public/static/langs/cy.json",
    "content": "{\n    \"notifications_delete\": \"Dileu\",\n    \"action_bar_sign_in\": \"Mewngofnodi\",\n    \"notifications_copied_to_clipboard\": \"Wedi'i gopio i'r clipfwrdd\",\n    \"common_cancel\": \"Canslo\",\n    \"nav_button_account\": \"Cyfrif\",\n    \"common_save\": \"Arbed\",\n    \"common_add\": \"Ychwanegu\",\n    \"signup_title\": \"Creu cyfrif ntfy\",\n    \"signup_form_username\": \"Enw defnyddiwr\",\n    \"signup_form_password\": \"Cyfrinair\",\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_settings\": \"Gosodiadau\",\n    \"action_bar_profile_title\": \"Proffil\",\n    \"action_bar_profile_logout\": \"Allgofnodi\",\n    \"message_bar_publish\": \"Cyhoeddi neges\",\n    \"notifications_attachment_copy_url_button\": \"Copio URL\",\n    \"notifications_attachment_open_title\": \"Ewch i {{url}}\",\n    \"publish_dialog_base_url_label\": \"URL y Gwasanaeth\",\n    \"publish_dialog_priority_high\": \"Blaenoriaeth uchel\",\n    \"publish_dialog_title_label\": \"Teitl\",\n    \"publish_dialog_message_label\": \"Neges\",\n    \"publish_dialog_attach_label\": \"URL Atodiad\",\n    \"publish_dialog_filename_label\": \"Enw ffeil\",\n    \"publish_dialog_filename_placeholder\": \"Enw ffeil yr atodiad\",\n    \"action_bar_account\": \"Cyfrif\",\n    \"action_bar_unsubscribe\": \"Dad-danysgrifio\",\n    \"login_title\": \"Mewngofnodi i'ch cyfrif ntfy\",\n    \"login_form_button_submit\": \"Mewngofnodi\",\n    \"action_bar_change_display_name\": \"Newid enw arddangos\",\n    \"action_bar_profile_settings\": \"Gosodiadau\",\n    \"nav_button_settings\": \"Gosodiadau\",\n    \"nav_button_documentation\": \"Dogfennaeth\",\n    \"alert_not_supported_context_description\": \"Dim ond dros HTTPS y gellir derbyn cyhoeddiadau. Mae hyn yn gyfyngiad ar yr API <mdnLink>Notifications</mdnLink>.\",\n    \"notifications_attachment_open_button\": \"Agor atodiad\",\n    \"notifications_attachment_file_document\": \"dogfen arall\",\n    \"notifications_click_open_button\": \"Agor linc\",\n    \"publish_dialog_base_url_placeholder\": \"URL y Gwasanaeth, e.e. https://example.com\",\n    \"publish_dialog_attach_placeholder\": \"Atodi ffeil drwy URL, e.e. https://f-droid.org/F-Droid.apk\",\n    \"notifications_click_copy_url_button\": \"Copio linc\",\n    \"notifications_actions_open_url_title\": \"Ewch i {{url}}\",\n    \"publish_dialog_email_label\": \"Ebost\",\n    \"signup_form_confirm_password\": \"Cadarnhau cyfrinair\",\n    \"signup_form_button_submit\": \"Cofrestru\",\n    \"common_back\": \"Yn ôl\",\n    \"common_copy_to_clipboard\": \"Copio i'r clipfwrdd\",\n    \"signup_already_have_account\": \"Gyda chyfrif yn barod? Mewngofnodi!\"\n}\n"
  },
  {
    "path": "web/public/static/langs/da.json",
    "content": "{\n    \"common_save\": \"Gem\",\n    \"common_add\": \"Tilføj\",\n    \"signup_title\": \"Opret en ntfy-konto\",\n    \"signup_form_username\": \"Brugernavn\",\n    \"signup_form_password\": \"Kodeord\",\n    \"signup_form_confirm_password\": \"Bekræft kodeord\",\n    \"common_cancel\": \"Annuller\",\n    \"action_bar_account\": \"Konto\",\n    \"signup_error_username_taken\": \"Brugernavnet {{username}} er optaget\",\n    \"login_form_button_submit\": \"Log ind\",\n    \"action_bar_show_menu\": \"Vis menu\",\n    \"action_bar_logo_alt\": \"ntfy-logo\",\n    \"action_bar_settings\": \"Indstillinger\",\n    \"signup_form_button_submit\": \"Opret konto\",\n    \"signup_form_toggle_password_visibility\": \"Skift synlighed af adgangskode\",\n    \"signup_disabled\": \"Tilmelding er deaktiveret\",\n    \"signup_error_creation_limit_reached\": \"Grænsen for kontooprettelse er nået\",\n    \"login_title\": \"Log ind på din ntfy-konto\",\n    \"login_link_signup\": \"Opret konto\",\n    \"login_disabled\": \"Login er deaktiveret\",\n    \"action_bar_reservation_add\": \"Reserver emne\",\n    \"action_bar_reservation_edit\": \"Rediger reservation\",\n    \"action_bar_reservation_delete\": \"Fjern reservation\",\n    \"action_bar_reservation_limit_reached\": \"Grænsen er nået\",\n    \"action_bar_send_test_notification\": \"Send testnotifikation\",\n    \"action_bar_unsubscribe\": \"Afmeld\",\n    \"action_bar_toggle_mute\": \"Slå lyden fra/til for notifikationer\",\n    \"action_bar_change_display_name\": \"Skift visningsnavn\",\n    \"action_bar_toggle_action_menu\": \"Åben/luk handlingsmenu\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Indstillinger\",\n    \"action_bar_profile_logout\": \"Log ud\",\n    \"action_bar_sign_in\": \"Log ind\",\n    \"action_bar_sign_up\": \"Opret konto\",\n    \"message_bar_type_message\": \"Skriv en besked her\",\n    \"nav_button_settings\": \"Indstillinger\",\n    \"message_bar_publish\": \"Udgiv besked\",\n    \"nav_topics_title\": \"Tilmeldte emner\",\n    \"nav_button_all_notifications\": \"Alle notifikationer\",\n    \"nav_button_connecting\": \"forbinder\",\n    \"nav_upgrade_banner_label\": \"Opgrader til ntfy Pro\",\n    \"alert_notification_permission_required_title\": \"Notifikationer er deaktiveret\",\n    \"alert_notification_permission_required_description\": \"Giv din browser tilladelse til at vise skrivebordsnotifikationer.\",\n    \"alert_not_supported_title\": \"Notifikationer understøttes ikke\",\n    \"alert_not_supported_description\": \"Notifikationer understøttes ikke i din browser.\",\n    \"alert_not_supported_context_description\": \"Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i <mdnLink>Notifications API</mdnLink>.\",\n    \"nav_button_subscribe\": \"Abonner på emne\",\n    \"notifications_list_item\": \"Notifikation\",\n    \"notifications_delete\": \"Slet\",\n    \"notifications_tags\": \"Tags\",\n    \"notifications_list\": \"Notifikationsliste\",\n    \"notifications_mark_read\": \"Marker som læst\",\n    \"notifications_copied_to_clipboard\": \"Kopieret til udklipsholder\",\n    \"notifications_priority_x\": \"Prioritet {{priority}}\",\n    \"notifications_attachment_copy_url_title\": \"Kopier URL-adresse til vedhæftet fil til udklipsholder\",\n    \"notifications_attachment_copy_url_button\": \"Kopier URL\",\n    \"notifications_attachment_open_title\": \"Gå til {{url}}\",\n    \"notifications_attachment_open_button\": \"Åben vedhæftning\",\n    \"notifications_attachment_link_expires\": \"link udløber {{date}}\",\n    \"notifications_attachment_link_expired\": \"download-link er udløbet\",\n    \"notifications_attachment_file_image\": \"billedfil\",\n    \"notifications_attachment_file_app\": \"Android-appfil\",\n    \"notifications_attachment_file_document\": \"andet dokument\",\n    \"notifications_click_copy_url_title\": \"Kopier linkets URL til udklipsholderen\",\n    \"notifications_click_copy_url_button\": \"Kopier link\",\n    \"notifications_example\": \"Eksempel\",\n    \"notifications_click_open_button\": \"Åbn link\",\n    \"notifications_actions_not_supported\": \"Handlingen understøttes ikke i webappen\",\n    \"notifications_actions_http_request_title\": \"Send HTTP {{method}} til {{url}}\",\n    \"notifications_none_for_topic_title\": \"Du har ikke modtaget nogen notifikationer om dette emne endnu.\",\n    \"notifications_none_for_any_title\": \"Du har ikke modtaget nogen notifikationer.\",\n    \"display_name_dialog_placeholder\": \"Vist navn\",\n    \"publish_dialog_progress_uploading\": \"Uploader…\",\n    \"display_name_dialog_title\": \"Skift visningsnavn\",\n    \"publish_dialog_progress_uploading_detail\": \"Uploader {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_emoji_picker_show\": \"Vælg emoji\",\n    \"publish_dialog_priority_min\": \"Min. prioritet\",\n    \"publish_dialog_priority_low\": \"Lav prioritet\",\n    \"publish_dialog_priority_default\": \"Standardprioritet\",\n    \"publish_dialog_priority_high\": \"Høj prioritet\",\n    \"publish_dialog_title_label\": \"Titel\",\n    \"publish_dialog_message_label\": \"Besked\",\n    \"publish_dialog_tags_label\": \"Tags\",\n    \"publish_dialog_priority_label\": \"Prioritet\",\n    \"publish_dialog_message_placeholder\": \"Skriv en besked her\",\n    \"publish_dialog_tags_placeholder\": \"Komma-separeret liste over tags, f.eks. warning, srv1-backup\",\n    \"publish_dialog_click_label\": \"Klik på URL\",\n    \"publish_dialog_email_reset\": \"Fjern videresendelse af e-mail\",\n    \"publish_dialog_attach_placeholder\": \"Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_delay_label\": \"Forsinkelse\",\n    \"publish_dialog_button_send\": \"Send\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Tilmeld\",\n    \"common_back\": \"Tilbage\",\n    \"subscribe_dialog_login_username_label\": \"Brugernavn, f.eks. phil\",\n    \"account_basics_title\": \"Konto\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Emnet er allerede reserveret\",\n    \"account_basics_username_admin_tooltip\": \"Du er Admin\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Bekræft kodeord\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Forkert kodeord\",\n    \"account_usage_of_limit\": \"af {{limit}}\",\n    \"account_basics_tier_basic\": \"Grundlæggende\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(intet niveau)\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(med {{tier}}} niveau)\",\n    \"account_usage_messages_title\": \"Udgivne beskeder\",\n    \"account_delete_dialog_button_submit\": \"Slet konto permanent\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} pr. fil\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Tilmeld dig nu\",\n    \"account_tokens_table_expires_header\": \"Udløber\",\n    \"account_tokens_table_last_access_header\": \"Seneste adgang\",\n    \"account_tokens_delete_dialog_title\": \"Slet adgangstoken\",\n    \"prefs_notifications_sound_no_sound\": \"Ingen lyd\",\n    \"prefs_notifications_min_priority_title\": \"Minimumsprioritet\",\n    \"prefs_notifications_sound_play\": \"Afspil den valgte lyd\",\n    \"prefs_notifications_min_priority_max_only\": \"Kun maks. prioritet\",\n    \"prefs_notifications_delete_after_three_hours\": \"Efter tre timer\",\n    \"prefs_users_add_button\": \"Tilføj bruger\",\n    \"prefs_users_dialog_title_edit\": \"Rediger bruger\",\n    \"prefs_reservations_title\": \"Reserverede emner\",\n    \"prefs_reservations_add_button\": \"Tilføj reserveret emne\",\n    \"prefs_reservations_table_access_header\": \"Adgang\",\n    \"prefs_reservations_delete_button\": \"Nulstil emneadgang\",\n    \"prefs_reservations_dialog_title_edit\": \"Rediger reserveret emne\",\n    \"prefs_reservations_dialog_access_label\": \"Adgang\",\n    \"prefs_reservations_dialog_title_delete\": \"Slet emnereservation\",\n    \"priority_low\": \"lav\",\n    \"priority_min\": \"min\",\n    \"reservation_delete_dialog_submit_button\": \"Slet reservation\",\n    \"priority_high\": \"høj\",\n    \"priority_max\": \"maks\",\n    \"error_boundary_stack_trace\": \"Strack trace\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopier stack trace\",\n    \"signup_already_have_account\": \"Har du allerede en konto? Log ind!\",\n    \"action_bar_clear_notifications\": \"Ryd alle notifikationer\",\n    \"notifications_new_indicator\": \"Ny notifikation\",\n    \"notifications_attachment_image\": \"Vedhæftet billede\",\n    \"account_delete_dialog_label\": \"Kodeord\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Privat browsing understøttes ikke\",\n    \"notifications_actions_open_url_title\": \"Gå til {{url}}\",\n    \"notifications_attachment_file_audio\": \"lydfil\",\n    \"publish_dialog_click_placeholder\": \"URL der åbnes, når der klikkes på notifikationen\",\n    \"publish_dialog_email_placeholder\": \"Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com\",\n    \"notifications_attachment_file_video\": \"videofil\",\n    \"account_basics_tier_title\": \"Kontotype\",\n    \"publish_dialog_filename_label\": \"Filnavn\",\n    \"account_basics_tier_manage_billing_button\": \"Administrer fakturering\",\n    \"account_usage_emails_title\": \"Afsendte e-mails\",\n    \"account_usage_reservations_title\": \"Reserverede emner\",\n    \"account_delete_title\": \"Slet konto\",\n    \"nav_button_account\": \"Konto\",\n    \"nav_button_documentation\": \"Dokumentation\",\n    \"publish_dialog_priority_max\": \"Maks. prioritet\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Opsig abonnement\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Opdater abonnement\",\n    \"publish_dialog_button_cancel\": \"Annuller\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"account_tokens_title\": \"Adgangstokens\",\n    \"account_tokens_table_never_expires\": \"Udløber aldrig\",\n    \"prefs_notifications_sound_title\": \"Notifikationslyd\",\n    \"account_tokens_dialog_button_update\": \"Opdater token\",\n    \"account_tokens_dialog_button_create\": \"Opret token\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Annuller\",\n    \"prefs_users_table_user_header\": \"Bruger\",\n    \"prefs_appearance_title\": \"Udseende\",\n    \"subscribe_dialog_login_button_login\": \"Log ind\",\n    \"subscribe_dialog_login_password_label\": \"Kodeord\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonym\",\n    \"account_usage_title\": \"Anvendelse\",\n    \"account_basics_username_title\": \"Brugernavn\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"account_basics_password_title\": \"Kodeord\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Valgt\",\n    \"account_usage_unlimited\": \"Ubegrænset\",\n    \"account_tokens_table_label_header\": \"Label\",\n    \"account_tokens_dialog_button_cancel\": \"Annuller\",\n    \"account_basics_tier_change_button\": \"Rediger\",\n    \"account_delete_dialog_button_cancel\": \"Annuller\",\n    \"account_upgrade_dialog_button_cancel\": \"Annuller\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_upgrade_dialog_tier_current_label\": \"Nuværende\",\n    \"prefs_notifications_title\": \"Notifikationer\",\n    \"prefs_notifications_delete_after_never\": \"Aldrig\",\n    \"prefs_reservations_table_topic_header\": \"Emne\",\n    \"prefs_users_dialog_password_label\": \"Kodeord\",\n    \"prefs_appearance_language_title\": \"Sprog\",\n    \"prefs_reservations_dialog_topic_label\": \"Emne\",\n    \"priority_default\": \"standard\",\n    \"publish_dialog_attached_file_remove\": \"Fjern vedhæftet fil\",\n    \"prefs_users_table\": \"Bruger tabel\",\n    \"prefs_users_edit_button\": \"Rediger bruger\",\n    \"prefs_users_dialog_title_add\": \"Tilføj bruger\",\n    \"prefs_users_delete_button\": \"Slet bruger\",\n    \"account_tokens_table_copied_to_clipboard\": \"Adgangstoken kopieret\",\n    \"prefs_notifications_min_priority_any\": \"Enhver prioritet\",\n    \"prefs_notifications_delete_after_title\": \"Slet notifikationer\",\n    \"publish_dialog_delay_reset\": \"Fjern forsinket levering\",\n    \"prefs_users_title\": \"Administrer brugere\",\n    \"account_basics_password_dialog_button_submit\": \"Skift kodeord\",\n    \"prefs_reservations_dialog_title_add\": \"Reserver emne\",\n    \"account_basics_password_dialog_current_password_label\": \"Nuværende kodeord\",\n    \"account_basics_password_dialog_new_password_label\": \"Nyt kodeord\",\n    \"notifications_loading\": \"Indlæser notifikationer…\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} daglige e-mails\",\n    \"account_tokens_table_create_token_button\": \"Opret adgangstoken\",\n    \"account_tokens_dialog_title_delete\": \"Slet adgangstoken\",\n    \"publish_dialog_chip_email_label\": \"Videresend til e-mail\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} samlet lagerplads\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Brug en anden server\",\n    \"account_basics_tier_upgrade_button\": \"Opgrader til Pro\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} daglige beskeder\",\n    \"common_copy_to_clipboard\": \"Kopier til udklipsholder\",\n    \"prefs_reservations_edit_button\": \"Rediger emneadgang\",\n    \"account_upgrade_dialog_title\": \"Skift kontoniveau\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reserverede emner\",\n    \"account_tokens_dialog_expires_never\": \"Token udløber aldrig\",\n    \"account_tokens_table_current_session\": \"Nuværende browsersession\",\n    \"account_tokens_dialog_title_edit\": \"Rediger adgangstoken\",\n    \"account_tokens_dialog_title_create\": \"Opret adgangstoken\",\n    \"prefs_notifications_delete_after_one_day\": \"Efter en dag\",\n    \"account_tokens_delete_dialog_submit_button\": \"Slet token permanent\",\n    \"prefs_notifications_delete_after_one_month\": \"Efter en måned\",\n    \"prefs_notifications_delete_after_one_week\": \"Efter en uge\",\n    \"prefs_users_dialog_username_label\": \"Brugernavn, f.eks. phil\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Notifikationer slettes automatisk efter en dag\",\n    \"notifications_none_for_topic_description\": \"For at sende en notifikation til dette emne, skal du blot sende en PUT eller POST til emne-URL'en.\",\n    \"notifications_none_for_any_description\": \"For at sende en notifikation til et emne, skal du blot sende en PUT eller POST til emne-URL'en. Her er et eksempel med et af dine emner.\",\n    \"notifications_no_subscriptions_title\": \"Det ser ud til, at du ikke har nogen abonnementer endnu.\",\n    \"notifications_more_details\": \"For mere information, se <websiteLink>webstedet</websiteLink> eller <docsLink>dokumentationen</docsLink>.\",\n    \"display_name_dialog_description\": \"Angiv et alternativt navn for et emne, der vises på abonnementslisten. Dette gør det nemmere at identificere emner med komplicerede navne.\",\n    \"reserve_dialog_checkbox_label\": \"Reserver emne og konfigurer adgang\",\n    \"publish_dialog_attachment_limits_file_reached\": \"overskrider {{fileSizeLimit}} filgrænse\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"overskrider kvote, {{remainingBytes}} tilbage\",\n    \"publish_dialog_topic_label\": \"Emnenavn\",\n    \"publish_dialog_topic_placeholder\": \"Emnenavn, f.eks. phil_alerts\",\n    \"publish_dialog_topic_reset\": \"Nulstil emne\",\n    \"publish_dialog_click_reset\": \"Fjern klik-URL\",\n    \"publish_dialog_delay_placeholder\": \"Forsink levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \\\"{{naturalLanguage}}\\\" (kun på engelsk)\",\n    \"publish_dialog_other_features\": \"Andre funktioner:\",\n    \"publish_dialog_chip_attach_url_label\": \"Vedhæft fil via URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Vedhæft lokal fil\",\n    \"publish_dialog_details_examples_description\": \"For eksempler og en detaljeret beskrivelse af alle afsendelsesfunktioner henvises til <docsLink>dokumentationen</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Annuller afsendelse\",\n    \"publish_dialog_attached_file_title\": \"Vedhæftet fil:\",\n    \"emoji_picker_search_placeholder\": \"Søg emoji\",\n    \"emoji_picker_search_clear\": \"Ryd søgning\",\n    \"subscribe_dialog_subscribe_title\": \"Abonner på emne\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Emnenavn, f.eks. phil_alerts\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generer navn\",\n    \"subscribe_dialog_login_title\": \"Login påkrævet\",\n    \"subscribe_dialog_login_description\": \"Dette emne er adgangskodebeskyttet. Indtast venligst brugernavn og adgangskode for at abonnere.\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Brugeren {{username}} er ikke autoriseret\",\n    \"account_basics_password_description\": \"Skift adgangskoden til din konto\",\n    \"account_usage_limits_reset_daily\": \"Brugsgrænser nulstilles dagligt ved midnat (UTC)\",\n    \"account_basics_tier_paid_until\": \"Abonnementet er betalt indtil {{date}} og fornys automatisk\",\n    \"account_basics_tier_payment_overdue\": \"Din betaling er forfalden. Opdater venligst din betalingsmetode, ellers bliver din konto snart nedgraderet.\",\n    \"account_basics_tier_canceled_subscription\": \"Dit abonnement blev annulleret og vil blive nedgraderet til en gratis konto den {{date}}.\",\n    \"account_usage_cannot_create_portal_session\": \"Kan ikke åbne faktureringsportalen\",\n    \"account_delete_description\": \"Slet din konto permanent\",\n    \"account_delete_dialog_description\": \"Dette vil slette din konto permanent, inklusive alle data, der er gemt på serveren. Efter sletning vil dit brugernavn være utilgængeligt i 7 dage. Hvis du virkelig ønsker at fortsætte, bedes du bekræfte med dit kodeord i feltet nedenfor.\",\n    \"account_upgrade_dialog_button_pay_now\": \"Betal nu og abonner\",\n    \"account_tokens_table_last_origin_tooltip\": \"Fra IP-adresse {{ip}}, klik for at slå op\",\n    \"account_tokens_dialog_label\": \"Label, f.eks. radarmeddelelser\",\n    \"account_tokens_dialog_expires_label\": \"Adgangstoken udløber om\",\n    \"account_tokens_dialog_expires_unchanged\": \"Lad udløbsdatoen forblive uændret\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token udløber om {{hours}} timer\",\n    \"account_tokens_dialog_expires_x_days\": \"Token udløber om {{days}} dage\",\n    \"prefs_notifications_sound_description_none\": \"Notifikationer afspiller ingen lyd, når de ankommer\",\n    \"prefs_notifications_sound_description_some\": \"Notifikationer afspiller {{sound}}-lyden, når de ankommer\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Lav prioritet og højere\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Standardprioritet og højere\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Høj prioritet og højere\",\n    \"prefs_notifications_delete_after_never_description\": \"Notifikationer slettes aldrig automatisk\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Notifikationer slettes automatisk efter tre timer\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Notifikationer slettes automatisk efter en uge\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Notifikationer slettes automatisk efter en måned\",\n    \"prefs_reservations_limit_reached\": \"Du har nået din grænse for reserverede emner.\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klik for at abonnere\",\n    \"reservation_delete_dialog_action_keep_title\": \"Behold cachelagrede meddelelser og vedhæftede filer\",\n    \"reservation_delete_dialog_action_delete_title\": \"Slet cachelagrede meddelelser og vedhæftede filer\",\n    \"error_boundary_title\": \"Oh nej, ntfy brød sammen\",\n    \"error_boundary_description\": \"Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Ingen telefonopkald\",\n    \"account_upgrade_dialog_billing_contact_email\": \"For faktureringsspørgsmål bedes du <Link>kontakte os</Link> direkte.\",\n    \"account_basics_tier_interval_monthly\": \"månedlig\",\n    \"publish_dialog_checkbox_publish_another\": \"Udgiv en anden\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} daglige telefonopkald\",\n    \"publish_dialog_filename_placeholder\": \"Vedhæftet filnavn\",\n    \"prefs_users_description\": \"Tilføj/fjern brugere til dine beskyttede emner her. Vær opmærksom på, at brugernavn og adgangskode er gemt i browserens lokale lager.\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefonnummer\",\n    \"subscribe_dialog_subscribe_description\": \"Emner kan ikke beskyttes med adgangskode, så vælg et navn, der ikke er let at gætte. Når du har abonneret, kan du PUT/POST notifikationer.\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Bekræft kode\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"spar op til {{discount}}%\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Proration</strong>: Når du opgraderer mellem betalte planer, vil prisforskellen blive <strong>opkrævet med det samme</strong>. Ved nedgradering til et lavere niveau, vil saldoen blive brugt til at betale for fremtidige faktureringsperioder.\",\n    \"account_usage_attachment_storage_title\": \"opbevaring af vedhæftede filer\",\n    \"message_bar_error_publishing\": \"Der opstod en fejl under udgivelse af meddelelse\",\n    \"publish_dialog_chip_delay_label\": \"Forsinke leveringen\",\n    \"prefs_reservations_table_not_subscribed\": \"Ikke abonneret\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} daglige telefonopkald\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Send SMS\",\n    \"prefs_reservations_table_everyone_read_only\": \"Jeg kan udgive og abonnere, alle kan abonnere\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Kun jeg kan udgive og abonnere\",\n    \"publish_dialog_chip_topic_label\": \"Skift emne\",\n    \"account_basics_phone_numbers_dialog_description\": \"For at bruge opkaldsmeddelelsesfunktionen skal du tilføje og bekræfte mindst ét telefonnummer. Bekræftelse kan gøres via SMS eller et telefonopkald.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reserveret emne\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Ingen reserverede emner\",\n    \"publish_dialog_base_url_label\": \"Tjeneste-URL\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Kan ikke slette eller redigere en aktiv bruger\",\n    \"publish_dialog_title_no_topic\": \"Udgiv notifikation\",\n    \"publish_dialog_attach_label\": \"URL til vedhæftede filer\",\n    \"nav_button_muted\": \"Notifikationer slået fra\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Vis notifikationer hvis prioritet er {{number}} ({{name}}) eller højere\",\n    \"reservation_delete_dialog_description\": \"Fjernelse af en reservation opgiver ejerskabet over emnet og giver andre mulighed for at reservere det. Du kan beholde eller slette eksisterende beskeder og vedhæftede filer.\",\n    \"prefs_reservations_table_everyone_read_write\": \"Alle kan udgive og abonnere\",\n    \"account_upgrade_dialog_interval_monthly\": \"månedlig\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Ingen telefonnumre endnu\",\n    \"notifications_no_subscriptions_description\": \"Klik på linket \\\"{{linktext}}\\\" for at oprette eller abonnere på et emne. Derefter kan du sende beskeder via PUT eller POST, og du vil modtage notifikationer her.\",\n    \"publish_dialog_message_published\": \"Notifikation udgivet\",\n    \"publish_dialog_chip_call_label\": \"Telefon opkald\",\n    \"account_basics_phone_numbers_dialog_title\": \"Tilføj telefonnummer\",\n    \"account_tokens_delete_dialog_description\": \"Før du sletter et adgangstoken, skal du sikre dig, at ingen programmer eller scripts aktivt bruger det. <strong>Denne handling kan ikke fortrydes</strong>.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"For spørgsmål om fakturering, se venligst vores <Link>hjemmeside</Link>.\",\n    \"account_usage_reservations_none\": \"Ingen reserverede emner til denne konto\",\n    \"account_tokens_description\": \"Brug adgangstokens, når du udgiver og abonnerer via ntfy API, så du ikke behøver at sende dine kontooplysninger. Tjek <Link>dokumentationen</Link> for at få mere at vide.\",\n    \"prefs_reservations_table\": \"Reserverede emner tabel\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} daglig e-mail\",\n    \"prefs_reservations_description\": \"Her kan du reservere emnenavne til personlig brug. Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.\",\n    \"prefs_users_description_no_sync\": \"Brugere og adgangskoder er ikke synkroniseret til din konto.\",\n    \"nav_button_publish_message\": \"Udgiv notifikation\",\n    \"prefs_users_table_base_url_header\": \"Tjeneste-URL\",\n    \"publish_dialog_attach_reset\": \"Fjern URL til vedhæftede filer\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} daglig besked\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst én reservation</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>.\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy-webappen har brug for IndexedDB for at fungere, og din browser understøtter ikke IndexedDB i privat browsing-tilstand.<br/><br/>Selv om dette er uheldigt, giver det heller ikke ret meget mening at bruge ntfy-webappen i privat browsing-tilstand alligevel, fordi alt er gemt i browserens lager. Du kan læse mere om det <githubLink>i dette GitHub issue</githubLink>, eller tale med os på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"publish_dialog_title_placeholder\": \"Notifikationstitel, f.eks. Advarsel om diskplads\",\n    \"account_basics_tier_description\": \"Din kontos niveau\",\n    \"account_basics_phone_numbers_description\": \"For notifikationer via telefonopkald\",\n    \"account_upgrade_dialog_cancel_warning\": \"Dette vil <strong>annullere dit abonnement</strong> og nedgradere din konto den {{date}}. På den dato <strong>slettes</strong> emnereservationer samt meddelelser, der er gemt på serveren.\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Ingen verificerede telefonnumre\",\n    \"publish_dialog_call_label\": \"Telefon opkald\",\n    \"account_usage_calls_title\": \"Telefonopkald foretaget\",\n    \"prefs_notifications_min_priority_description_any\": \"Viser alle notifikationer, uanset prioritet\",\n    \"error_boundary_gathering_info\": \"Indsaml mere info…\",\n    \"reservation_delete_dialog_action_keep_description\": \"Beskeder og vedhæftede filer, der er cachelagret på serveren, bliver offentligt synlige for personer med kendskab til emnenavnet.\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefonnummer kopieret til udklipsholder\",\n    \"prefs_reservations_dialog_description\": \"Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.\",\n    \"publish_dialog_title_topic\": \"Udgiv til {{topic}}\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"f.eks. +4512345678\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"f.eks. 123456\",\n    \"account_basics_username_description\": \"Hej, der er du ❤\",\n    \"publish_dialog_base_url_placeholder\": \"Tjeneste-URL, f.eks. https://example.com\",\n    \"account_basics_tier_interval_yearly\": \"årligt\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} årligt. Faktureres månedligt.\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Opkald\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"overskrider filgrænsen og kvoten på {{fileSizeLimit}}, {{remainingBytes}} tilbage\",\n    \"account_upgrade_dialog_interval_yearly\": \"årligt\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} faktureres årligt. Spar {{save}}.\",\n    \"account_usage_basis_ip_description\": \"Brugsstatistikker og begrænsninger for denne konto er baseret på din IP-adresse, så de kan være delt med andre brugere. Ovenstående grænser er omtrentlige baseret på de eksisterende hastigheds grænser.\",\n    \"account_basics_password_dialog_title\": \"Skift kodeord\",\n    \"account_basics_phone_numbers_title\": \"Telefonnumre\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"spar {{discount}}%\",\n    \"publish_dialog_drop_file_here\": \"Smid filen her\",\n    \"prefs_reservations_table_everyone_write_only\": \"Jeg kan udgive og abonnere, alle kan udgive\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Kan ikke redigere eller slette nuværende sessionstoken\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Vedhæftet filnavn\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Tjeneste-URL\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"måned\",\n    \"message_bar_show_dialog\": \"Vis udgivelsesdialogen\",\n    \"account_usage_calls_none\": \"Der kan ikke foretages telefonopkald med denne konto\",\n    \"nav_upgrade_banner_description\": \"Reserver emner, flere beskeder og e-mails og større vedhæftede filer\",\n    \"publish_dialog_call_reset\": \"Fjern telefon opkald\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Verifikationskode\",\n    \"reservation_delete_dialog_action_delete_description\": \"Cachelagrede beskeder og vedhæftede filer slettes permanent. Denne handling kan ikke fortrydes.\",\n    \"alert_grant_button\": \"Tillad nu\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} pr. fil, slettet efter {{expiry}}\",\n    \"publish_dialog_chip_click_label\": \"Klik på URL\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ring til mig\",\n    \"publish_dialog_call_item\": \"Ring til tlf. {{number}}\",\n    \"prefs_users_dialog_base_url_label\": \"Tjeneste-URL, f.eks. https://ntfy.sh\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_delete_dialog_billing_warning\": \"Hvis du sletter din konto, så annulleres dit abonnement med det samme. Du vil ikke længere have adgang til faktureringspanelet.\",\n    \"prefs_notifications_min_priority_description_max\": \"Vis notifikationer, hvis prioritet er 5 (maks.)\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst {{count}} reservationer</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>.\"\n}\n"
  },
  {
    "path": "web/public/static/langs/de.json",
    "content": "{\n    \"nav_topics_title\": \"Abonnierte Themen\",\n    \"nav_button_all_notifications\": \"Alle Benachrichtigungen\",\n    \"nav_button_settings\": \"Einstellungen\",\n    \"nav_button_documentation\": \"Dokumentation\",\n    \"nav_button_publish_message\": \"Benachrichtigung senden\",\n    \"nav_button_subscribe\": \"Thema abonnieren\",\n    \"alert_notification_permission_required_title\": \"Benachrichtigungen sind deaktiviert\",\n    \"publish_dialog_base_url_label\": \"Service-URL\",\n    \"publish_dialog_details_examples_description\": \"Beispiele und ausführliche Informationen zu allen Optionen findest Du in der <docsLink>Dokumentation</docsLink>.\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Dateiname des Anhangs\",\n    \"subscribe_dialog_login_description\": \"Dieses Thema benötigt eine Anmeldung. Bitte gib Benutzernamen und Kennwort ein.\",\n    \"prefs_notifications_title\": \"Benachrichtigungen\",\n    \"prefs_notifications_sound_title\": \"Benachrichtigungston\",\n    \"prefs_notifications_min_priority_max_only\": \"Nur höchste Priorität\",\n    \"prefs_notifications_delete_after_never\": \"Nie\",\n    \"prefs_users_dialog_password_label\": \"Kennwort\",\n    \"common_cancel\": \"Abbrechen\",\n    \"common_add\": \"Hinzufügen\",\n    \"common_save\": \"Speichern\",\n    \"prefs_appearance_language_title\": \"Sprache\",\n    \"notifications_none_for_any_description\": \"Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.\",\n    \"publish_dialog_message_placeholder\": \"Gib hier eine Nachricht ein\",\n    \"notifications_attachment_link_expires\": \"Link läuft ab am/um {{date}}\",\n    \"notifications_click_copy_url_title\": \"Link-URL in Zwischenablage kopieren\",\n    \"publish_dialog_priority_low\": \"Niedrige Priorität\",\n    \"publish_dialog_message_label\": \"Nachricht\",\n    \"action_bar_unsubscribe\": \"Abbestellen\",\n    \"notifications_copied_to_clipboard\": \"In Zwischenablage kopiert\",\n    \"notifications_loading\": \"Benachrichtigungen werden geladen …\",\n    \"notifications_attachment_open_title\": \"Gehe zu {{url}}\",\n    \"notifications_none_for_any_title\": \"Du hast keine Benachrichtigungen empfangen.\",\n    \"action_bar_send_test_notification\": \"Test-Benachrichtigung senden\",\n    \"alert_notification_permission_required_description\": \"Browser erlauben, Desktop-Benachrichtigungen anzuzeigen\",\n    \"notifications_tags\": \"Tags\",\n    \"message_bar_type_message\": \"Gib hier eine Nachricht ein\",\n    \"message_bar_error_publishing\": \"Fehler beim Senden der Benachrichtigung\",\n    \"alert_not_supported_title\": \"Benachrichtigungen werden nicht unterstützt\",\n    \"alert_not_supported_description\": \"Benachrichtigungen werden von deinem Browser nicht unterstützt\",\n    \"action_bar_settings\": \"Einstellungen\",\n    \"action_bar_clear_notifications\": \"Alle Benachrichtigungen löschen\",\n    \"alert_notification_permission_required_button\": \"Jetzt erlauben\",\n    \"notifications_none_for_topic_title\": \"Du hast für dieses Thema noch keine Benachrichtigungen empfangen.\",\n    \"notifications_click_open_button\": \"Link öffnen\",\n    \"notifications_more_details\": \"Ausführlichere Informationen findest Du auf der <websiteLink>Website</websiteLink> und in der <docsLink>Dokumentation</docsLink>.\",\n    \"notifications_attachment_copy_url_title\": \"URL des Anhangs in Zwischenablage kopieren\",\n    \"notifications_attachment_copy_url_button\": \"URL kopieren\",\n    \"notifications_attachment_open_button\": \"Anhang öffnen\",\n    \"notifications_attachment_link_expired\": \"Download-Link ist abgelaufen\",\n    \"notifications_click_copy_url_button\": \"Link kopieren\",\n    \"notifications_actions_open_url_title\": \"Gehe zu {{url}}\",\n    \"publish_dialog_other_features\": \"Andere Optionen:\",\n    \"notifications_none_for_topic_description\": \"Um Benachrichtigungen an dieses Thema zu senden, PUTe/POSTe an die Themen-URL.\",\n    \"notifications_no_subscriptions_title\": \"Anscheinend hast Du noch keine Themen abonniert.\",\n    \"notifications_no_subscriptions_description\": \"Klicke den „{{linktext}}“-Link um ein Thema zu erstellen oder zu abonnieren. Danach kannst Du Nachrichten per PUT oder POST senden und erhältst hier die Benachrichtigungen.\",\n    \"notifications_example\": \"Beispiel\",\n    \"publish_dialog_progress_uploading\": \"Wird hochgeladen …\",\n    \"publish_dialog_title_topic\": \"Senden an {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Benachrichtigung senden\",\n    \"publish_dialog_message_published\": \"Benachrichtigung gesendet\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"überschreitet das Dateigrößen-Limit {{fileSizeLimit}} und die Quota, {{remainingBytes}} übrig\",\n    \"publish_dialog_progress_uploading_detail\": \"Hochladen {{loaded}}/{{total}} ({{percent}} %) …\",\n    \"publish_dialog_priority_max\": \"Max. Priorität\",\n    \"publish_dialog_topic_placeholder\": \"Thema, z.B. phil_alerts\",\n    \"publish_dialog_attachment_limits_file_reached\": \"überschreitet das Dateigrößen-Limit {{fileSizeLimit}}\",\n    \"publish_dialog_topic_label\": \"Thema\",\n    \"publish_dialog_priority_default\": \"Standard-Priorität\",\n    \"publish_dialog_base_url_placeholder\": \"Service-URL, z.B. https://example.com\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"überschreitet die Quota, {{remainingBytes}} übrig\",\n    \"publish_dialog_priority_min\": \"Min. Priorität\",\n    \"publish_dialog_priority_high\": \"Hohe Priorität\",\n    \"publish_dialog_title_label\": \"Titel\",\n    \"publish_dialog_tags_placeholder\": \"Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup\",\n    \"publish_dialog_priority_label\": \"Priorität\",\n    \"publish_dialog_filename_label\": \"Dateiname\",\n    \"publish_dialog_title_placeholder\": \"Benachrichtigungstitel, z. B. Speicherplatzwarnung\",\n    \"publish_dialog_tags_label\": \"Tags\",\n    \"publish_dialog_click_label\": \"Klick-URL\",\n    \"publish_dialog_click_placeholder\": \"URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird\",\n    \"publish_dialog_email_label\": \"E-Mail\",\n    \"publish_dialog_attach_label\": \"URL des Anhangs\",\n    \"publish_dialog_attach_placeholder\": \"Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_placeholder\": \"Dateiname des Anhangs\",\n    \"publish_dialog_delay_label\": \"Verzögerung\",\n    \"publish_dialog_email_placeholder\": \"E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z.B. phil@example.com\",\n    \"publish_dialog_chip_click_label\": \"Klick-URL\",\n    \"publish_dialog_button_cancel_sending\": \"Senden abbrechen\",\n    \"publish_dialog_drop_file_here\": \"Datei hierher ziehen\",\n    \"publish_dialog_chip_email_label\": \"An E-Mail weiterleiten\",\n    \"publish_dialog_button_cancel\": \"Abbrechen\",\n    \"publish_dialog_chip_attach_file_label\": \"Lokale Datei anhängen\",\n    \"prefs_notifications_min_priority_title\": \"Minimale Priorität\",\n    \"prefs_users_add_button\": \"Benutzer hinzufügen\",\n    \"publish_dialog_delay_placeholder\": \"Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \\\"{{naturalLanguage}}\\\" (nur Englisch)\",\n    \"prefs_appearance_title\": \"Darstellung\",\n    \"subscribe_dialog_login_password_label\": \"Kennwort\",\n    \"common_back\": \"Zurück\",\n    \"publish_dialog_chip_attach_url_label\": \"Datei von URL anhängen\",\n    \"publish_dialog_chip_delay_label\": \"Auslieferung verzögern\",\n    \"publish_dialog_chip_topic_label\": \"Thema ändern\",\n    \"subscribe_dialog_subscribe_title\": \"Thema abonnieren\",\n    \"subscribe_dialog_login_username_label\": \"Benutzername, z.B. phil\",\n    \"subscribe_dialog_login_button_login\": \"Anmelden\",\n    \"prefs_notifications_sound_no_sound\": \"Kein Ton\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Standard-Priorität und höher\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Thema, z.B. phil_alerts\",\n    \"publish_dialog_button_send\": \"Senden\",\n    \"publish_dialog_checkbox_publish_another\": \"Weitere Nachricht senden\",\n    \"publish_dialog_attached_file_title\": \"Dateianhang:\",\n    \"emoji_picker_search_placeholder\": \"Emoji suchen\",\n    \"subscribe_dialog_subscribe_description\": \"Themen sind evtl. nicht kennwort-geschützt, also wähle einen schwer zu erratenden Namen. Nach dem Abonnieren kannst Du Benachrichtigungen per POST/PUT senden.\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Anderen Server verwenden\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Abbrechen\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Abonnieren\",\n    \"subscribe_dialog_login_title\": \"Anmeldung erforderlich\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonym\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Benutzer {{username}} hat keine Berechtigung\",\n    \"prefs_notifications_min_priority_any\": \"Alle Prioritäten\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Niedrige Priorität und höher\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Hohe Priorität und höher\",\n    \"prefs_notifications_delete_after_title\": \"Benachrichtigungen löschen\",\n    \"prefs_notifications_delete_after_three_hours\": \"Nach drei Stunden\",\n    \"prefs_users_dialog_title_edit\": \"Benutzer bearbeiten\",\n    \"prefs_notifications_delete_after_one_day\": \"Nach einem Tag\",\n    \"prefs_notifications_delete_after_one_week\": \"Nach einer Woche\",\n    \"prefs_notifications_delete_after_one_month\": \"Nach einem Monat\",\n    \"prefs_users_title\": \"Benutzer verwalten\",\n    \"prefs_users_table_user_header\": \"Benutzer\",\n    \"prefs_users_table_base_url_header\": \"Service-URL\",\n    \"prefs_users_dialog_base_url_label\": \"Service-URL, z.B. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Benutzername, z.B. phil\",\n    \"prefs_users_description\": \"Benutzer für kennwort-geschützte Themen hinzufügen/löschen. Achtung: Benutzername und Kennwort werden im lokalen Browser-Speicher abgelegt.\",\n    \"prefs_users_dialog_title_add\": \"Benutzer hinzufügen\",\n    \"error_boundary_title\": \"Oh nein, ntfy ist abgestürzt\",\n    \"error_boundary_description\": \"Das sollte offensichtlich nicht passieren. Sorry.<br/>Wenn möglich, <githubLink>melde den Fehler auf GitHub</githubLink> oder schreibe uns auf <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_stack_trace\": \"Stacktrace\",\n    \"error_boundary_gathering_info\": \"Weitere Informationen sammeln …\",\n    \"error_boundary_button_copy_stack_trace\": \"Stacktrace kopieren\",\n    \"prefs_notifications_delete_after_never_description\": \"Benachrichtigungen werden nie automatisch gelöscht\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Benachrichtigungen werden nach einem Monat automatisch gelöscht\",\n    \"prefs_notifications_min_priority_description_any\": \"Alle Benachrichtigungen (aller Prioritäten) anzeigen\",\n    \"prefs_notifications_min_priority_description_max\": \"Zeige Benachrichtigungen wenn ihre Priorität5 (max) ist\",\n    \"priority_low\": \"niedrig\",\n    \"priority_default\": \"Standard\",\n    \"priority_high\": \"hoch\",\n    \"priority_max\": \"max\",\n    \"prefs_notifications_sound_description_none\": \"Kein Ton beim Empfang einer Benachrichtigung\",\n    \"prefs_notifications_sound_description_some\": \"Sound {{sound}} beim Eintreffen einer Benachrichtigung abspielen\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Zeige Benachrichtigungen wenn ihre Priorität {{number}} ({{name}}) oder höher ist\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Benachrichtigungen werden nach drei Stunden automatisch gelöscht\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Benachrichtigungen werden nach einem Tag automatisch gelöscht\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Benachrichtigungen werden nach einer Woche automatisch gelöscht\",\n    \"priority_min\": \"min\",\n    \"notifications_actions_not_supported\": \"Diese Aktion wird in der Web-App nicht unterstützt\",\n    \"notifications_actions_http_request_title\": \"Sende HTTP {{method}} an {{url}}\",\n    \"action_bar_show_menu\": \"Menü anzeigen\",\n    \"action_bar_toggle_mute\": \"Stummschaltung an/aus\",\n    \"message_bar_show_dialog\": \"Dialog zur Veröffentlichung anzeigen\",\n    \"message_bar_publish\": \"Benachrichtigung veröffentlichen\",\n    \"nav_button_connecting\": \"verbinde\",\n    \"notifications_list\": \"Benachrichtigungsliste\",\n    \"notifications_mark_read\": \"Als gelesen markieren\",\n    \"notifications_delete\": \"Löschen\",\n    \"notifications_priority_x\": \"Priorität {{priority}}\",\n    \"notifications_attachment_file_image\": \"Bilddatei\",\n    \"notifications_attachment_image\": \"Bild des Anhangs\",\n    \"notifications_attachment_file_video\": \"Videodatei\",\n    \"notifications_attachment_file_audio\": \"Audiodatei\",\n    \"notifications_attachment_file_app\": \"Android App-Datei\",\n    \"notifications_attachment_file_document\": \"anderes Dokument\",\n    \"publish_dialog_attached_file_remove\": \"Angehängte Datei entfernen\",\n    \"emoji_picker_search_clear\": \"Suche leeren\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Service URL\",\n    \"prefs_notifications_sound_play\": \"Gewählten Sound abspielen\",\n    \"prefs_users_table\": \"Benutzertabelle\",\n    \"prefs_users_edit_button\": \"Benutzer bearbeiten\",\n    \"prefs_users_delete_button\": \"Benutzer löschen\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Private Browser-Tabs werden nicht unterstützt\",\n    \"publish_dialog_delay_reset\": \"Verzögerte Zustellung entfernen\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_toggle_action_menu\": \"Aktionsmenü öffnen/schließen\",\n    \"notifications_new_indicator\": \"Neue Benachrichtigung\",\n    \"publish_dialog_email_reset\": \"E-Mail-Weiterleitung entfernen\",\n    \"action_bar_logo_alt\": \"ntfy Logo\",\n    \"nav_button_muted\": \"Benachrichtigungen stummgeschaltet\",\n    \"notifications_list_item\": \"Benachrichtigung\",\n    \"publish_dialog_emoji_picker_show\": \"Emoji wählen\",\n    \"publish_dialog_topic_reset\": \"Thema zurücksetzen\",\n    \"publish_dialog_attach_reset\": \"angehängte URL entfernen\",\n    \"publish_dialog_click_reset\": \"Klick-URL entfernen\",\n    \"account_tokens_delete_dialog_description\": \"Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. <strong>Diese Aktion kann nicht rückgängig gemacht werden</strong>.\",\n    \"account_upgrade_dialog_cancel_warning\": \"Dies wird <strong>Dein Abo stornieren</strong> und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten <strong>gelöscht</strong>.\",\n    \"prefs_reservations_table_everyone_read_write\": \"Jeder kann veröffentlichen und lesen\",\n    \"prefs_reservations_table_everyone_read_only\": \"Ich kann veröffentlichen und lesen, jeder kann lesen\",\n    \"prefs_reservations_table_access_header\": \"Zugriff\",\n    \"account_tokens_dialog_button_cancel\": \"Abbrechen\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token verfällt in {{hours}} Stunden\",\n    \"account_tokens_dialog_expires_never\": \"Token verfällt nie\",\n    \"signup_form_username\": \"Benutzername\",\n    \"signup_form_button_submit\": \"Konto anlegen\",\n    \"signup_already_have_account\": \"Du hast schon ein Konto? Melde Dich an!\",\n    \"signup_disabled\": \"Die Anmeldung ist deaktiviert\",\n    \"login_title\": \"Melde Dich mit Deinem ntfy-Konto an\",\n    \"login_form_button_submit\": \"Anmelden\",\n    \"login_link_signup\": \"Konto erstellen\",\n    \"login_disabled\": \"Anmeldung ist deaktiviert\",\n    \"action_bar_account\": \"Konto\",\n    \"action_bar_change_display_name\": \"Anzeigenamen ändern\",\n    \"action_bar_reservation_add\": \"Thema reservieren\",\n    \"action_bar_reservation_edit\": \"Reservierung ändern\",\n    \"action_bar_reservation_delete\": \"Reservierung entfernen\",\n    \"action_bar_reservation_limit_reached\": \"Grenze erreicht\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Einstellungen\",\n    \"action_bar_profile_logout\": \"Ausloggen\",\n    \"action_bar_sign_in\": \"Anmelden\",\n    \"signup_form_password\": \"Kennwort\",\n    \"signup_form_toggle_password_visibility\": \"Kennwort-Sichtbarkeit umschalten\",\n    \"nav_button_account\": \"Konto\",\n    \"nav_upgrade_banner_description\": \"Themen reservieren, mehr Nachrichten & E-Mails und größere Anhänge\",\n    \"display_name_dialog_title\": \"Anzeigennamen ändern\",\n    \"display_name_dialog_placeholder\": \"Anzeigename\",\n    \"reserve_dialog_checkbox_label\": \"Thema reservieren und Zugriffsrechte konfigurieren\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Thema ist bereits reserviert\",\n    \"account_basics_username_title\": \"Benutzername\",\n    \"account_basics_username_description\": \"Hey, das bist Du ❤\",\n    \"account_basics_password_description\": \"Konto-Kennwort ändern\",\n    \"account_basics_password_dialog_title\": \"Kennwort ändern\",\n    \"account_basics_password_dialog_current_password_label\": \"Aktuelles Kennwort\",\n    \"account_basics_password_dialog_new_password_label\": \"Neues Kennwort\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Kennwort bestätigen\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Kennwort falsch\",\n    \"account_usage_title\": \"Verbrauch\",\n    \"account_usage_of_limit\": \"von {{limit}}\",\n    \"account_usage_unlimited\": \"unbegrenzt\",\n    \"account_usage_limits_reset_daily\": \"Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt\",\n    \"account_basics_password_title\": \"Kennwort\",\n    \"account_basics_tier_description\": \"Der Funktionsumfang Deines Konto-Levels\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(mit Level {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(kein Level)\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"account_basics_tier_basic\": \"Basic\",\n    \"account_basics_tier_free\": \"Kostenlos\",\n    \"account_basics_tier_paid_until\": \"Abo bezahlt bis {{date}} mit automatischer Verlängerung\",\n    \"account_basics_tier_payment_overdue\": \"Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.\",\n    \"account_basics_tier_manage_billing_button\": \"Zahlung verwalten\",\n    \"account_usage_messages_title\": \"Veröffentlichte Nachrichten\",\n    \"account_usage_emails_title\": \"Gesendete E-Mails\",\n    \"account_usage_reservations_title\": \"Reservierte Themen\",\n    \"account_usage_reservations_none\": \"Keine reservierten Themen für dieses Konto\",\n    \"account_usage_attachment_storage_title\": \"Speicherplatz für Anhänge\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} pro Datei, Löschung nach {{expiry}}\",\n    \"account_usage_cannot_create_portal_session\": \"Kann Abrechnungsportal nicht öffnen\",\n    \"account_delete_title\": \"Konto löschen\",\n    \"account_delete_description\": \"Konto endgültig löschen\",\n    \"account_delete_dialog_label\": \"Kennwort\",\n    \"account_delete_dialog_button_cancel\": \"Abbrechen\",\n    \"account_delete_dialog_button_submit\": \"Lösche mein Konto endgültig\",\n    \"account_basics_tier_change_button\": \"Wechseln\",\n    \"account_basics_tier_canceled_subscription\": \"Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.\",\n    \"account_usage_basis_ip_description\": \"Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.\",\n    \"account_delete_dialog_billing_warning\": \"Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.\",\n    \"account_upgrade_dialog_title\": \"Konto-Level ändern\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Anrechnung</strong>: Wenn Du auf einen höheren kostenpflichtigen Level wechselst wird die Differenz <strong>sofort berechnet</strong>. Beim Wechsel auf ein kleineres Level verwenden wir Dein Guthaben für zukünftige Abrechnungsperioden.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reservierte Themen\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} Nachrichten pro Tag\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} E-Mails pro Tag\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} pro Datei\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} gesamter Speicherplatz\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Ausgewählt\",\n    \"account_upgrade_dialog_tier_current_label\": \"Aktuell\",\n    \"account_upgrade_dialog_button_cancel\": \"Abbrechen\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Jetzt ein Konto anlegen\",\n    \"account_upgrade_dialog_button_pay_now\": \"Jetzt bezahlen und abonnieren\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Abo stornieren\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Abo aktualisieren\",\n    \"account_tokens_title\": \"Access-Token\",\n    \"account_tokens_description\": \"Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die <Link>Dokumentation</Link> für mehr Info.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Bezeichnung\",\n    \"account_tokens_table_last_access_header\": \"Letzter Zugriff\",\n    \"account_tokens_table_expires_header\": \"Verfällt\",\n    \"account_tokens_table_never_expires\": \"Verfällt nie\",\n    \"account_tokens_table_current_session\": \"Aktuelle Browser-Sitzung\",\n    \"common_copy_to_clipboard\": \"In die Zwischenablage kopieren\",\n    \"account_tokens_table_copied_to_clipboard\": \"Access-Token kopiert\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Aktuelles Token kann nicht bearbeitet oder gelöscht werden\",\n    \"account_tokens_table_create_token_button\": \"Access-Token erzeugen\",\n    \"account_tokens_table_last_origin_tooltip\": \"Von IP-Adresse {{ip}}, klicke zum Nachschlagen\",\n    \"account_tokens_dialog_title_create\": \"Access-Token erzeugen\",\n    \"account_tokens_dialog_title_edit\": \"Access-Token bearbeiten\",\n    \"account_tokens_dialog_title_delete\": \"Access-Token löschen\",\n    \"account_tokens_dialog_label\": \"Bezeichnung, z.B. Radarr Benachrichtigungen\",\n    \"account_tokens_dialog_button_create\": \"Token erzeugen\",\n    \"account_tokens_dialog_button_update\": \"Token aktualisieren\",\n    \"account_tokens_dialog_expires_label\": \"Access-Token verfällt in\",\n    \"account_tokens_dialog_expires_unchanged\": \"Verfallsdatum nicht ändern\",\n    \"account_tokens_dialog_expires_x_days\": \"Token verfällt in {{days}} Tagen\",\n    \"account_tokens_delete_dialog_title\": \"Access-Token löschen\",\n    \"account_tokens_delete_dialog_submit_button\": \"Token endgültig löschen\",\n    \"prefs_users_description_no_sync\": \"Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden\",\n    \"prefs_reservations_title\": \"Reservierte Themen\",\n    \"prefs_reservations_description\": \"Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.\",\n    \"prefs_reservations_limit_reached\": \"Du hast Dein Limit an reservierten Themen erreicht.\",\n    \"prefs_reservations_add_button\": \"Reserviertes Thema hinzufügen\",\n    \"prefs_reservations_edit_button\": \"Zugriff auf Thema bearbeiten\",\n    \"prefs_reservations_delete_button\": \"Zugriff auf Thema zurücksetzen\",\n    \"prefs_reservations_table\": \"Übersicht reservierter Themen\",\n    \"prefs_reservations_table_topic_header\": \"Thema\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Nur ich kann veröffentlichen und lesen\",\n    \"prefs_reservations_table_everyone_write_only\": \"Ich kann veröffentlichen und lesen, jeder kann veröffentlichen\",\n    \"prefs_reservations_table_not_subscribed\": \"Nicht abonniert\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klicken um zu abonnieren\",\n    \"prefs_reservations_dialog_title_add\": \"Thema reservieren\",\n    \"prefs_reservations_dialog_title_edit\": \"Reserviertes Thema bearbeiten\",\n    \"prefs_reservations_dialog_title_delete\": \"Thema-Reservierung löschen\",\n    \"prefs_reservations_dialog_description\": \"Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.\",\n    \"prefs_reservations_dialog_topic_label\": \"Thema\",\n    \"prefs_reservations_dialog_access_label\": \"Zugriff\",\n    \"reservation_delete_dialog_description\": \"Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Behalte gecachte Nachrichten und Dateien\",\n    \"reservation_delete_dialog_action_keep_description\": \"Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Löschen gecachte Nachrichten und Dateien\",\n    \"reservation_delete_dialog_action_delete_description\": \"Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.\",\n    \"reservation_delete_dialog_submit_button\": \"Reservierung löschen\",\n    \"account_basics_password_dialog_button_submit\": \"Kennwort ändern\",\n    \"account_basics_tier_title\": \"Kontotyp\",\n    \"account_basics_tier_upgrade_button\": \"Upgrade auf Pro\",\n    \"account_delete_dialog_description\": \"Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.\",\n    \"signup_form_confirm_password\": \"Kennwort wiederholen\",\n    \"signup_title\": \"Erstelle ein ntfy-Konto\",\n    \"signup_error_username_taken\": \"Benutzername {{username}} ist bereits vergeben\",\n    \"signup_error_creation_limit_reached\": \"Grenze der Account-Erstellung erreicht\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Namen erzeugen\",\n    \"account_basics_title\": \"Konto\",\n    \"action_bar_sign_up\": \"Konto erstellen\",\n    \"nav_upgrade_banner_label\": \"Upgrade auf ntfy Pro\",\n    \"alert_not_supported_context_description\": \"Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der <mdnLink>Notifications API</mdnLink>.\",\n    \"display_name_dialog_description\": \"Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.\",\n    \"account_basics_username_admin_tooltip\": \"Du bist Admin\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"spare {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"spare bis zu {{discount}}%\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"Monat\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} pro Jahr. Spare {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Bei Fragen zur Abrechnung, <Link>kontaktiere uns</Link> bitte direkt.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Bei Fragen zur Abrechnung sieh bitte auf unserer <Link>Webseite</Link> nach.\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Keine reservierten Themen\",\n    \"account_basics_tier_interval_yearly\": \"jährlich\",\n    \"account_basics_tier_interval_monthly\": \"monatlich\",\n    \"account_upgrade_dialog_interval_monthly\": \"Monatlich\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} pro Jahr. Monatlich abgerechnet.\",\n    \"account_upgrade_dialog_interval_yearly\": \"Jährlich\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} tägliche Nachricht\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reserviertes Thema\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} tägliche E-Mail\",\n    \"publish_dialog_call_label\": \"Telefonanruf\",\n    \"publish_dialog_call_item\": \"Telefonnummer {{number}} anrufen\",\n    \"publish_dialog_chip_call_label\": \"Telefonanruf\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Keine verifizierten Telefonnummern\",\n    \"account_basics_phone_numbers_title\": \"Telefonnummern\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefonnummer wurde in die Zwischenablage kopiert\",\n    \"account_basics_phone_numbers_dialog_title\": \"Telefonnummer hinzufügen\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} Telefonanrufe pro Tag\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Keine Telefonanrufe\",\n    \"publish_dialog_call_reset\": \"Telefonanruf entfernen\",\n    \"account_basics_phone_numbers_dialog_description\": \"Um die Benachrichtigung per Telefonanruf zu nutzen musst Du mindestens eine Telefonnummer hinzufügen und verifizieren. Die Verifizierung kann per SMS oder über einen Anruf erfolgen.\",\n    \"account_basics_phone_numbers_description\": \"Für Telefon-Benachrichtigungen\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Noch keine Telefonnummern\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefonnummer\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Anruf\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"z.B. +49123456789\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ruf mich an\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"SMS senden\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Verifizierungs-Code\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"z.B. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Code bestätigen\",\n    \"account_usage_calls_title\": \"Getätigte Anrufe\",\n    \"account_usage_calls_none\": \"Noch keine Anrufe mit diesem Account getätigt\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} Telefonanrufe pro Tag\",\n    \"action_bar_mute_notifications\": \"Benachrichtigungen stummschalten\",\n    \"action_bar_unmute_notifications\": \"Stummschaltung von Benachrichtigungen aufheben\",\n    \"alert_notification_permission_denied_title\": \"Benachrichtigungen sind blockiert\",\n    \"alert_notification_permission_denied_description\": \"Bitte reaktiviere diese in deinem Browser\",\n    \"notifications_actions_failed_notification\": \"Aktion nicht erfolgreich\",\n    \"alert_notification_ios_install_required_title\": \"iOS Installation erforderlich\",\n    \"alert_notification_ios_install_required_description\": \"Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist\",\n    \"publish_dialog_checkbox_markdown\": \"Als Markdown formatieren\",\n    \"prefs_notifications_web_push_title\": \"Hintergrundbenachrichtigungen\",\n    \"prefs_notifications_web_push_disabled_description\": \"Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Aktiviert für {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Deaktiviert\",\n    \"prefs_appearance_theme_title\": \"Thema\",\n    \"prefs_appearance_theme_system\": \"System (Standard)\",\n    \"prefs_appearance_theme_dark\": \"Nachtmodus\",\n    \"prefs_appearance_theme_light\": \"Tagmodus\",\n    \"error_boundary_button_reload_ntfy\": \"ntfy neu laden\",\n    \"web_push_subscription_expiring_title\": \"Benachrichtigungen werden pausiert\",\n    \"web_push_subscription_expiring_body\": \"Öffne ntfy um weiterhin Benachrichtigungen zu erhalten\",\n    \"web_push_unknown_notification_title\": \"Unbekannte Benachrichtigung vom Server empfangen\",\n    \"web_push_unknown_notification_body\": \"Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest\",\n    \"prefs_notifications_web_push_enabled_description\": \"Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden\"\n}\n"
  },
  {
    "path": "web/public/static/langs/en.json",
    "content": "{\n  \"common_cancel\": \"Cancel\",\n  \"common_save\": \"Save\",\n  \"common_add\": \"Add\",\n  \"common_back\": \"Back\",\n  \"common_copy_to_clipboard\": \"Copy to clipboard\",\n  \"common_refresh\": \"Refresh\",\n  \"version_update_available_title\": \"New version available\",\n  \"version_update_available_description\": \"The ntfy server has been updated. Please refresh the page.\",\n  \"signup_title\": \"Create a ntfy account\",\n  \"signup_form_username\": \"Username\",\n  \"signup_form_password\": \"Password\",\n  \"signup_form_confirm_password\": \"Confirm password\",\n  \"signup_form_button_submit\": \"Sign up\",\n  \"signup_form_toggle_password_visibility\": \"Toggle password visibility\",\n  \"signup_already_have_account\": \"Already have an account? Sign in!\",\n  \"signup_disabled\": \"Signup is disabled\",\n  \"signup_error_username_taken\": \"Username {{username}} is already taken\",\n  \"signup_error_creation_limit_reached\": \"Account creation limit reached\",\n  \"login_title\": \"Sign in to your ntfy account\",\n  \"login_form_button_submit\": \"Sign in\",\n  \"login_link_signup\": \"Sign up\",\n  \"login_disabled\": \"Login is disabled\",\n  \"action_bar_show_menu\": \"Show menu\",\n  \"action_bar_logo_alt\": \"ntfy logo\",\n  \"action_bar_settings\": \"Settings\",\n  \"action_bar_account\": \"Account\",\n  \"action_bar_change_display_name\": \"Change display name\",\n  \"action_bar_reservation_add\": \"Reserve topic\",\n  \"action_bar_reservation_edit\": \"Change reservation\",\n  \"action_bar_reservation_delete\": \"Remove reservation\",\n  \"action_bar_reservation_limit_reached\": \"Limit reached\",\n  \"action_bar_send_test_notification\": \"Send test notification\",\n  \"action_bar_clear_notifications\": \"Clear all notifications\",\n  \"action_bar_mute_notifications\": \"Mute notifications\",\n  \"action_bar_unmute_notifications\": \"Unmute notifications\",\n  \"action_bar_unsubscribe\": \"Unsubscribe\",\n  \"action_bar_toggle_mute\": \"Mute/unmute notifications\",\n  \"action_bar_toggle_action_menu\": \"Open/close action menu\",\n  \"action_bar_profile_title\": \"Profile\",\n  \"action_bar_profile_settings\": \"Settings\",\n  \"action_bar_profile_logout\": \"Logout\",\n  \"action_bar_sign_in\": \"Sign in\",\n  \"action_bar_sign_up\": \"Sign up\",\n  \"message_bar_type_message\": \"Type a message here\",\n  \"message_bar_error_publishing\": \"Error publishing notification\",\n  \"message_bar_show_dialog\": \"Show publish dialog\",\n  \"message_bar_publish\": \"Publish message\",\n  \"nav_topics_title\": \"Subscribed topics\",\n  \"nav_button_all_notifications\": \"All notifications\",\n  \"nav_button_account\": \"Account\",\n  \"nav_button_settings\": \"Settings\",\n  \"nav_button_documentation\": \"Documentation\",\n  \"nav_button_publish_message\": \"Publish notification\",\n  \"nav_button_subscribe\": \"Subscribe to topic\",\n  \"nav_button_muted\": \"Notifications muted\",\n  \"nav_button_connecting\": \"connecting\",\n  \"nav_upgrade_banner_label\": \"Upgrade to ntfy Pro\",\n  \"nav_upgrade_banner_description\": \"Reserve topics, more messages & emails, and larger attachments\",\n  \"alert_notification_permission_required_title\": \"Notifications are disabled\",\n  \"alert_notification_permission_required_description\": \"Grant your browser permission to display desktop notifications\",\n  \"alert_notification_permission_required_button\": \"Grant now\",\n  \"alert_notification_permission_denied_title\": \"Notifications are blocked\",\n  \"alert_notification_permission_denied_description\": \"Please re-enable them in your browser\",\n  \"alert_notification_ios_install_required_title\": \"iOS install required\",\n  \"alert_notification_ios_install_required_description\": \"Click on the Share icon and Add to Home Screen to enable notifications on iOS\",\n  \"alert_not_supported_title\": \"Notifications not supported\",\n  \"alert_not_supported_description\": \"Notifications are not supported in your browser\",\n  \"alert_not_supported_context_description\": \"Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.\",\n  \"notifications_list\": \"Notifications list\",\n  \"notifications_list_item\": \"Notification\",\n  \"notifications_mark_read\": \"Mark as read\",\n  \"notifications_delete\": \"Delete\",\n  \"notifications_copied_to_clipboard\": \"Copied to clipboard\",\n  \"notifications_tags\": \"Tags\",\n  \"notifications_priority_x\": \"Priority {{priority}}\",\n  \"notifications_new_indicator\": \"New notification\",\n  \"notifications_attachment_image\": \"Attachment image\",\n  \"notifications_attachment_copy_url_title\": \"Copy attachment URL to clipboard\",\n  \"notifications_attachment_copy_url_button\": \"Copy URL\",\n  \"notifications_attachment_open_title\": \"Go to {{url}}\",\n  \"notifications_attachment_open_button\": \"Open attachment\",\n  \"notifications_attachment_link_expires\": \"link expires {{date}}\",\n  \"notifications_attachment_link_expired\": \"download link expired\",\n  \"notifications_attachment_file_image\": \"image file\",\n  \"notifications_attachment_file_video\": \"video file\",\n  \"notifications_attachment_file_audio\": \"audio file\",\n  \"notifications_attachment_file_app\": \"Android app file\",\n  \"notifications_attachment_file_document\": \"other document\",\n  \"notifications_click_copy_url_title\": \"Copy link URL to clipboard\",\n  \"notifications_click_copy_url_button\": \"Copy link\",\n  \"notifications_click_open_button\": \"Open link\",\n  \"notifications_actions_open_url_title\": \"Go to {{url}}\",\n  \"notifications_actions_not_supported\": \"Action not supported in web app\",\n  \"notifications_actions_http_request_title\": \"Send HTTP {{method}} to {{url}}\",\n  \"notifications_actions_failed_notification\": \"Unsuccessful action\",\n  \"notifications_none_for_topic_title\": \"You haven't received any notifications for this topic yet.\",\n  \"notifications_none_for_topic_description\": \"To send notifications to this topic, simply PUT or POST to the topic URL.\",\n  \"notifications_none_for_any_title\": \"You haven't received any notifications.\",\n  \"notifications_none_for_any_description\": \"To send notifications to a topic, simply PUT or POST to the topic URL. Here's an example using one of your topics.\",\n  \"notifications_no_subscriptions_title\": \"It looks like you don't have any subscriptions yet.\",\n  \"notifications_no_subscriptions_description\": \"Click the \\\"{{linktext}}\\\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.\",\n  \"notifications_example\": \"Example\",\n  \"notifications_more_details\": \"For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.\",\n  \"display_name_dialog_title\": \"Change display name\",\n  \"display_name_dialog_description\": \"Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.\",\n  \"display_name_dialog_placeholder\": \"Display name\",\n  \"reserve_dialog_checkbox_label\": \"Reserve topic and configure access\",\n  \"notifications_loading\": \"Loading notifications …\",\n  \"publish_dialog_title_topic\": \"Publish to {{topic}}\",\n  \"publish_dialog_title_no_topic\": \"Publish notification\",\n  \"publish_dialog_progress_uploading\": \"Uploading …\",\n  \"publish_dialog_progress_uploading_detail\": \"Uploading {{loaded}}/{{total}} ({{percent}}%) …\",\n  \"publish_dialog_message_published\": \"Notification published\",\n  \"publish_dialog_attachment_limits_file_and_quota_reached\": \"exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining\",\n  \"publish_dialog_attachment_limits_file_reached\": \"exceeds {{fileSizeLimit}} file limit\",\n  \"publish_dialog_attachment_limits_quota_reached\": \"exceeds quota, {{remainingBytes}} remaining\",\n  \"publish_dialog_emoji_picker_show\": \"Pick emoji\",\n  \"publish_dialog_priority_min\": \"Min. priority\",\n  \"publish_dialog_priority_low\": \"Low priority\",\n  \"publish_dialog_priority_default\": \"Default priority\",\n  \"publish_dialog_priority_high\": \"High priority\",\n  \"publish_dialog_priority_max\": \"Max. priority\",\n  \"publish_dialog_base_url_label\": \"Service URL\",\n  \"publish_dialog_base_url_placeholder\": \"Service URL, e.g. https://example.com\",\n  \"publish_dialog_topic_label\": \"Topic name\",\n  \"publish_dialog_topic_placeholder\": \"Topic name, e.g. phil_alerts\",\n  \"publish_dialog_topic_reset\": \"Reset topic\",\n  \"publish_dialog_title_label\": \"Title\",\n  \"publish_dialog_title_placeholder\": \"Notification title, e.g. Disk space alert\",\n  \"publish_dialog_message_label\": \"Message\",\n  \"publish_dialog_message_placeholder\": \"Type a message here\",\n  \"publish_dialog_tags_label\": \"Tags\",\n  \"publish_dialog_tags_placeholder\": \"Comma-separated list of tags, e.g. warning, srv1-backup\",\n  \"publish_dialog_priority_label\": \"Priority\",\n  \"publish_dialog_click_label\": \"Click URL\",\n  \"publish_dialog_click_placeholder\": \"URL that is opened when notification is clicked\",\n  \"publish_dialog_click_reset\": \"Remove click URL\",\n  \"publish_dialog_email_label\": \"Email\",\n  \"publish_dialog_email_placeholder\": \"Address to forward the notification to, e.g. phil@example.com\",\n  \"publish_dialog_email_reset\": \"Remove email forward\",\n  \"publish_dialog_call_label\": \"Phone call\",\n  \"publish_dialog_call_item\": \"Call phone number {{number}}\",\n  \"publish_dialog_call_reset\": \"Remove phone call\",\n  \"publish_dialog_attach_label\": \"Attachment URL\",\n  \"publish_dialog_attach_placeholder\": \"Attach file by URL, e.g. https://f-droid.org/F-Droid.apk\",\n  \"publish_dialog_attach_reset\": \"Remove attachment URL\",\n  \"publish_dialog_filename_label\": \"Filename\",\n  \"publish_dialog_filename_placeholder\": \"Attachment filename\",\n  \"publish_dialog_delay_label\": \"Delay\",\n  \"publish_dialog_delay_placeholder\": \"Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \\\"{{naturalLanguage}}\\\" (English only)\",\n  \"publish_dialog_delay_reset\": \"Remove delayed delivery\",\n  \"publish_dialog_other_features\": \"Other features:\",\n  \"publish_dialog_chip_click_label\": \"Click URL\",\n  \"publish_dialog_chip_email_label\": \"Forward to email\",\n  \"publish_dialog_chip_call_label\": \"Phone call\",\n  \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"No verified phone numbers\",\n  \"publish_dialog_chip_attach_url_label\": \"Attach file by URL\",\n  \"publish_dialog_chip_attach_file_label\": \"Attach local file\",\n  \"publish_dialog_chip_delay_label\": \"Delay delivery\",\n  \"publish_dialog_chip_topic_label\": \"Change topic\",\n  \"publish_dialog_details_examples_description\": \"For examples and a detailed description of all send features, please refer to the <docsLink>documentation</docsLink>.\",\n  \"publish_dialog_button_cancel_sending\": \"Cancel sending\",\n  \"publish_dialog_button_cancel\": \"Cancel\",\n  \"publish_dialog_button_send\": \"Send\",\n  \"publish_dialog_checkbox_markdown\": \"Format as Markdown\",\n  \"publish_dialog_checkbox_publish_another\": \"Publish another\",\n  \"publish_dialog_attached_file_title\": \"Attached file:\",\n  \"publish_dialog_attached_file_filename_placeholder\": \"Attachment filename\",\n  \"publish_dialog_attached_file_remove\": \"Remove attached file\",\n  \"publish_dialog_drop_file_here\": \"Drop file here\",\n  \"emoji_picker_search_placeholder\": \"Search emoji\",\n  \"emoji_picker_search_clear\": \"Clear search\",\n  \"subscribe_dialog_subscribe_title\": \"Subscribe to topic\",\n  \"subscribe_dialog_subscribe_description\": \"Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.\",\n  \"subscribe_dialog_subscribe_topic_placeholder\": \"Topic name, e.g. phil_alerts\",\n  \"subscribe_dialog_subscribe_use_another_label\": \"Use another server\",\n  \"subscribe_dialog_subscribe_use_another_background_info\": \"Notifications from other servers will not be received when the web app is not open\",\n  \"subscribe_dialog_subscribe_base_url_label\": \"Service URL\",\n  \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generate name\",\n  \"subscribe_dialog_subscribe_button_cancel\": \"Cancel\",\n  \"subscribe_dialog_subscribe_button_subscribe\": \"Subscribe\",\n  \"subscribe_dialog_login_title\": \"Login required\",\n  \"subscribe_dialog_login_description\": \"This topic is password-protected. Please enter username and password to subscribe.\",\n  \"subscribe_dialog_login_username_label\": \"Username, e.g. phil\",\n  \"subscribe_dialog_login_password_label\": \"Password\",\n  \"subscribe_dialog_login_button_login\": \"Login\",\n  \"subscribe_dialog_error_user_not_authorized\": \"User {{username}} not authorized\",\n  \"subscribe_dialog_error_topic_already_reserved\": \"Topic already reserved\",\n  \"subscribe_dialog_error_user_anonymous\": \"anonymous\",\n  \"account_basics_title\": \"Account\",\n  \"account_basics_username_title\": \"Username\",\n  \"account_basics_username_description\": \"Hey, that's you ❤\",\n  \"account_basics_username_admin_tooltip\": \"You are Admin\",\n  \"account_basics_password_title\": \"Password\",\n  \"account_basics_password_description\": \"Change your account password\",\n  \"account_basics_password_dialog_title\": \"Change password\",\n  \"account_basics_password_dialog_current_password_label\": \"Current password\",\n  \"account_basics_password_dialog_new_password_label\": \"New password\",\n  \"account_basics_password_dialog_confirm_password_label\": \"Confirm password\",\n  \"account_basics_password_dialog_button_submit\": \"Change password\",\n  \"account_basics_password_dialog_current_password_incorrect\": \"Password incorrect\",\n  \"account_basics_phone_numbers_title\": \"Phone numbers\",\n  \"account_basics_phone_numbers_dialog_description\": \"To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.\",\n  \"account_basics_phone_numbers_description\": \"For phone call notifications\",\n  \"account_basics_phone_numbers_no_phone_numbers_yet\": \"No phone numbers yet\",\n  \"account_basics_phone_numbers_copied_to_clipboard\": \"Phone number copied to clipboard\",\n  \"account_basics_phone_numbers_dialog_title\": \"Add phone number\",\n  \"account_basics_phone_numbers_dialog_number_label\": \"Phone number\",\n  \"account_basics_phone_numbers_dialog_number_placeholder\": \"e.g. +1222333444\",\n  \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Send SMS\",\n  \"account_basics_phone_numbers_dialog_verify_button_call\": \"Call me\",\n  \"account_basics_phone_numbers_dialog_code_label\": \"Verification code\",\n  \"account_basics_phone_numbers_dialog_code_placeholder\": \"e.g. 123456\",\n  \"account_basics_phone_numbers_dialog_check_verification_button\": \"Confirm code\",\n  \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n  \"account_basics_phone_numbers_dialog_channel_call\": \"Call\",\n  \"account_basics_cannot_edit_or_delete_provisioned_user\": \"A provisioned user cannot be edited or deleted\",\n  \"account_usage_title\": \"Usage\",\n  \"account_usage_of_limit\": \"of {{limit}}\",\n  \"account_usage_unlimited\": \"Unlimited\",\n  \"account_usage_limits_reset_daily\": \"Usage limits are reset daily at midnight (UTC)\",\n  \"account_basics_tier_title\": \"Account type\",\n  \"account_basics_tier_description\": \"Your account's power level\",\n  \"account_basics_tier_admin\": \"Admin\",\n  \"account_basics_tier_admin_suffix_with_tier\": \"(with {{tier}} tier)\",\n  \"account_basics_tier_admin_suffix_no_tier\": \"(no tier)\",\n  \"account_basics_tier_basic\": \"Basic\",\n  \"account_basics_tier_free\": \"Free\",\n  \"account_basics_tier_interval_monthly\": \"monthly\",\n  \"account_basics_tier_interval_yearly\": \"annually\",\n  \"account_basics_tier_upgrade_button\": \"Upgrade to Pro\",\n  \"account_basics_tier_change_button\": \"Change\",\n  \"account_basics_tier_paid_until\": \"Subscription paid until {{date}}, and will auto-renew\",\n  \"account_basics_tier_payment_overdue\": \"Your payment is overdue. Please update your payment method, or your account will be downgraded soon.\",\n  \"account_basics_tier_canceled_subscription\": \"Your subscription was canceled and will be downgraded to a free account on {{date}}.\",\n  \"account_basics_tier_manage_billing_button\": \"Manage billing\",\n  \"account_usage_messages_title\": \"Published messages\",\n  \"account_usage_emails_title\": \"Emails sent\",\n  \"account_usage_calls_title\": \"Phone calls made\",\n  \"account_usage_calls_none\": \"No phone calls can be made with this account\",\n  \"account_usage_reservations_title\": \"Reserved topics\",\n  \"account_usage_reservations_none\": \"No reserved topics for this account\",\n  \"account_usage_attachment_storage_title\": \"Attachment storage\",\n  \"account_usage_attachment_storage_description\": \"{{filesize}} per file, deleted after {{expiry}}\",\n  \"account_usage_basis_ip_description\": \"Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.\",\n  \"account_usage_cannot_create_portal_session\": \"Unable to open billing portal\",\n  \"account_delete_title\": \"Delete account\",\n  \"account_delete_description\": \"Permanently delete your account\",\n  \"account_delete_dialog_description\": \"This will permanently delete your account, including all data that is stored on the server. After deletion, your username will be unavailable for 7 days. If you really want to proceed, please confirm with your password in the box below.\",\n  \"account_delete_dialog_label\": \"Password\",\n  \"account_delete_dialog_button_cancel\": \"Cancel\",\n  \"account_delete_dialog_button_submit\": \"Permanently delete account\",\n  \"account_delete_dialog_billing_warning\": \"Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.\",\n  \"account_upgrade_dialog_title\": \"Change account tier\",\n  \"account_upgrade_dialog_interval_monthly\": \"Monthly\",\n  \"account_upgrade_dialog_interval_yearly\": \"Annually\",\n  \"account_upgrade_dialog_interval_yearly_discount_save\": \"save {{discount}}%\",\n  \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"save up to {{discount}}%\",\n  \"account_upgrade_dialog_cancel_warning\": \"This will <strong>cancel your subscription</strong>, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server <strong>will be deleted</strong>.\",\n  \"account_upgrade_dialog_proration_info\": \"<strong>Proration</strong>: When upgrading between paid plans, the price difference will be <strong>charged immediately</strong>. When downgrading to a lower tier, the balance will be used to pay for future billing periods.\",\n  \"account_upgrade_dialog_reservations_warning_one\": \"The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least one reservation</strong>. You can remove reservations in the <Link>Settings</Link>.\",\n  \"account_upgrade_dialog_reservations_warning_other\": \"The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least {{count}} reservations</strong>. You can remove reservations in the <Link>Settings</Link>.\",\n  \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reserved topic\",\n  \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reserved topics\",\n  \"account_upgrade_dialog_tier_features_no_reservations\": \"No reserved topics\",\n  \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} daily message\",\n  \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} daily messages\",\n  \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} daily email\",\n  \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} daily emails\",\n  \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} daily phone calls\",\n  \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} daily phone calls\",\n  \"account_upgrade_dialog_tier_features_no_calls\": \"No phone calls\",\n  \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per file\",\n  \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} total storage\",\n  \"account_upgrade_dialog_tier_price_per_month\": \"month\",\n  \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} per year. Billed monthly.\",\n  \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} billed annually. Save {{save}}.\",\n  \"account_upgrade_dialog_tier_selected_label\": \"Selected\",\n  \"account_upgrade_dialog_tier_current_label\": \"Current\",\n  \"account_upgrade_dialog_billing_contact_email\": \"For billing questions, please <Link>contact us</Link> directly.\",\n  \"account_upgrade_dialog_billing_contact_website\": \"For billing questions, please refer to our <Link>website</Link>.\",\n  \"account_upgrade_dialog_button_cancel\": \"Cancel\",\n  \"account_upgrade_dialog_button_redirect_signup\": \"Sign up now\",\n  \"account_upgrade_dialog_button_pay_now\": \"Pay now and subscribe\",\n  \"account_upgrade_dialog_button_cancel_subscription\": \"Cancel subscription\",\n  \"account_upgrade_dialog_button_update_subscription\": \"Update subscription\",\n  \"account_tokens_title\": \"Access tokens\",\n  \"account_tokens_description\": \"Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the <Link>documentation</Link> to learn more.\",\n  \"account_tokens_table_token_header\": \"Token\",\n  \"account_tokens_table_label_header\": \"Label\",\n  \"account_tokens_table_last_access_header\": \"Last access\",\n  \"account_tokens_table_expires_header\": \"Expires\",\n  \"account_tokens_table_never_expires\": \"Never expires\",\n  \"account_tokens_table_current_session\": \"Current browser session\",\n  \"account_tokens_table_copied_to_clipboard\": \"Access token copied\",\n  \"account_tokens_table_cannot_delete_or_edit\": \"Cannot edit or delete current session token\",\n  \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Cannot edit or delete provisioned token\",\n  \"account_tokens_table_create_token_button\": \"Create access token\",\n  \"account_tokens_table_last_origin_tooltip\": \"From IP address {{ip}}, click to lookup\",\n  \"account_tokens_dialog_title_create\": \"Create access token\",\n  \"account_tokens_dialog_title_edit\": \"Edit access token\",\n  \"account_tokens_dialog_title_delete\": \"Delete access token\",\n  \"account_tokens_dialog_label\": \"Label, e.g. Radarr notifications\",\n  \"account_tokens_dialog_button_create\": \"Create token\",\n  \"account_tokens_dialog_button_update\": \"Update token\",\n  \"account_tokens_dialog_button_cancel\": \"Cancel\",\n  \"account_tokens_dialog_expires_label\": \"Access token expires in\",\n  \"account_tokens_dialog_expires_unchanged\": \"Leave expiry date unchanged\",\n  \"account_tokens_dialog_expires_x_hours\": \"Token expires in {{hours}} hours\",\n  \"account_tokens_dialog_expires_x_days\": \"Token expires in {{days}} days\",\n  \"account_tokens_dialog_expires_never\": \"Token never expires\",\n  \"account_tokens_delete_dialog_title\": \"Delete access token\",\n  \"account_tokens_delete_dialog_description\": \"Before deleting an access token, be sure that no applications or scripts are actively using it. <strong>This action cannot be undone</strong>.\",\n  \"account_tokens_delete_dialog_submit_button\": \"Permanently delete token\",\n  \"prefs_notifications_title\": \"Notifications\",\n  \"prefs_notifications_sound_title\": \"Notification sound\",\n  \"prefs_notifications_sound_description_none\": \"Notifications do not play any sound when they arrive\",\n  \"prefs_notifications_sound_description_some\": \"Notifications play the {{sound}} sound when they arrive\",\n  \"prefs_notifications_sound_no_sound\": \"No sound\",\n  \"prefs_notifications_sound_play\": \"Play selected sound\",\n  \"prefs_notifications_min_priority_title\": \"Minimum priority\",\n  \"prefs_notifications_min_priority_description_any\": \"Showing all notifications, regardless of priority\",\n  \"prefs_notifications_min_priority_description_x_or_higher\": \"Show notifications if priority is {{number}} ({{name}}) or above\",\n  \"prefs_notifications_min_priority_description_max\": \"Show notifications if priority is 5 (max)\",\n  \"prefs_notifications_min_priority_any\": \"Any priority\",\n  \"prefs_notifications_min_priority_low_and_higher\": \"Low priority and higher\",\n  \"prefs_notifications_min_priority_default_and_higher\": \"Default priority and higher\",\n  \"prefs_notifications_min_priority_high_and_higher\": \"High priority and higher\",\n  \"prefs_notifications_min_priority_max_only\": \"Only max priority\",\n  \"prefs_notifications_delete_after_title\": \"Delete notifications\",\n  \"prefs_notifications_delete_after_never\": \"Never\",\n  \"prefs_notifications_delete_after_three_hours\": \"After three hours\",\n  \"prefs_notifications_delete_after_one_day\": \"After one day\",\n  \"prefs_notifications_delete_after_one_week\": \"After one week\",\n  \"prefs_notifications_delete_after_one_month\": \"After one month\",\n  \"prefs_notifications_delete_after_never_description\": \"Notifications are never auto-deleted\",\n  \"prefs_notifications_delete_after_three_hours_description\": \"Notifications are auto-deleted after three hours\",\n  \"prefs_notifications_delete_after_one_day_description\": \"Notifications are auto-deleted after one day\",\n  \"prefs_notifications_delete_after_one_week_description\": \"Notifications are auto-deleted after one week\",\n  \"prefs_notifications_delete_after_one_month_description\": \"Notifications are auto-deleted after one month\",\n  \"prefs_notifications_web_push_title\": \"Background notifications\",\n  \"prefs_notifications_web_push_enabled_description\": \"Notifications are received even when the web app is not running (via Web Push)\",\n  \"prefs_notifications_web_push_disabled_description\": \"Notification are received when the web app is running (via WebSocket)\",\n  \"prefs_notifications_web_push_enabled\": \"Enabled for {{server}}\",\n  \"prefs_notifications_web_push_disabled\": \"Disabled\",\n  \"prefs_users_title\": \"Manage users\",\n  \"prefs_users_description\": \"Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.\",\n  \"prefs_users_description_no_sync\": \"Users and passwords are not synchronized to your account.\",\n  \"prefs_users_table\": \"Users table\",\n  \"prefs_users_add_button\": \"Add user\",\n  \"prefs_users_edit_button\": \"Edit user\",\n  \"prefs_users_delete_button\": \"Delete user\",\n  \"prefs_users_table_cannot_delete_or_edit\": \"Cannot delete or edit logged in user\",\n  \"prefs_users_table_user_header\": \"User\",\n  \"prefs_users_table_base_url_header\": \"Service URL\",\n  \"prefs_users_dialog_title_add\": \"Add user\",\n  \"prefs_users_dialog_title_edit\": \"Edit user\",\n  \"prefs_users_dialog_base_url_label\": \"Service URL, e.g. https://ntfy.sh\",\n  \"prefs_users_dialog_base_url_invalid\": \"Invalid URL format. Must start with http:// or https://\",\n  \"prefs_users_dialog_base_url_exists\": \"A user for this service URL already exists\",\n  \"prefs_users_dialog_username_label\": \"Username, e.g. phil\",\n  \"prefs_users_dialog_password_label\": \"Password\",\n  \"prefs_appearance_title\": \"Appearance\",\n  \"prefs_appearance_language_title\": \"Language\",\n  \"prefs_appearance_theme_title\": \"Theme\",\n  \"prefs_appearance_theme_system\": \"System (default)\",\n  \"prefs_appearance_theme_dark\": \"Dark mode\",\n  \"prefs_appearance_theme_light\": \"Light mode\",\n  \"prefs_reservations_title\": \"Reserved topics\",\n  \"prefs_reservations_description\": \"You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.\",\n  \"prefs_reservations_limit_reached\": \"You reached your reserved topics limit.\",\n  \"prefs_reservations_add_button\": \"Add reserved topic\",\n  \"prefs_reservations_edit_button\": \"Edit topic access\",\n  \"prefs_reservations_delete_button\": \"Reset topic access\",\n  \"prefs_reservations_table\": \"Reserved topics table\",\n  \"prefs_reservations_table_topic_header\": \"Topic\",\n  \"prefs_reservations_table_access_header\": \"Access\",\n  \"prefs_reservations_table_everyone_deny_all\": \"Only I can publish and subscribe\",\n  \"prefs_reservations_table_everyone_read_only\": \"I can publish and subscribe, everyone can subscribe\",\n  \"prefs_reservations_table_everyone_write_only\": \"I can publish and subscribe, everyone can publish\",\n  \"prefs_reservations_table_everyone_read_write\": \"Everyone can publish and subscribe\",\n  \"prefs_reservations_table_not_subscribed\": \"Not subscribed\",\n  \"prefs_reservations_table_click_to_subscribe\": \"Click to subscribe\",\n  \"prefs_reservations_dialog_title_add\": \"Reserve topic\",\n  \"prefs_reservations_dialog_title_edit\": \"Edit reserved topic\",\n  \"prefs_reservations_dialog_title_delete\": \"Delete topic reservation\",\n  \"prefs_reservations_dialog_description\": \"Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.\",\n  \"prefs_reservations_dialog_topic_label\": \"Topic\",\n  \"prefs_reservations_dialog_access_label\": \"Access\",\n  \"reservation_delete_dialog_description\": \"Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.\",\n  \"reservation_delete_dialog_action_keep_title\": \"Keep cached messages and attachments\",\n  \"reservation_delete_dialog_action_keep_description\": \"Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.\",\n  \"reservation_delete_dialog_action_delete_title\": \"Delete cached messages and attachments\",\n  \"reservation_delete_dialog_action_delete_description\": \"Cached messages and attachments will be permanently deleted. This action cannot be undone.\",\n  \"reservation_delete_dialog_submit_button\": \"Delete reservation\",\n  \"priority_min\": \"min\",\n  \"priority_low\": \"low\",\n  \"priority_default\": \"default\",\n  \"priority_high\": \"high\",\n  \"priority_max\": \"max\",\n  \"error_boundary_title\": \"Oh no, ntfy crashed\",\n  \"error_boundary_description\": \"This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.\",\n  \"error_boundary_button_copy_stack_trace\": \"Copy stack trace\",\n  \"error_boundary_button_reload_ntfy\": \"Reload ntfy\",\n  \"error_boundary_stack_trace\": \"Stack trace\",\n  \"error_boundary_gathering_info\": \"Gather more info …\",\n  \"error_boundary_unsupported_indexeddb_title\": \"Private browsing not supported\",\n  \"error_boundary_unsupported_indexeddb_description\": \"The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.\",\n  \"web_push_subscription_expiring_title\": \"Notifications will be paused\",\n  \"web_push_subscription_expiring_body\": \"Open ntfy to continue receiving notifications\",\n  \"web_push_unknown_notification_title\": \"Unknown notification received from server\",\n  \"web_push_unknown_notification_body\": \"You may need to update ntfy by opening the web app\"\n}\n"
  },
  {
    "path": "web/public/static/langs/eo.json",
    "content": "{\n    \"common_cancel\": \"Nuligi\",\n    \"common_save\": \"Konservi\",\n    \"common_add\": \"Aldoni\"\n}\n"
  },
  {
    "path": "web/public/static/langs/es.json",
    "content": "{\n    \"action_bar_settings\": \"Configuración\",\n    \"action_bar_send_test_notification\": \"Enviar notificación de prueba\",\n    \"action_bar_clear_notifications\": \"Borrar todas las notificaciones\",\n    \"nav_topics_title\": \"Tópicos suscritos\",\n    \"alert_notification_permission_required_button\": \"Conceder ahora\",\n    \"action_bar_unsubscribe\": \"Cancelar la suscripción\",\n    \"message_bar_type_message\": \"Escriba un mensaje aquí\",\n    \"message_bar_error_publishing\": \"Error al publicar la notificación\",\n    \"alert_notification_permission_required_title\": \"Las notificaciones están deshabilitadas\",\n    \"alert_notification_permission_required_description\": \"Concede a tu navegador permiso para mostrar notificaciones de escritorio\",\n    \"nav_button_all_notifications\": \"Todas las notificaciones\",\n    \"nav_button_settings\": \"Ajustes\",\n    \"nav_button_subscribe\": \"Suscribirse al tópico\",\n    \"nav_button_documentation\": \"Documentación\",\n    \"nav_button_publish_message\": \"Publicar notificación\",\n    \"notifications_copied_to_clipboard\": \"Copiado al portapapeles\",\n    \"alert_not_supported_title\": \"Notificaciones no soportadas\",\n    \"alert_not_supported_description\": \"Su navegador no admite notificaciones\",\n    \"notifications_tags\": \"Etiquetas\",\n    \"notifications_attachment_copy_url_title\": \"Copiar la URL del archivo adjunto en el portapapeles\",\n    \"notifications_attachment_copy_url_button\": \"Copiar URL\",\n    \"notifications_attachment_open_title\": \"Ir a {{url}}\",\n    \"notifications_attachment_open_button\": \"Abrir archivo adjunto\",\n    \"notifications_attachment_link_expires\": \"el enlace expira el día {{date}}\",\n    \"notifications_attachment_link_expired\": \"el enlace de descarga ha expirado\",\n    \"notifications_click_copy_url_title\": \"Copiar la URL del enlace en el portapapeles\",\n    \"notifications_click_copy_url_button\": \"Copiar enlace\",\n    \"notifications_actions_open_url_title\": \"Ir a {{url}}\",\n    \"notifications_click_open_button\": \"Abrir enlace\",\n    \"notifications_none_for_topic_title\": \"Aún no has recibido ninguna notificación en este tópico.\",\n    \"notifications_none_for_topic_description\": \"Para enviar notificaciones a este tópico, simplemente realice un PUT o POST a la URL del tópico.\",\n    \"notifications_none_for_any_title\": \"No ha recibido ninguna notificación.\",\n    \"notifications_no_subscriptions_title\": \"Parece que aún no tiene ninguna suscripción.\",\n    \"notifications_no_subscriptions_description\": \"Haga clic en el enlace \\\"{{linktext}}\\\" para crear o suscribirse a un tópico. Después, puede enviar mensajes a través de un PUT o POST y recibirá notificaciones aquí.\",\n    \"notifications_more_details\": \"Para más información, consulta la <websiteLink>página web</websiteLink> o la <docsLink>documentación</docsLink>.\",\n    \"notifications_loading\": \"Cargando notificaciones …\",\n    \"publish_dialog_title_topic\": \"Publicar en {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publicar notificación\",\n    \"publish_dialog_progress_uploading\": \"Cargando …\",\n    \"publish_dialog_progress_uploading_detail\": \"Cargando {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Notificación publicada\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"supera el límite y la cuota de archivos de {{fileSizeLimit}}, restan {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_reached\": \"supera el límite de archivos de {{fileSizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"supera la cuota, restan {{remainingBytes}}\",\n    \"publish_dialog_priority_min\": \"Prioridad mínima\",\n    \"publish_dialog_priority_default\": \"Prioridad predeterminada\",\n    \"publish_dialog_priority_max\": \"Prioridad máxima\",\n    \"publish_dialog_base_url_label\": \"URL del servicio\",\n    \"publish_dialog_base_url_placeholder\": \"URL del servicio, por ejemplo, https://example.com\",\n    \"publish_dialog_topic_label\": \"Nombre del tópico\",\n    \"publish_dialog_topic_placeholder\": \"Nombre del tópico, ej. phil_alerts\",\n    \"publish_dialog_title_label\": \"Título\",\n    \"publish_dialog_message_label\": \"Mensaje\",\n    \"publish_dialog_tags_placeholder\": \"Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup\",\n    \"publish_dialog_click_label\": \"Click URL\",\n    \"publish_dialog_click_placeholder\": \"URL que se abre cuando se hace click en la notificación\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Dirección a la que se reenviará la notificación, por ejemplo, phil@example.com\",\n    \"publish_dialog_attach_label\": \"URL del archivo adjunto\",\n    \"publish_dialog_filename_label\": \"Nombre del archivo\",\n    \"publish_dialog_delay_placeholder\": \"Retraso en la entrega, por ejemplo, {{unixTimestamp}}, {{relativeTime}}, o \\\"{{naturalLanguage}}\\\" (sólo en inglés)\",\n    \"publish_dialog_other_features\": \"Otras características:\",\n    \"publish_dialog_chip_click_label\": \"Click URL\",\n    \"publish_dialog_chip_email_label\": \"Reenviar al email\",\n    \"publish_dialog_chip_attach_url_label\": \"Adjuntar un archivo por URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Adjuntar archivo local\",\n    \"publish_dialog_chip_topic_label\": \"Cambiar de tópico\",\n    \"publish_dialog_button_cancel_sending\": \"Cancelar el envío\",\n    \"publish_dialog_button_cancel\": \"Cancelar\",\n    \"publish_dialog_checkbox_publish_another\": \"Publicar otro\",\n    \"publish_dialog_attached_file_title\": \"Archivo adjunto:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nombre del archivo adjunto\",\n    \"publish_dialog_drop_file_here\": \"Suelta el archivo aquí\",\n    \"emoji_picker_search_placeholder\": \"Buscar emojis\",\n    \"subscribe_dialog_subscribe_title\": \"Suscribirse al tópico\",\n    \"subscribe_dialog_subscribe_description\": \"Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/POST de notificaciones.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nombre del tópico, ej. phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Usar otro servidor\",\n    \"subscribe_dialog_login_title\": \"Es necesario iniciar sesión\",\n    \"subscribe_dialog_login_description\": \"Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.\",\n    \"subscribe_dialog_login_username_label\": \"Nombre de usuario, ej. phil\",\n    \"subscribe_dialog_login_password_label\": \"Contraseña\",\n    \"common_back\": \"Volver\",\n    \"subscribe_dialog_login_button_login\": \"Iniciar sesión\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Usuario {{username}} no autorizado\",\n    \"subscribe_dialog_error_user_anonymous\": \"anónimo\",\n    \"prefs_notifications_title\": \"Notificaciones\",\n    \"prefs_notifications_sound_title\": \"Sonido de notificación\",\n    \"prefs_notifications_min_priority_any\": \"Cualquier prioridad\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Prioridad baja y superior\",\n    \"prefs_notifications_min_priority_max_only\": \"Solo prioridad máxima\",\n    \"prefs_notifications_delete_after_title\": \"Eliminar notificaciones\",\n    \"prefs_notifications_delete_after_never\": \"Nunca\",\n    \"prefs_notifications_delete_after_three_hours\": \"Después de tres horas\",\n    \"prefs_notifications_delete_after_one_day\": \"Después de un día\",\n    \"prefs_notifications_delete_after_one_week\": \"Después de una semana\",\n    \"prefs_notifications_delete_after_one_month\": \"Después de un mes\",\n    \"prefs_users_title\": \"Administrar usuarios\",\n    \"prefs_users_description\": \"Añada/elimine usuarios para sus tópicos protegidos aquí. Tenga en cuenta que el nombre de usuario y la contraseña se guardan en el almacenamiento local del navegador.\",\n    \"prefs_users_add_button\": \"Añadir usuario\",\n    \"prefs_users_dialog_title_edit\": \"Editar usuario\",\n    \"prefs_users_dialog_base_url_label\": \"URL del servicio, ej. https://ntfy.sh\",\n    \"common_add\": \"Añadir\",\n    \"common_save\": \"Guardar\",\n    \"prefs_appearance_title\": \"Apariencia\",\n    \"prefs_appearance_language_title\": \"Idioma\",\n    \"error_boundary_title\": \"Oh no, ntfy tuvo un error\",\n    \"error_boundary_button_copy_stack_trace\": \"Copiar el stack trace\",\n    \"error_boundary_stack_trace\": \"Rastreo de pila\",\n    \"error_boundary_gathering_info\": \"Reunir más información …\",\n    \"notifications_example\": \"Ejemplo\",\n    \"prefs_notifications_min_priority_title\": \"Prioridad mínima\",\n    \"notifications_none_for_any_description\": \"Para enviar notificaciones a un tópico, simplemente realice un PUT o POST a la URL del tópico. Aquí hay un ejemplo usando uno de sus tópicos.\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Cancelar\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Suscribir\",\n    \"publish_dialog_message_placeholder\": \"Escriba un mensaje aquí\",\n    \"publish_dialog_tags_label\": \"Etiquetas\",\n    \"publish_dialog_priority_label\": \"Prioridad\",\n    \"publish_dialog_priority_low\": \"Prioridad baja\",\n    \"publish_dialog_priority_high\": \"Prioridad alta\",\n    \"publish_dialog_delay_label\": \"Retraso\",\n    \"publish_dialog_title_placeholder\": \"Título de la notificación, ej. Alerta de espacio en disco\",\n    \"publish_dialog_details_examples_description\": \"Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.\",\n    \"publish_dialog_attach_placeholder\": \"Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_placeholder\": \"Nombre del archivo adjunto\",\n    \"publish_dialog_chip_delay_label\": \"Retraso en la entrega\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioridad predeterminada y superior\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Prioridad alta y superior\",\n    \"prefs_users_table_user_header\": \"Usuario\",\n    \"prefs_users_table_base_url_header\": \"URL del servicio\",\n    \"publish_dialog_button_send\": \"Enviar\",\n    \"prefs_notifications_sound_no_sound\": \"Sin sonido\",\n    \"prefs_users_dialog_password_label\": \"Contraseña\",\n    \"error_boundary_description\": \"Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.\",\n    \"prefs_users_dialog_title_add\": \"Añadir usuario\",\n    \"common_cancel\": \"Cancelar\",\n    \"prefs_users_dialog_username_label\": \"Nombre de usuario, ej. phil\",\n    \"priority_max\": \"máx\",\n    \"priority_high\": \"alta\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Las notificaciones se eliminan automáticamente después de un mes\",\n    \"priority_min\": \"mín\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Las notificaciones se eliminan automáticamente después de tres horas\",\n    \"prefs_notifications_sound_description_none\": \"Las notificaciones no reproducen ningún sonido cuando llegan\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Mostrar notificaciones si la prioridad es {{number}} ({{name}}) o superior\",\n    \"prefs_notifications_min_priority_description_max\": \"Mostrar notificaciones si la prioridad es 5 (máxima)\",\n    \"prefs_notifications_sound_description_some\": \"Las notificaciones reproducen el sonido {{sound}} cuando llegan\",\n    \"prefs_notifications_min_priority_description_any\": \"Mostrando todas las notificaciones, independientemente de su prioridad\",\n    \"prefs_notifications_delete_after_never_description\": \"Las notificaciones nunca se borran automáticamente\",\n    \"priority_default\": \"predeterminada\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Las notificaciones se eliminan automáticamente después de un día\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Las notificaciones se eliminan automáticamente después de una semana\",\n    \"priority_low\": \"baja\",\n    \"notifications_actions_not_supported\": \"Acción no soportada en la aplicación web\",\n    \"notifications_actions_http_request_title\": \"Enviar HTTP {{method}} a {{url}}\",\n    \"error_boundary_unsupported_indexeddb_description\": \"La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_show_menu\": \"Mostrar menú\",\n    \"action_bar_logo_alt\": \"logo de ntfy\",\n    \"action_bar_toggle_action_menu\": \"Abrir/cerrar el menú de acción\",\n    \"message_bar_show_dialog\": \"Mostrar diálogo de publicación\",\n    \"message_bar_publish\": \"Publicar mensaje\",\n    \"nav_button_muted\": \"Notificaciones silenciadas\",\n    \"nav_button_connecting\": \"conectando\",\n    \"notifications_list\": \"Lista de notificaciones\",\n    \"notifications_list_item\": \"Notificación\",\n    \"notifications_mark_read\": \"Marcar como leído\",\n    \"notifications_delete\": \"Eliminar\",\n    \"notifications_priority_x\": \"Prioridad {{priority}}\",\n    \"notifications_new_indicator\": \"Nueva notificación\",\n    \"notifications_attachment_image\": \"Imagen adjunta\",\n    \"notifications_attachment_file_image\": \"archivo de imagen\",\n    \"notifications_attachment_file_video\": \"archivo de video\",\n    \"notifications_attachment_file_audio\": \"archivo de audio\",\n    \"notifications_attachment_file_app\": \"Archivo de aplicación de Android\",\n    \"notifications_attachment_file_document\": \"otro documento\",\n    \"action_bar_toggle_mute\": \"Silenciar/reactivar notificaciones\",\n    \"publish_dialog_emoji_picker_show\": \"Elige un emoji\",\n    \"publish_dialog_topic_reset\": \"Restablecer tópico\",\n    \"publish_dialog_click_reset\": \"Eliminar URL de clic\",\n    \"publish_dialog_email_reset\": \"Eliminar el reenvío de correo electrónico\",\n    \"publish_dialog_attach_reset\": \"Eliminar la URL del archivo adjunto\",\n    \"publish_dialog_delay_reset\": \"Eliminar entrega retrasada\",\n    \"publish_dialog_attached_file_remove\": \"Eliminar el archivo adjunto\",\n    \"emoji_picker_search_clear\": \"Limpiar búsqueda\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL del servicio\",\n    \"prefs_notifications_sound_play\": \"Reproducir el sonido seleccionado\",\n    \"prefs_users_table\": \"Tabla de usuarios\",\n    \"prefs_users_edit_button\": \"Editar usuario\",\n    \"prefs_users_delete_button\": \"Eliminar usuario\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Navegación privada no soportada\",\n    \"action_bar_profile_title\": \"Perfil\",\n    \"action_bar_profile_settings\": \"Configuración\",\n    \"signup_title\": \"Crear una cuenta ntfy\",\n    \"signup_form_username\": \"Nombre de usuario\",\n    \"signup_form_password\": \"Contraseña\",\n    \"signup_form_confirm_password\": \"Confirmar contraseña\",\n    \"signup_form_button_submit\": \"Registro\",\n    \"signup_form_toggle_password_visibility\": \"Alternar la visibilidad de la contraseña\",\n    \"signup_already_have_account\": \"¿Ya tienes una cuenta? ¡Iniciar sesión!\",\n    \"signup_disabled\": \"El registro está deshabilitado\",\n    \"signup_error_username_taken\": \"El nombre de usuario {{username}} ya está en uso\",\n    \"signup_error_creation_limit_reached\": \"Límite de creación de cuenta alcanzado\",\n    \"login_title\": \"Inicie sesión en su cuenta ntfy\",\n    \"login_form_button_submit\": \"Iniciar sesión\",\n    \"login_link_signup\": \"Registro\",\n    \"login_disabled\": \"Inicio de sesión deshabilitado\",\n    \"action_bar_account\": \"Cuenta\",\n    \"action_bar_change_display_name\": \"Cambiar nombre de usuario\",\n    \"action_bar_reservation_add\": \"Reservar tema\",\n    \"action_bar_reservation_edit\": \"Modificar reserva\",\n    \"action_bar_reservation_delete\": \"Quitar reserva\",\n    \"action_bar_reservation_limit_reached\": \"Límite alcanzado\",\n    \"action_bar_profile_logout\": \"Cerrar sesión\",\n    \"action_bar_sign_in\": \"Iniciar sesión\",\n    \"action_bar_sign_up\": \"Registro\",\n    \"nav_button_account\": \"Cuenta\",\n    \"nav_upgrade_banner_label\": \"Actualizar a ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes\",\n    \"display_name_dialog_title\": \"Cambiar el nombre para mostrar\",\n    \"display_name_dialog_description\": \"Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.\",\n    \"display_name_dialog_placeholder\": \"Nombre para mostrar\",\n    \"account_basics_username_admin_tooltip\": \"Eres Administrador\",\n    \"account_basics_password_description\": \"Cambiar la contraseña de tu cuenta\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmar contraseña\",\n    \"account_basics_password_dialog_button_submit\": \"Cambiar contraseña\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Contraseña incorrecta\",\n    \"account_usage_unlimited\": \"Ilimitado\",\n    \"account_usage_title\": \"Uso\",\n    \"account_usage_of_limit\": \"de {{limit}}\",\n    \"account_usage_limits_reset_daily\": \"Los límites de uso se restablecen diariamente a la medianoche (UTC)\",\n    \"account_basics_tier_description\": \"Nivel de poder de tu cuenta\",\n    \"account_basics_tier_admin\": \"Administrador\",\n    \"alert_not_supported_context_description\": \"Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la <mdnLink>API de notificaciones</mdnLink> .\",\n    \"reserve_dialog_checkbox_label\": \"Reservar tópico y configurar el acceso\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generar nombre\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Tópico ya reservado\",\n    \"account_basics_title\": \"Cuenta\",\n    \"account_basics_username_title\": \"Nombre de usuario\",\n    \"account_basics_username_description\": \"Hey, ese eres tú ❤\",\n    \"account_basics_password_title\": \"Contraseña\",\n    \"account_basics_password_dialog_title\": \"Cambiar contraseña\",\n    \"account_basics_password_dialog_current_password_label\": \"Contraseña actual\",\n    \"account_basics_password_dialog_new_password_label\": \"Contraseña nueva\",\n    \"account_basics_tier_basic\": \"Básico\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(con nivel {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(sin nivel)\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_basics_tier_upgrade_button\": \"Actualizar a Pro\",\n    \"account_basics_tier_change_button\": \"Cambiar\",\n    \"account_basics_tier_paid_until\": \"Suscripción pagada hasta {{date}}, y se renovará automáticamente\",\n    \"account_basics_tier_manage_billing_button\": \"Administrar la facturación\",\n    \"account_basics_tier_title\": \"Tipo de cuenta\",\n    \"account_tokens_description\": \"Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la <Link>documentación</Link> para obtener más información.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Etiqueta\",\n    \"account_tokens_table_last_access_header\": \"Último acceso\",\n    \"account_tokens_table_expires_header\": \"Expira\",\n    \"account_tokens_table_never_expires\": \"Nunca expira\",\n    \"account_tokens_table_current_session\": \"Sesión del navegador actual\",\n    \"common_copy_to_clipboard\": \"Copiar al portapapeles\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token de acceso copiado\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"No se puede editar ni eliminar el token de sesión actual\",\n    \"account_tokens_table_create_token_button\": \"Crear token de acceso\",\n    \"account_tokens_table_last_origin_tooltip\": \"Desde la dirección IP {{ip}}, haga clic para buscar\",\n    \"account_tokens_dialog_title_create\": \"Crear token de acceso\",\n    \"account_tokens_dialog_title_edit\": \"Editar token de acceso\",\n    \"account_tokens_dialog_title_delete\": \"Eliminar token de acceso\",\n    \"account_tokens_dialog_label\": \"Etiqueta, por ejemplo, notificaciones de Radarr\",\n    \"account_tokens_dialog_button_create\": \"Crear token\",\n    \"prefs_reservations_table_everyone_write_only\": \"Puedo publicar y suscribirme, todo el mundo puede publicar\",\n    \"account_usage_messages_title\": \"Mensajes publicados\",\n    \"account_usage_reservations_title\": \"Tópicos reservados\",\n    \"account_usage_reservations_none\": \"No hay tópicos reservados para esta cuenta\",\n    \"account_usage_cannot_create_portal_session\": \"No se puede abrir el portal de facturación\",\n    \"account_upgrade_dialog_title\": \"Cambiar nivel de cuenta\",\n    \"account_basics_tier_payment_overdue\": \"Su pago ha vencido. Por favor actualice su método de pago o su cuenta será degradada en breve.\",\n    \"account_basics_tier_canceled_subscription\": \"Su suscripción fue cancelada y será degradada a una cuenta gratuita el {{date}}.\",\n    \"account_usage_emails_title\": \"Correos enviados\",\n    \"account_usage_attachment_storage_title\": \"Almacenamiento de archivos adjuntos\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} por archivo, eliminado después de {{expiry}}\",\n    \"account_usage_basis_ip_description\": \"Las estadísticas de uso y los límites de esta cuenta se basan en su dirección IP, por lo que podrían ser compartidos con otros usuarios. Los límites mostrados anteriormente son aproximados basados en los límites existentes.\",\n    \"account_delete_title\": \"Elimina cuenta\",\n    \"account_delete_dialog_button_cancel\": \"Cancelar\",\n    \"account_delete_dialog_billing_warning\": \"La eliminación de su cuenta también cancela su suscripción de facturación inmediatamente. Ya no tendrá acceso al panel de facturación.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, <strong>por favor elimine al menos una reserva</strong>. Puede eliminar reservas en <Link>Configuración</Link>.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Seleccionado\",\n    \"account_upgrade_dialog_button_cancel\": \"Cancelar\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Cancelar suscripción\",\n    \"account_tokens_title\": \"Tokens de acceso\",\n    \"account_delete_description\": \"Eliminar permanentemente su cuenta\",\n    \"account_delete_dialog_description\": \"Esto borrará permanentemente su cuenta, incluyendo todos los datos almacenados en el servidor. Tras la eliminación, su nombre de usuario no estará disponible durante 7 días. Si realmente desea continuar, por favor confirme su contraseña en la casilla de abajo.\",\n    \"account_delete_dialog_label\": \"Contraseña\",\n    \"account_delete_dialog_button_submit\": \"Eliminar permanentemente la cuenta\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} tópicos reservados\",\n    \"account_upgrade_dialog_cancel_warning\": \"Esto <strong>cancelará su suscripción</strong> y degradará su cuenta en {{date}}. En esa fecha, sus tópicos reservados y sus mensajes almacenados en caché en el servidor <strong>serán eliminados</strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Prorrateo</strong>: al actualizar entre planes pagos, la diferencia de precio se <strong>cobrará de inmediato</strong>. Al cambiar a un nivel inferior, el saldo se utilizará para pagar futuros períodos de facturación.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, <strong>por favor elimine al menos {{count}} reservaciones</strong>. Puede eliminar reservaciones en <Link>Configuración</Link>.\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mensajes diarios\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} correos diarios\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} por archivo\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} almacenamiento total\",\n    \"account_upgrade_dialog_tier_current_label\": \"Actual\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Regístrese ahora\",\n    \"account_upgrade_dialog_button_pay_now\": \"Pague ahora y suscríbase\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Actualizar suscripción\",\n    \"account_tokens_dialog_button_update\": \"Actualizar token\",\n    \"account_tokens_dialog_expires_label\": \"El token de acceso expira en\",\n    \"prefs_reservations_table\": \"Tabla de tópicos reservados\",\n    \"prefs_reservations_dialog_description\": \"Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.\",\n    \"account_tokens_dialog_button_cancel\": \"Cancelar\",\n    \"account_tokens_dialog_expires_unchanged\": \"No modificar la fecha de expiración\",\n    \"prefs_reservations_add_button\": \"Agregar tópico reservado\",\n    \"prefs_reservations_table_access_header\": \"Acceso\",\n    \"reservation_delete_dialog_action_delete_description\": \"Los mensajes y archivos adjuntos almacenados en caché se eliminarán de forma permanente. Esta acción no se puede deshacer.\",\n    \"account_tokens_dialog_expires_x_hours\": \"El token expira en {{hours}} horas\",\n    \"account_tokens_delete_dialog_title\": \"Eliminar token de acceso\",\n    \"prefs_reservations_limit_reached\": \"Ha alcanzado su límite de tópicos reservados.\",\n    \"prefs_reservations_table_everyone_read_write\": \"Todo el mundo puede publicar y suscribirse\",\n    \"reservation_delete_dialog_action_keep_description\": \"Los mensajes y archivos adjuntos que se almacenen en caché en el servidor pasarán a ser visibles públicamente para las personas que conozcan el nombre del tópico.\",\n    \"account_tokens_dialog_expires_x_days\": \"El token expira en {{days}} días\",\n    \"account_tokens_dialog_expires_never\": \"El token nunca expira\",\n    \"account_tokens_delete_dialog_description\": \"Antes de eliminar un token de acceso, asegúrese de que ninguna aplicación o script lo está utilizando activamente. <strong>Esta acción no se puede deshacer</strong>.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"No se puede eliminar o editar el usuario conectado\",\n    \"prefs_reservations_title\": \"Tópicos reservados\",\n    \"prefs_reservations_edit_button\": \"Editar acceso al tópico\",\n    \"prefs_reservations_table_topic_header\": \"Tópico\",\n    \"prefs_reservations_table_everyone_read_only\": \"Puedo publicar y suscribirme, todo el mundo puede suscribirse\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Sólo yo puedo publicar y suscribirme\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Haga clic para suscribirse\",\n    \"prefs_reservations_dialog_title_edit\": \"Edita tópico reservado\",\n    \"account_tokens_delete_dialog_submit_button\": \"Eliminar permanentemente el token\",\n    \"prefs_reservations_description\": \"Aquí puede reservar nombres de tópicos para uso personal. Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.\",\n    \"prefs_reservations_delete_button\": \"Restablecer acceso a tópico\",\n    \"prefs_reservations_table_not_subscribed\": \"No suscrito\",\n    \"prefs_reservations_dialog_title_add\": \"Reservar tópico\",\n    \"prefs_users_description_no_sync\": \"Los usuarios y las contraseñas no están sincronizados con su cuenta.\",\n    \"prefs_reservations_dialog_title_delete\": \"Borrar reserva de tópico\",\n    \"prefs_reservations_dialog_access_label\": \"Acceso\",\n    \"reservation_delete_dialog_action_keep_title\": \"Conservar mensajes y archivos adjuntos en caché\",\n    \"prefs_reservations_dialog_topic_label\": \"Tópico\",\n    \"reservation_delete_dialog_description\": \"Al eliminar una reserva se renuncia a la propiedad sobre el tópico y se permite que otros lo reserven. Puede conservar o eliminar los mensajes y archivos adjuntos existentes.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Eliminar mensajes y archivos adjuntos en caché\",\n    \"reservation_delete_dialog_submit_button\": \"Eliminar reserva\",\n    \"account_basics_tier_interval_monthly\": \"mensualmente\",\n    \"account_basics_tier_interval_yearly\": \"anualmente\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensualmente\",\n    \"account_upgrade_dialog_interval_yearly\": \"Anualmente\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"ahorrar {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"ahorra hasta un {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Ningún tema reservado\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mes\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} facturado anualmente. Guardar {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Si tiene preguntas sobre facturación, consulte nuestra <Link>página web</Link>.\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} al año. Facturación mensual.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} mensaje diario\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} correo electrónico diario\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} tema reservado\",\n    \"publish_dialog_call_label\": \"Llamada telefónica\",\n    \"publish_dialog_call_placeholder\": \"Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \\\"sí\\\"\",\n    \"publish_dialog_chip_call_label\": \"Llamada telefónica\",\n    \"account_basics_phone_numbers_title\": \"Números de teléfono\",\n    \"account_basics_phone_numbers_description\": \"Para notificaciones por llamada teléfonica\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Aún no hay números de teléfono\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Número de teléfono\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"p. ej. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Envía SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Llámame\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Código de verificación\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Llamar\",\n    \"account_usage_calls_title\": \"Llamadas telefónicas realizadas\",\n    \"account_usage_calls_none\": \"No se pueden hacer llamadas telefónicas con esta cuenta\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} llamadas telefónicas diarias\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} llamadas telefónicas diarias\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"No hay llamadas telefónicas\",\n    \"publish_dialog_call_reset\": \"Eliminar llamada telefónica\",\n    \"account_basics_phone_numbers_dialog_description\": \"Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Número de teléfono copiado al portapapeles\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Confirmar código\",\n    \"account_basics_phone_numbers_dialog_title\": \"Agregar número de teléfono\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"p.ej. 123456\",\n    \"publish_dialog_call_item\": \"Llamar al número de teléfono {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"No hay números de teléfono verificados\",\n    \"action_bar_mute_notifications\": \"Silenciar Notificaciones\",\n    \"action_bar_unmute_notifications\": \"Reactivar notificaciones\",\n    \"alert_notification_permission_denied_title\": \"Notificaciones bloqueadas\",\n    \"alert_notification_permission_denied_description\": \"Porfavor, reactivelas en su navegador\",\n    \"alert_notification_ios_install_required_title\": \"Requiere instalacion de iOS\",\n    \"alert_notification_ios_install_required_description\": \"Haz click en el icono de compartir y Añadir a pantalla de inicio para activar las notificaciones de iOS\",\n    \"notifications_actions_failed_notification\": \"Acción fallida\",\n    \"publish_dialog_checkbox_markdown\": \"Formatear como Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Las notificaciones de otros servidores no se recibirán cuando la aplicación web no esté abierta\",\n    \"prefs_notifications_web_push_title\": \"Notificaciones en segundo plano\",\n    \"prefs_notifications_web_push_enabled_description\": \"Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)\",\n    \"prefs_notifications_web_push_disabled\": \"Desactivado\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_system\": \"Sistema (por defecto)\",\n    \"error_boundary_button_reload_ntfy\": \"Volver a cargar ntfy\",\n    \"web_push_subscription_expiring_title\": \"Las notificaciones se pausarán\",\n    \"prefs_notifications_web_push_disabled_description\": \"Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Activado para {{server}}\",\n    \"prefs_appearance_theme_light\": \"Claro\",\n    \"prefs_appearance_theme_dark\": \"Oscuro\",\n    \"web_push_subscription_expiring_body\": \"Abrir ntfy para seguir recibiendo notificaciones\",\n    \"web_push_unknown_notification_title\": \"Notificación desconocida recibida del servidor\",\n    \"web_push_unknown_notification_body\": \"Puede que necesites actualizar ntfy abriendo la aplicación web\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Un usuario provisionado no se puede editar o eliminar\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"No se puede editar o eliminar un token provisionado\"\n}\n"
  },
  {
    "path": "web/public/static/langs/et.json",
    "content": "{\n    \"signup_title\": \"Loo ntfy kasutajakonto\",\n    \"signup_form_username\": \"Kasutajanimi\",\n    \"signup_form_password\": \"Salasõna\",\n    \"signup_form_confirm_password\": \"Kinnita salasõna õigsust\",\n    \"signup_already_have_account\": \"Sul juba on kasutajakonto olemas? Siis logi sisse!\",\n    \"signup_disabled\": \"Kasutajakonto loomine pole hetkel lubatud\",\n    \"signup_error_username_taken\": \"Kasutajanimi {{username}} on juba olemas\",\n    \"signup_error_creation_limit_reached\": \"Kasutajakontode loomise ülempiir on käes\",\n    \"login_title\": \"Logi sisse oma ntfy kasutajakontole\",\n    \"login_form_button_submit\": \"Logi sisse\",\n    \"login_link_signup\": \"Liitu\",\n    \"login_disabled\": \"Sisselogimine pole hetkel kasutusel\",\n    \"action_bar_show_menu\": \"Näita menüüd\",\n    \"action_bar_logo_alt\": \"ntfy logo\",\n    \"action_bar_settings\": \"Seadistused\",\n    \"action_bar_change_display_name\": \"Muuda kuvatavat nime\",\n    \"common_cancel\": \"Katkesta\",\n    \"common_save\": \"Salvesta\",\n    \"common_back\": \"Tagasi\",\n    \"common_copy_to_clipboard\": \"Kopeeri lõikelauale\",\n    \"common_add\": \"Lisa\",\n    \"signup_form_button_submit\": \"Liitu\",\n    \"signup_form_toggle_password_visibility\": \"Vaheta salasõna nähtavust\",\n    \"action_bar_account\": \"Kasutajakonto\",\n    \"action_bar_sign_in\": \"Logi sisse\",\n    \"nav_button_documentation\": \"Juhendid ja teave\",\n    \"action_bar_profile_title\": \"Profiil\",\n    \"action_bar_profile_settings\": \"Seadistused\",\n    \"action_bar_sign_up\": \"Liitu\",\n    \"message_bar_type_message\": \"Sisesta oma sõnum siia\",\n    \"message_bar_error_publishing\": \"Viga teavituse avaldamisel\",\n    \"message_bar_show_dialog\": \"Näita avaldamisvaadet\",\n    \"message_bar_publish\": \"Avalda sõnum\",\n    \"nav_topics_title\": \"Tellitud teemad\",\n    \"nav_button_all_notifications\": \"Kõik teavitused\",\n    \"nav_button_account\": \"Kasutajakonto\",\n    \"nav_button_settings\": \"Seadistused\",\n    \"nav_button_publish_message\": \"Avalda teavitus\",\n    \"nav_button_subscribe\": \"Telli teema\",\n    \"nav_button_muted\": \"Teavitused on summutatud\",\n    \"nav_button_connecting\": \"loome ühendust\",\n    \"nav_upgrade_banner_label\": \"Uuenda ntfy Pro teenuseks\",\n    \"action_bar_profile_logout\": \"Logi välja\",\n    \"notifications_list_item\": \"Teavitus\",\n    \"account_tokens_table_expires_header\": \"Aegub\",\n    \"notifications_attachment_file_document\": \"muu dokument\",\n    \"notifications_list\": \"Teavituste loend\",\n    \"notifications_delete\": \"Kustuta\",\n    \"notifications_copied_to_clipboard\": \"Kopeeritud lõikelauale\",\n    \"alert_notification_permission_denied_description\": \"Palun luba nad veebibrauseris uuesti\",\n    \"account_tokens_table_last_access_header\": \"Viimase kasutamise aeg\",\n    \"account_tokens_table_token_header\": \"Tunnusluba\",\n    \"account_tokens_table_last_origin_tooltip\": \"IP-aadressilt {{ip}}, klõpsi täpsema teabe nägemiseks\",\n    \"action_bar_reservation_add\": \"Reserveeri teema\",\n    \"action_bar_reservation_edit\": \"Muuda reserveerimist\",\n    \"action_bar_reservation_delete\": \"Eemalda reserveerimine\",\n    \"action_bar_reservation_limit_reached\": \"Ülempiir on käes\",\n    \"action_bar_send_test_notification\": \"Saata testteavitus\",\n    \"action_bar_clear_notifications\": \"Kustuta kõik teavitused\",\n    \"action_bar_mute_notifications\": \"Summuta teavitused\",\n    \"nav_upgrade_banner_description\": \"Reserveeri teemasid, rohkem sõnumeid ja e-kirju ning suuremad manused\",\n    \"action_bar_unmute_notifications\": \"Lõpeta teavituste summutamine\",\n    \"action_bar_unsubscribe\": \"Lõpeta tellimus\",\n    \"action_bar_toggle_mute\": \"Lülita teavituste summutamine sisse/välja\",\n    \"action_bar_toggle_action_menu\": \"Ava/sulge tegevuste menüü\",\n    \"notifications_mark_read\": \"Märgi loetuks\",\n    \"notifications_tags\": \"Sildid\",\n    \"notifications_priority_x\": \"{{priority}}. prioriteet\",\n    \"notifications_new_indicator\": \"Uus teavitus\",\n    \"notifications_attachment_image\": \"Pilt manusena\",\n    \"notifications_attachment_copy_url_title\": \"Kopeeri manuse võrguaadress lõikelauale\",\n    \"notifications_attachment_copy_url_button\": \"Kopeeri võrguaadress\",\n    \"notifications_attachment_open_title\": \"Ava {{url}} aadress\",\n    \"notifications_attachment_open_button\": \"Ava manus\",\n    \"notifications_attachment_link_expires\": \"link aegub {{date}}\",\n    \"notifications_attachment_link_expired\": \"allalaadimise link on aegunud\",\n    \"notifications_attachment_file_image\": \"pildifail\",\n    \"notifications_attachment_file_video\": \"videofail\",\n    \"notifications_attachment_file_audio\": \"helifail\",\n    \"notifications_attachment_file_app\": \"Androidi rakenduse fail\",\n    \"notifications_click_copy_url_title\": \"Kopeeri lingi võrguaadress lõikelauale\",\n    \"notifications_click_copy_url_button\": \"Kopeeri link\",\n    \"notifications_click_open_button\": \"Ava link\",\n    \"notifications_actions_open_url_title\": \"Ava {{url}} aadress\",\n    \"notifications_actions_not_supported\": \"Toiming pole veebirakenduses toetatud\",\n    \"alert_notification_permission_required_title\": \"Teavitused pole kasutusel\",\n    \"alert_notification_permission_required_description\": \"Anna oma brauserile õigused näidata töölauateavitusi\",\n    \"alert_notification_permission_required_button\": \"Luba nüüd\",\n    \"alert_notification_permission_denied_title\": \"Teavitused on blokeeritud\",\n    \"alert_notification_ios_install_required_title\": \"Vajalik on iOS-i paigaldamine\",\n    \"alert_not_supported_title\": \"Teavitused pole toetatud\",\n    \"alert_not_supported_description\": \"Teavitused pole sinu veebibrauseris toetatud\",\n    \"account_tokens_table_label_header\": \"Silt\",\n    \"account_tokens_table_never_expires\": \"Ei aegu iialgi\",\n    \"account_tokens_table_current_session\": \"Praegune brauserisessioon\",\n    \"account_tokens_table_copied_to_clipboard\": \"Ligipääsu tunnusluba on kopeeritud\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Praeguse sessiooni tunnusluba ei saa muuta ega kustutada\",\n    \"account_tokens_table_create_token_button\": \"Loo ligipääsuks vajalik tunnusluba\",\n    \"account_tokens_dialog_title_create\": \"Loo ligipääsuks vajalik tunnusluba\",\n    \"account_tokens_dialog_title_edit\": \"Muuda ligipääsuks vajalikku tunnusluba\",\n    \"account_tokens_dialog_title_delete\": \"Kustuta ligipääsuks vajalik tunnusluba\",\n    \"subscribe_dialog_login_password_label\": \"Salasõna\",\n    \"publish_dialog_filename_label\": \"Failinimi\",\n    \"prefs_reservations_table_access_header\": \"Ligipääs\",\n    \"publish_dialog_chip_click_label\": \"Klõpsi võrguaadressi\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Katkesta\",\n    \"publish_dialog_delay_label\": \"Viivitus\",\n    \"account_basics_password_title\": \"Salasõna\",\n    \"account_upgrade_dialog_button_cancel\": \"Katkesta\",\n    \"notifications_example\": \"Näide\",\n    \"account_usage_title\": \"Kasutus\",\n    \"account_basics_title\": \"Kasutajakonto\",\n    \"prefs_reservations_table_topic_header\": \"Teema\",\n    \"account_delete_dialog_button_cancel\": \"Katkesta\",\n    \"account_delete_dialog_label\": \"Salasõna\",\n    \"publish_dialog_message_label\": \"Sõnum\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Kõne\",\n    \"prefs_users_dialog_password_label\": \"Salasõna\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Telli\",\n    \"publish_dialog_priority_label\": \"Prioriteet\",\n    \"subscribe_dialog_login_button_login\": \"Logi sisse\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonüümne\",\n    \"prefs_appearance_theme_title\": \"Kujundus\",\n    \"publish_dialog_button_cancel\": \"Katkesta\",\n    \"account_usage_unlimited\": \"Piiramatu\",\n    \"prefs_notifications_delete_after_never\": \"Mitte kunagi\",\n    \"account_upgrade_dialog_interval_monthly\": \"Iga kuu\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"kuus\",\n    \"prefs_notifications_web_push_disabled\": \"Pole kasutusel\",\n    \"prefs_appearance_title\": \"Välimus\",\n    \"prefs_appearance_language_title\": \"Keel\",\n    \"prefs_reservations_dialog_topic_label\": \"Teema\",\n    \"publish_dialog_priority_min\": \"Väikseim tähtsus\",\n    \"notifications_actions_failed_notification\": \"Ebaõnnestunud toiming\",\n    \"publish_dialog_title_label\": \"Pealkiri\",\n    \"publish_dialog_tags_label\": \"Sildid\",\n    \"publish_dialog_email_label\": \"E-post\",\n    \"display_name_dialog_placeholder\": \"Kuvatav nimi\",\n    \"publish_dialog_title_no_topic\": \"Avalda teavitus\",\n    \"publish_dialog_progress_uploading\": \"Laadin üles…\",\n    \"publish_dialog_message_published\": \"Teavitus on avaldatud\",\n    \"publish_dialog_emoji_picker_show\": \"Vali emoji\",\n    \"publish_dialog_priority_low\": \"Vähetähtis\",\n    \"publish_dialog_priority_default\": \"Vaikimisi tähtsus\",\n    \"publish_dialog_priority_high\": \"Oluline\",\n    \"publish_dialog_priority_max\": \"Väga oluline\",\n    \"publish_dialog_base_url_label\": \"Teenuse võrguaadress\",\n    \"publish_dialog_topic_label\": \"Teema nimi\",\n    \"publish_dialog_topic_reset\": \"Lähtesta teema\",\n    \"publish_dialog_click_label\": \"Klõpsi võrguaadressi\",\n    \"publish_dialog_call_label\": \"Telefonikõne\",\n    \"publish_dialog_button_send\": \"Saada\",\n    \"publish_dialog_attach_label\": \"Manuse võrguaadress\",\n    \"publish_dialog_filename_placeholder\": \"Manuse failinimi\",\n    \"publish_dialog_other_features\": \"Lisavõimalused:\",\n    \"publish_dialog_chip_call_label\": \"Telefonikõne\",\n    \"publish_dialog_chip_delay_label\": \"Viivita saatmisega\",\n    \"publish_dialog_chip_topic_label\": \"Muuda teemat\",\n    \"publish_dialog_button_cancel_sending\": \"Katkesta saatmine\",\n    \"account_basics_username_title\": \"Kasutajanimi\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"Tekstisõnum\",\n    \"account_basics_tier_admin\": \"Peakasutaja\",\n    \"account_basics_tier_basic\": \"Baasteenus\",\n    \"account_basics_tier_free\": \"Tasuta\",\n    \"account_basics_tier_interval_monthly\": \"kord kuus\",\n    \"account_basics_tier_interval_yearly\": \"kord aastas\",\n    \"account_basics_tier_change_button\": \"Muuda\",\n    \"account_upgrade_dialog_interval_yearly\": \"Kord aastas\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Valitud\",\n    \"account_upgrade_dialog_tier_current_label\": \"Praegune\",\n    \"account_tokens_dialog_button_cancel\": \"Katkesta\",\n    \"prefs_notifications_title\": \"Teavitused\",\n    \"prefs_users_table_user_header\": \"Kasutaja\",\n    \"prefs_reservations_dialog_access_label\": \"Ligipääs\",\n    \"priority_min\": \"min\",\n    \"priority_low\": \"madal\",\n    \"priority_default\": \"vaikimisi\",\n    \"priority_high\": \"kõrge\",\n    \"priority_max\": \"kõrgeim\",\n    \"alert_notification_ios_install_required_description\": \"Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“\",\n    \"notifications_none_for_topic_title\": \"Sul pole selles teemas veel ühtegi teavitust.\",\n    \"notifications_none_for_topic_description\": \"Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.\",\n    \"publish_dialog_base_url_placeholder\": \"Teenuse võrguaadress, nt. https://toresait.com\",\n    \"notifications_loading\": \"Laadin teavitusi…\",\n    \"publish_dialog_title_topic\": \"Avalda teemas {{topic}}\",\n    \"publish_dialog_progress_uploading_detail\": \"Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_topic_placeholder\": \"Teema nimi, nt. kadri_kiirteated\",\n    \"publish_dialog_title_placeholder\": \"Teavituse pealkiri, nt. Andmeruumi teavitus\",\n    \"publish_dialog_message_placeholder\": \"Siia sisesta sõnum\",\n    \"notifications_none_for_any_title\": \"Sa pole veel saanud ühtegi teavitust.\",\n    \"publish_dialog_chip_attach_file_label\": \"Lisa kohalik fail\",\n    \"publish_dialog_chip_attach_url_label\": \"Lisa fail võrguaadressilt\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Kinnitatud telefoninumbreid ei leidu\",\n    \"publish_dialog_chip_email_label\": \"Edasta e-posti aadressile\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Teenuse võrguaadress\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Loo nimi\",\n    \"publish_dialog_checkbox_markdown\": \"Kasuta Markdown-vormingut\",\n    \"subscribe_dialog_login_title\": \"Vajalik on sisselogimine\",\n    \"subscribe_dialog_login_username_label\": \"Kasutajanimi, nt. kadri\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Saada SMS\",\n    \"account_basics_username_description\": \"Hei, see oled sina ❤\",\n    \"account_basics_username_admin_tooltip\": \"Sina oled peakasutaja\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Helista mulle\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Kinnituskood\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"nt. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Korda koodi\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} sõnum päevas\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} sõnumit päevas\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Liitu kohe\",\n    \"notifications_actions_http_request_title\": \"Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} e-kirja päevas\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} e-kiri päevas\",\n    \"alert_not_supported_context_description\": \"Teavitused võivad kasutada vaid HTTPS-ühendust. See on <mdnLink>Teavituste API</mdnLink> piirang.\",\n    \"publish_dialog_tags_placeholder\": \"Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus\",\n    \"display_name_dialog_title\": \"Muuda kuvatavat nime\",\n    \"display_name_dialog_description\": \"Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.\",\n    \"reserve_dialog_checkbox_label\": \"Reserveeri teema ja seadista ligipääs\",\n    \"publish_dialog_attachment_limits_file_reached\": \"ületab failisuuruse piiri: {{fileSizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"ületab kvooti, jäänud on {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}\",\n    \"publish_dialog_click_placeholder\": \"Teavituse klõpsimisel avatav võrguaadress\",\n    \"publish_dialog_click_reset\": \"Eemalda klikatav võrguaadress\",\n    \"publish_dialog_email_placeholder\": \"Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com\",\n    \"publish_dialog_email_reset\": \"Eemalda edastamiseks kasutatav e-posti aadress\",\n    \"publish_dialog_call_item\": \"Helista telefoninumbrile {{number}}\",\n    \"publish_dialog_call_reset\": \"Eemalda helistamine\",\n    \"publish_dialog_attach_placeholder\": \"Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Eemalda manuse lisamisel kasutatav võrguaadress\",\n    \"publish_dialog_delay_reset\": \"Eemalda viivitus teavituse edastamisel\",\n    \"account_basics_password_description\": \"Muuda oma kasutajakonto salasõna\",\n    \"account_basics_password_dialog_title\": \"Salasõna muutmine\",\n    \"account_basics_password_dialog_current_password_label\": \"Senine salasõna\",\n    \"account_basics_password_dialog_button_submit\": \"Muuda salasõna\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Salasõna pole korrektne\",\n    \"account_basics_phone_numbers_title\": \"Telefoninumbrid\",\n    \"account_basics_phone_numbers_description\": \"Kõneteavituste jaoks\",\n    \"account_basics_tier_title\": \"Kasutajakonto tüüp\",\n    \"account_basics_tier_description\": \"Sinu kasutajakonto õigused\",\n    \"account_delete_dialog_button_submit\": \"Kustuta kasutajakonto jäädavalt\",\n    \"prefs_appearance_theme_system\": \"Süsteemi kujundus\",\n    \"prefs_appearance_theme_dark\": \"Tume kujundus\",\n    \"prefs_appearance_theme_light\": \"Hele kujundus\",\n    \"prefs_reservations_title\": \"Reserveeritud teemad\",\n    \"prefs_users_table\": \"Kasutajate loend\",\n    \"prefs_users_add_button\": \"Lisa kasutaja\",\n    \"prefs_users_edit_button\": \"Muuda kasutajat\",\n    \"prefs_users_delete_button\": \"Kustuta kasutaja\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Sisselogitud kasutajat ei saa kustutada ega muuta\",\n    \"prefs_users_table_base_url_header\": \"Teenuse võrguaadress\",\n    \"prefs_users_dialog_title_add\": \"Lisa kasutaja\",\n    \"prefs_users_dialog_title_edit\": \"Muuda kasutajat\",\n    \"prefs_users_dialog_base_url_label\": \"Teenuse võrguaadress, nt. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Kasutajanimi, nt. kadri\",\n    \"prefs_notifications_delete_after_three_hours\": \"Kolme tunni möödumisel\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Teavitused kustutatakse automaatselt kolme tunni möödumisel\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Teavitused kustutatakse automaatselt ühe päeva möödumisel\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Teavitused kustutatakse automaatselt ühe nädala möödumisel\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Teavitused kustutatakse automaatselt ühe kuu möödumisel\",\n    \"prefs_notifications_delete_after_never_description\": \"Mitte kunagi ei kustutata teavitusi automaatselt\",\n    \"prefs_notifications_delete_after_title\": \"Kustuta teavitused\",\n    \"publish_dialog_delay_placeholder\": \"Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)\",\n    \"account_basics_password_dialog_new_password_label\": \"Uus salasõna\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Korda salasõna\",\n    \"account_basics_phone_numbers_dialog_description\": \"Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"nt. +37256123456\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Telefoninumbreid veel pole\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefoninumber on kopeeritud lõikelauale\",\n    \"account_basics_phone_numbers_dialog_title\": \"Lisa telefoninumber\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefoninumber\",\n    \"prefs_notifications_delete_after_one_week\": \"Ühe nädala möödumisel\",\n    \"prefs_notifications_delete_after_one_day\": \"Ühe päeva möödumisel\",\n    \"prefs_notifications_delete_after_one_month\": \"Ühe kuu möödumisel\",\n    \"publish_dialog_attached_file_title\": \"Manustatud fail:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Manuse faili nimi\",\n    \"publish_dialog_attached_file_remove\": \"Eemalda manustatud fail\",\n    \"publish_dialog_drop_file_here\": \"Lohista fail siia\",\n    \"emoji_picker_search_placeholder\": \"Otsi emojit\",\n    \"publish_dialog_checkbox_publish_another\": \"Avalda veel midagi\",\n    \"emoji_picker_search_clear\": \"Tühjenda otsing\",\n    \"account_usage_reservations_title\": \"Reserveeritud teemad\",\n    \"account_usage_reservations_none\": \"Sellel kasutajakontol pole reserveeritud teemasid\",\n    \"account_usage_attachment_storage_title\": \"Manuste andmeruum\",\n    \"account_usage_calls_none\": \"Selle kasutajakontoga ei saa helistada\",\n    \"account_usage_calls_title\": \"Helistatud kõnesid\",\n    \"account_usage_messages_title\": \"Avaldatud sõnumeid\",\n    \"account_usage_emails_title\": \"Saadetud e-kirju\",\n    \"account_basics_tier_manage_billing_button\": \"Halda arveldust\",\n    \"account_basics_tier_canceled_subscription\": \"Sinu teenusetellimus on katkestatud ja muutub tasuta {{date}} kontoks.\",\n    \"account_basics_tier_paid_until\": \"Tellimus on tasutud kuni {{date}} ja kuulub automaatselt uuendamisele\",\n    \"account_basics_tier_upgrade_button\": \"Hakka kasutama Pro-teenust\",\n    \"account_basics_tier_payment_overdue\": \"Sinu arve(d) on tasumata. Palun uuenda oma maksmisviisi või vastasel juhul peame varsti sinu kasutajakonto taseme muutma madalamaks.\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Reserveeritud teemasid pole\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reserveeritud teemat\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reserveeritud teema\",\n    \"prefs_notifications_sound_title\": \"Teavituse heli\",\n    \"account_tokens_delete_dialog_submit_button\": \"Kustuta tunnusluba jäädavalt\",\n    \"account_tokens_delete_dialog_description\": \"Enne tunnusloa kustutamist palun kontrolli, et ükski rakendus ei kasutaks seda. <strong>Seda tegevust ei saa tagasi pöörata</strong>.\",\n    \"account_tokens_delete_dialog_title\": \"Kustuta ligipääsu tunnusluba\",\n    \"account_tokens_dialog_expires_never\": \"Tunnusluba ei aegu iialgi\",\n    \"account_tokens_dialog_expires_x_days\": \"Tunnusluba aegub {{days}} päeva pärast\",\n    \"account_tokens_dialog_expires_x_hours\": \"Tunnusluba aegub {{hours}} tunni pärast\",\n    \"account_tokens_dialog_expires_unchanged\": \"Jäta aegumise kuupäev muutmata\",\n    \"account_tokens_dialog_expires_label\": \"Tunnusluba aegub\",\n    \"account_tokens_dialog_button_update\": \"Uuenda tunnusluba\",\n    \"account_tokens_dialog_button_create\": \"Loo tunnusluba\",\n    \"prefs_users_title\": \"Halda kasutajaid\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Kasuta muud serverit\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Teavitused muudest serveritest ei toimi, kui veebirakendus pole avatud\",\n    \"subscribe_dialog_login_description\": \"See teema on kaitstud salasõnaga. Tellimiseks sisesta palun kasutajanimi ja salasõna.\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Teema on juba reserveeritud\",\n    \"account_delete_title\": \"Kustuta kasutajakonto\",\n    \"account_delete_description\": \"Kustuta oma kasutajakonto jäädavalt\",\n    \"account_delete_dialog_description\": \"Järgnevaga kustutad serverist lõplikult oma kasutajakonto ning kõik temaga seotud andmed. Peale kustutamist pole kasutajanimi saadaval 7 päeva jooksul. Kui sa tõesti soovid kustutamisega jätkata, siis palun sisesta alljärgnevasse kasti oma salasõna.\",\n    \"web_push_unknown_notification_title\": \"Serverist saabus tundmatu teavitus\",\n    \"web_push_subscription_expiring_body\": \"Kui soovid, et jätkuvalt saabuks teavitused, siis ava ntfy\",\n    \"web_push_subscription_expiring_title\": \"Teavitused on ajutiselt peatatud\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Veebibrauseri privaatne režiim pole toetatud\",\n    \"error_boundary_button_reload_ntfy\": \"Laadi ntfy uuesti\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopeeri pinujälg\",\n    \"error_boundary_stack_trace\": \"Pinujälg\",\n    \"error_boundary_gathering_info\": \"Kogu täiendavat teavet…\",\n    \"notifications_none_for_any_description\": \"Teemakohaste teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile. Siin on üks näide ühe sinu teemaga.\",\n    \"notifications_no_subscriptions_title\": \"Tundub, et sul pole veel ühtegi tellimust.\",\n    \"notifications_no_subscriptions_description\": \"Olemasoleva teema tellimiseks või uue loomiseks klõpsa „{{linktext}}“. Peale seda saad PUT või POST meetodiga päringuga saata sõnumeid ning neid siin vastu võtta.\",\n    \"notifications_more_details\": \"Lisateavet leiad <websiteLink>veebisaidist</websiteLink> või <docsLink>juhendist</docsLink>.\",\n    \"publish_dialog_details_examples_description\": \"Näited ja saatmisvõimaluste üksikasjaliku kirjelduse leiad <docsLink>juhendist</docsLink>.\",\n    \"account_tokens_description\": \"Selleks, et ei peaks ntfy API abil avaldamise ja tellimuse päringusse lisama kasutajanime ja salasõna, kasuta tunnuslubasid. Lisateavet leiad <Link>juhendist</Link>.\",\n    \"subscribe_dialog_subscribe_title\": \"Telli teema\",\n    \"subscribe_dialog_subscribe_description\": \"Teemasid ei saa salasõnaga kaitsta, seega vali teema nimi, mida pole väga lihtne ära arvata. Peale tellimuse tegemist võide kohe hakata PUT või POST päringutega sõnumeid saatma.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Teema nimi, näiteks kadri_kiirteated\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Kasutajal {{username}} puudub volitus\",\n    \"account_usage_of_limit\": \"piirangust {{limit}}\",\n    \"account_usage_limits_reset_daily\": \"Kasutuspiirangud lähtestatakse keskööl (UTC järgi)\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(tasemega {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(tase puudub)\",\n    \"account_upgrade_dialog_title\": \"Muuda kasutajakonto taset\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} faili kohta\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} kõnet päevas\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} kõne päevas\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Ilma telefonikõnedeta\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"andmeruum kokku {{totalsize}}\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}}aastas. Arveldatuna kord kuus.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} arveldatuna kord aastas. Sa säästad {{save}}.\",\n    \"account_upgrade_dialog_button_pay_now\": \"Maksa nüüd ja telli\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Katkesta tellimus\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"säästa {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"säästa kuni {{discount}}%\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Küsimuste puhul arvelduste kohta, palun <Link>kontakteeru meiega otse</Link>.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Küsimuste puhul arvelduste kohta, palun <Link>vaata meie veebisaiti</Link>.\",\n    \"account_delete_dialog_billing_warning\": \"Sinu kasutajakonto kustutamisel katkeb koheselt ka tellimus. Muu hulgas ei saa sa enam ligi arvelduste haldusvaatele.\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Uuenda tellimust\",\n    \"account_tokens_title\": \"Tunnusload ligipääsuks\",\n    \"account_tokens_dialog_label\": \"Silt, näiteks „Salaradari teavitused“\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} faili kohta, kustutatud peale {{expiry}}\",\n    \"account_usage_cannot_create_portal_session\": \"Arvelduste vaate avamine ei õnnestu\",\n    \"prefs_notifications_min_priority_any\": \"Kõik prioriteedid\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Vähetähtsad ja kõrgemad\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Vaikimisi tähtsusega ja kõrgemad\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Väga tähtsad ja kõrgemad\",\n    \"prefs_notifications_min_priority_max_only\": \"Vaid kõrgeim prioriteet\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Vaid mina saan avaldada ja tellida\",\n    \"prefs_reservations_table_everyone_read_only\": \"Mina saan avaldada ja tellida, kõik saavad tellida\",\n    \"prefs_reservations_table_everyone_write_only\": \"Mina saan avaldada ja tellida, kõik saavad avaldada\",\n    \"prefs_reservations_table_everyone_read_write\": \"Kõik saavad avaldada ja tellida\",\n    \"prefs_reservations_table_not_subscribed\": \"Pole tellitud\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Tellimiseks klõpsi\",\n    \"prefs_reservations_dialog_title_add\": \"Reserveeri teema\",\n    \"prefs_reservations_dialog_title_edit\": \"Muuda reserveeritud teemat\",\n    \"prefs_reservations_dialog_title_delete\": \"Kustuta teema reserveering\",\n    \"prefs_reservations_dialog_description\": \"Teema reserveerimisega muutud selle omanikuks ja saad teiste jaoks määrata ligipääsuõigusi teemale.\",\n    \"reservation_delete_dialog_description\": \"Teema reserveerimisest loobudes annad teistele võimaluse seda reserveerida ja muutuda selle omanikuks. Sina saad otsustada, kas vanad sõnumid jäävad alles või kustutatakse.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Säilita puhverdatud sõnumid ja manused\",\n    \"reservation_delete_dialog_action_keep_description\": \"Serveris puhverdatud sõnumid ja manused muutuvad avalikult nähtavaks neile, kes teavad teema nime.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Kustuta puhverdatud sõnumid ja manused\",\n    \"reservation_delete_dialog_action_delete_description\": \"Puhverdatud sõnumid ja manused kustuvad jäädavalt. Seda tegevust ei saa hiljem tagasi pöörata.\",\n    \"reservation_delete_dialog_submit_button\": \"Kustuta reserveerimine\",\n    \"prefs_reservations_description\": \"Sa võid teemade nimesid reserveerida isiklikuks kasutuseks. Sellega muutud teema omanikuks ja saad määrata, kes ning mis viisil teemale ligi saab.\",\n    \"prefs_reservations_limit_reached\": \"Oled jõudnud reserveeritud teemade arvu ülempiirini.\",\n    \"prefs_reservations_add_button\": \"Lisa reserveeritud teema\",\n    \"prefs_reservations_edit_button\": \"Muuda ligipääsu teemale\",\n    \"prefs_reservations_delete_button\": \"Lähtesta ligipääs teemale\",\n    \"prefs_reservations_table\": \"Reserveeritud teemade tabel\",\n    \"web_push_unknown_notification_body\": \"Avades veebirakenduse peaksid vist tegema ntfy uuenduse\",\n    \"prefs_users_description_no_sync\": \"Kasutajad ja nende salasõnad pole sinu kontoga sünkroonitud.\",\n    \"error_boundary_title\": \"Vaat, kus lops - ntfy jooksis kokku\",\n    \"error_boundary_description\": \"Ilmselgelt ei peaks niimoodi juhtuma. Vabandust.<br/>Kui sul on mõni hetk aega, siis palun <githubLink>seate sellest GitHubis</githubLink> või kirjuta <discordLink>Discordis</discordLink> või <matrixLink>Matrixis</matrixLink>.\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Meie ntfy veebirakendus vajab korralikuks toimimiseks brauseri IndexedDB funktsionaalsust, aga sinu veebibrauser seda privaatses režiimis ei toeta.<br/><br/>See on nüüd õnnetu lugu küll, aga olemuslikult pole ntfy veebirakenduse kasutamisel privaatses režiimis eriti mõtet - kõike hoitakse ju brauseri hallatavas andmekogus. Lisateavet selle kohta leiad <githubLink>GitHubist siit</githubLink>, aga saad ka teema üle meiega arutleda <discordLink>Discordis</discordLink> või <matrixLink>Matrixis</matrixLink>.\",\n    \"account_usage_basis_ip_description\": \"Selle kasutajakonto statistika ja kasutuspiirangud põhinevad sinu IP-aadressil ja seega võivad nad olla teistega jagatud. Siin näidatud piirangud on hinnangulised ja põhinevad üldistel päringupiirangutel.\",\n    \"prefs_notifications_web_push_enabled\": \"Kasutusel serveris {{server}}\",\n    \"prefs_notifications_web_push_disabled_description\": \"Saad teavitusi siis, kui rakendus on töös (WebSocketi abil)\",\n    \"prefs_notifications_web_push_enabled_description\": \"Saad teavitusi siis, kui rakendus pole töös (Web Pushi abil)\",\n    \"prefs_notifications_web_push_title\": \"Teavitused taustal\",\n    \"prefs_notifications_min_priority_description_max\": \"Näita teavitusi siis, kui prioriteet on 5 (maksimaalne)\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Näita teavitusi siis, kui prioriteet on {{number}} ({{name}}) või kõrgem\",\n    \"prefs_notifications_sound_description_none\": \"Teavitused ei kasuta saabumisel helimärguannet\",\n    \"prefs_notifications_sound_description_some\": \"Teavitused kasutavad saabumisel helimärguannet {{sound}}\",\n    \"prefs_notifications_sound_no_sound\": \"Helimärguanne puudub\",\n    \"prefs_notifications_sound_play\": \"Esita valitud helimärguannet\",\n    \"prefs_notifications_min_priority_title\": \"Väikseim prioriteet\",\n    \"prefs_notifications_min_priority_description_any\": \"Näitan kõiki teavitusi ja seejuures ei arvesta prioriteetidega\",\n    \"account_upgrade_dialog_cancel_warning\": \"Sellega <strong>katkestad oma tellimuse</strong> ja {{date}} muutub sinu kasutajakonto tase madalamaks. Sel kuupäeval teemade reserveeringud tühistuvad ja puhverdatud sõnumid <strong>kustutatakse serverist</strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Summade jagamine</strong>: Kui muudad teenusepaketti paremaks, siis pead hinnavahe <strong>maksma kohe</strong>. Kui muudad teenusepaketti madalamaks, siis hinnavahe arvelt hüvituvad mõned järgmised maksed.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt üks reserveering</strong>. Seda saad <Link>teha siin</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt {{count}} reserveeringut</strong>. Seda saad <Link>teha siin</Link>.\",\n    \"prefs_users_description\": \"Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis.\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Eelsisestatud kasutajat ei saa muuta ega kustutada\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Eelsisestatud tunnusluba ei saa muuta ega kustutada\"\n}\n"
  },
  {
    "path": "web/public/static/langs/fa.json",
    "content": "{\n    \"signup_title\": \"ایجاد اکانت ntfy\",\n    \"signup_form_button_submit\": \"ثبت نام\",\n    \"signup_already_have_account\": \"قبلا اکانت دارید؟ وارد بشود\",\n    \"signup_disabled\": \"ثبت نام غیرفعال است\",\n    \"login_title\": \"ورود به اکانت ntfy\",\n    \"login_link_signup\": \"ثبت نام\",\n    \"login_disabled\": \"ورود غیرفعال است\",\n    \"action_bar_show_menu\": \"نمایش منو\",\n    \"action_bar_account\": \"اکانت\",\n    \"action_bar_reservation_limit_reached\": \"دسترسی محدود\",\n    \"action_bar_send_test_notification\": \"ارسال تستی اعلان\",\n    \"action_bar_unmute_notifications\": \"لغو ساکت کردن اعلان ها\",\n    \"action_bar_unsubscribe\": \"لغو اشتراک\",\n    \"action_bar_toggle_mute\": \"بی صدا/لغو اعلان ها\",\n    \"common_cancel\": \"لغو\",\n    \"common_save\": \"ذخیره\",\n    \"common_add\": \"اضافه کردن\",\n    \"common_back\": \"عقب\",\n    \"common_copy_to_clipboard\": \"کپی به کلیپ بورد\",\n    \"signup_form_username\": \"نام کاربری\",\n    \"signup_form_password\": \"کلمه عبور\",\n    \"signup_form_confirm_password\": \"تایید پسورد\",\n    \"signup_form_toggle_password_visibility\": \"تغییر وضعیت نمایش کلمه عبور\",\n    \"signup_error_username_taken\": \"نام کاربری {{username}} قبلا استفاده شده است\",\n    \"signup_error_creation_limit_reached\": \"به حد مجاز ایجاد حساب رسیده است\",\n    \"login_form_button_submit\": \"ورود\",\n    \"action_bar_logo_alt\": \"لوگوی ntfy\",\n    \"action_bar_settings\": \"تنظیمات\",\n    \"action_bar_change_display_name\": \"تغییر نام نمایشی\",\n    \"action_bar_reservation_add\": \"رزرو موضوع\",\n    \"action_bar_reservation_edit\": \"تغییر رزرو\",\n    \"action_bar_reservation_delete\": \"حذف رزرو\",\n    \"action_bar_mute_notifications\": \"ساکت کردن اعلان ها\",\n    \"action_bar_clear_notifications\": \"پاک کردن تمام اعلان ها\",\n    \"action_bar_toggle_action_menu\": \"گشودن يا بستن فهرست کنش\",\n    \"action_bar_profile_title\": \"نمايه\",\n    \"action_bar_profile_settings\": \"تنظیمات\",\n    \"action_bar_profile_logout\": \"خروج\",\n    \"action_bar_sign_in\": \"ورود\",\n    \"action_bar_sign_up\": \"ثبت نام\",\n    \"message_bar_type_message\": \"یک پیام بنویسید\",\n    \"message_bar_error_publishing\": \"خطا در انتظار اعلان\",\n    \"message_bar_publish\": \"انتشار پیام\",\n    \"nav_button_all_notifications\": \"همه اعلان‌ها\",\n    \"nav_button_account\": \"حساب کاربری\",\n    \"nav_button_settings\": \"تنظیمات\",\n    \"nav_button_documentation\": \"مستندات\",\n    \"nav_button_publish_message\": \"انتشار اعلان\",\n    \"nav_button_muted\": \"اعلان بی‌صدا شد\",\n    \"nav_button_connecting\": \"در حال اتصال\",\n    \"nav_upgrade_banner_label\": \"ارتقا با ntfy پیشرفته\",\n    \"alert_notification_permission_required_title\": \"اعلان‌ها غیرفعال هستند\",\n    \"alert_notification_permission_required_description\": \"به مرورگر خود اجازه دهید تا اعلان‌های دسکتاپ را نمایش دهد\",\n    \"alert_notification_permission_denied_title\": \"اعلان‌ها مسدود هستند\",\n    \"alert_notification_ios_install_required_title\": \"لازم به نصب نسخه iOS است\",\n    \"alert_notification_ios_install_required_description\": \"برای فعال کردن اعلان‌ها در iOS، روی نماد اشتراک‌گذاری و افزودن به صفحه اصلی کلیک کنید\"\n}\n"
  },
  {
    "path": "web/public/static/langs/fi.json",
    "content": "{\n    \"publish_dialog_message_placeholder\": \"Kirjoita viesti tähän\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Ei puheluita\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Laskutukseen liittyvissä kysymyksissä <Link>ole yhteydessä</Link> .\",\n    \"account_tokens_dialog_title_create\": \"Luo käyttöoikeustunnus\",\n    \"prefs_reservations_dialog_title_edit\": \"Muokkaa varattua topikkia\",\n    \"account_basics_tier_interval_monthly\": \"Kuukausittain\",\n    \"publish_dialog_checkbox_publish_another\": \"Julkaise toinen\",\n    \"publish_dialog_details_examples_description\": \"Katso esimerkkejä ja yksityiskohtaisen kuvauksen kaikista lähetysominaisuuksista <docsLink>dokumentaatiosta</docsLink>.\",\n    \"account_basics_tier_canceled_subscription\": \"Tilauksesi peruutettiin ja se muutetaan maksuttomaksi tiliksi {{date}}.\",\n    \"priority_default\": \"oletus\",\n    \"prefs_notifications_min_priority_title\": \"Vähimmäisprioriteetti\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} päivittäisiä puheluja\",\n    \"account_upgrade_dialog_tier_current_label\": \"Nykyinen\",\n    \"action_bar_account\": \"Tili\",\n    \"publish_dialog_filename_placeholder\": \"Liitetiedoston nimi\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Salasana virheellinen\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"prefs_notifications_delete_after_never\": \"Ei koskaan\",\n    \"prefs_users_description\": \"Lisää/poista käyttäjiä suojatuista topikeista täällä. Huomaa, että käyttäjätunnus ja salasana on tallennettu selaimen paikalliseen tallennustilaan.\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Puhelinnumero\",\n    \"subscribe_dialog_subscribe_description\": \"Aiheet eivät välttämättä ole salasanasuojattuja, joten valitse nimi, jota ei ole helposti arvatavissa. Kun olet tilannut, voit käyttää PUT/POST ilmoituksia.\",\n    \"action_bar_logo_alt\": \"ntfy-logo\",\n    \"account_basics_password_dialog_button_submit\": \"Vaihda salasana\",\n    \"publish_dialog_emoji_picker_show\": \"Valitse emoji\",\n    \"account_basics_username_title\": \"Käyttäjätunnus\",\n    \"login_disabled\": \"Kirjautuminen poissa käytöstä\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Vahvista koodi\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"säästä jopa {{discount}}%\",\n    \"account_tokens_dialog_label\": \"Etiketti, esim. Tutka-ilmoitukset\",\n    \"common_add\": \"Lisää\",\n    \"account_tokens_table_expires_header\": \"Vanhenee\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Osuussuhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.\",\n    \"prefs_reservations_dialog_access_label\": \"Oikeudet\",\n    \"account_usage_attachment_storage_title\": \"Liiteiden säilytys\",\n    \"prefs_users_dialog_username_label\": \"Käyttäjätunnus, esim. pentti\",\n    \"message_bar_error_publishing\": \"Virhe ilmoituksen julkaisemisessa\",\n    \"publish_dialog_chip_delay_label\": \"Viivästytä toimitusta\",\n    \"account_usage_messages_title\": \"Julkaistut viestit\",\n    \"notifications_attachment_open_button\": \"Avaa liite\",\n    \"emoji_picker_search_clear\": \"Tyhjennä haku\",\n    \"prefs_reservations_table_not_subscribed\": \"Ei tilattu\",\n    \"publish_dialog_topic_placeholder\": \"Topikin nimi, esim. erkin_hälyt\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} päivittäisiä emaileja\",\n    \"prefs_notifications_min_priority_max_only\": \"Vain maksimiprioriteetti\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} päivittäisiä puheluja\",\n    \"prefs_notifications_sound_description_some\": \"Ilmoitukset soittavat {{sound}}-äänen saapuessaan\",\n    \"prefs_reservations_edit_button\": \"Muokkaa topikin oikeuksia\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Lähetä SMS\",\n    \"account_basics_tier_change_button\": \"Vaihda\",\n    \"account_tokens_dialog_expires_never\": \"Käyttöoikeus ei vanhene koskaan\",\n    \"subscribe_dialog_login_title\": \"Kirjautuminen vaaditaan\",\n    \"account_tokens_dialog_expires_x_days\": \"Tunnus vanhenee {{days}} päivän kuluttua\",\n    \"notifications_new_indicator\": \"Uusi ilmoitus\",\n    \"prefs_reservations_table_everyone_read_only\": \"Minä voin julkaista ja tilata, kaikki voivat tilata\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Vain minä voin julkaista ja tilata\",\n    \"publish_dialog_chip_topic_label\": \"Vaihda topikkia\",\n    \"account_basics_phone_numbers_dialog_description\": \"Jotta voit käyttää puheluilmoitusominaisuutta, sinun on lisättävä ja vahvistettava vähintään yksi puhelinnumero. Vahvistus voidaan tehdä tekstiviestillä tai puhelimitse.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} varatut topikit\",\n    \"publish_dialog_tags_placeholder\": \"Pilkuilla eroteltu luettelo tunnisteista, esim. varoitus, srv1-varmuuskopio\",\n    \"account_delete_title\": \"Poista tili\",\n    \"publish_dialog_attached_file_remove\": \"Poista liitetiedosto\",\n    \"nav_button_connecting\": \"yhdistetään\",\n    \"account_delete_dialog_label\": \"Salasana\",\n    \"subscribe_dialog_login_button_login\": \"Kirjaudu\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Ei varattuja topikkeja\",\n    \"message_bar_type_message\": \"Kirjoita viesti tähän\",\n    \"publish_dialog_base_url_label\": \"Palvelun URL\",\n    \"signup_form_confirm_password\": \"Vahvista salasana\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Kirjautunutta käyttäjää ei voi poistaa tai muokata\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(mukana {{tier}} tier)\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Ilmoitukset poistetaan automaattisesti kolmen tunnin kuluttua\",\n    \"publish_dialog_chip_email_label\": \"Lähetä sähköpostiin\",\n    \"publish_dialog_attach_label\": \"Liitteen URL-osoite\",\n    \"signup_form_username\": \"Käyttäjätunnus\",\n    \"prefs_notifications_delete_after_three_hours\": \"Kolmen tunnin jälkeen\",\n    \"nav_button_muted\": \"Ilmoitukset mykistetty\",\n    \"action_bar_profile_settings\": \"Asetukset\",\n    \"signup_error_creation_limit_reached\": \"Tilin lisäämisraja saavutettu\",\n    \"notifications_attachment_open_title\": \"Siirry osoitteeseen {{url}}\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Näytä ilmoitukset, jos prioriteetti on {{number}} ({{name}}) tai suurempi\",\n    \"reservation_delete_dialog_description\": \"Varauksen poistaminen luopuu topikin omistajuudesta ja antaa muiden varata sen. Voit säilyttää tai poistaa olemassa olevia viestejä ja liitteitä.\",\n    \"subscribe_dialog_login_username_label\": \"Käyttäjätunnus, esim. pentti\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Käyttäjää {{username}} ei ole valtuutettu\",\n    \"prefs_reservations_table_everyone_read_write\": \"Jokainen voi julkaista ja tilata\",\n    \"prefs_reservations_dialog_title_delete\": \"Poista topikin varaus\",\n    \"prefs_users_table\": \"Käyttäjätaulukko\",\n    \"prefs_reservations_table_topic_header\": \"Topikki\",\n    \"action_bar_toggle_mute\": \"Mykistä/palauta ilmoitukset\",\n    \"reservation_delete_dialog_submit_button\": \"Poista varaus\",\n    \"account_basics_title\": \"Tili\",\n    \"nav_button_documentation\": \"Dokumentaatio\",\n    \"prefs_reservations_limit_reached\": \"Olet saavuttanut varattujen topikkien rajan.\",\n    \"account_upgrade_dialog_interval_monthly\": \"Kuukausittain\",\n    \"prefs_users_add_button\": \"Lisää käyttäjä\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} päivittäisiä viestejä\",\n    \"publish_dialog_delay_reset\": \"Poista viivästetty toimitus\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Ei puhelinnumeroita vielä\",\n    \"action_bar_toggle_action_menu\": \"Avaa/sulje toimintovalikko\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Luo nimi\",\n    \"notifications_list_item\": \"Ilmoitus\",\n    \"prefs_appearance_language_title\": \"Kieli\",\n    \"notifications_attachment_link_expired\": \"latauslinkki vanhentunut\",\n    \"subscribe_dialog_login_password_label\": \"Salasana\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Ilmoitukset poistetaan automaattisesti yhden päivän kuluttua\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Tilaa\",\n    \"account_tokens_table_never_expires\": \"Ei vanhene koskaan\",\n    \"account_tokens_delete_dialog_title\": \"Poista käyttöoikeustunnus\",\n    \"prefs_notifications_delete_after_one_month\": \"Kuukauden kuluttua\",\n    \"publish_dialog_chip_call_label\": \"Puhelu\",\n    \"account_basics_phone_numbers_dialog_title\": \"Lisää puhelinnumero\",\n    \"account_tokens_delete_dialog_description\": \"Ennen kuin poistat käyttöoikeustunnuksen, varmista, että mikään sovellus tai komentosarja ei käytä sitä aktiivisesti. <strong>Tätä toimintoa ei voi kumota</strong>.\",\n    \"nav_button_all_notifications\": \"Kaikki ilmoitukset\",\n    \"account_upgrade_dialog_button_cancel\": \"Peruuta\",\n    \"notifications_attachment_image\": \"Liitekuva\",\n    \"account_tokens_table_label_header\": \"Merkki\",\n    \"notifications_attachment_file_document\": \"muu asiakirja\",\n    \"publish_dialog_button_cancel\": \"Peruuta\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Laskutukseen liittyvissä kysymyksissä käy <Link>verkkosivustolla</Link>.\",\n    \"signup_form_button_submit\": \"Rekisteröidy\",\n    \"account_basics_username_admin_tooltip\": \"Olet pääkäyttäjä\",\n    \"prefs_notifications_delete_after_never_description\": \"Ilmoituksia ei koskaan poisteta automaattisesti\",\n    \"account_delete_dialog_description\": \"Tämä poistaa pysyvästi tilisi, mukaan lukien kaikki palvelimelle tallennetut tiedot. Poistamisen jälkeen käyttäjätunnuksesi on poissa käytöstä 7 päivään. Jos todella haluat jatkaa, vahvista salasanasi alla olevaan kenttään.\",\n    \"publish_dialog_email_reset\": \"Poista sähköpostin edelleenlähetys\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} varatut topikit\",\n    \"account_usage_reservations_none\": \"Tälle tilille ei ole varattu topikkeja\",\n    \"prefs_notifications_sound_description_none\": \"Ilmoitukset eivät toista ääntä saapuessaan\",\n    \"account_tokens_description\": \"Käytä käyttjätunnuksia, kun julkaiset ja tilaat ntfy API:n kautta, jotta sinun ei tarvitse lähettää tilisi tunnistetietoja. Katso lisätietoja <Link>documentation</Link>.\",\n    \"common_back\": \"Takaisin\",\n    \"prefs_reservations_table\": \"Varattujen topikkien taulukko\",\n    \"emoji_picker_search_placeholder\": \"Etsi emoji\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Topikin nimi, esim. pentin_hälyt\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Peruuta tilaus\",\n    \"notifications_attachment_file_audio\": \"äänitiedosto\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} päivittäisiä emaileja\",\n    \"action_bar_sign_up\": \"Kirjautuminen\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} tiedostokoko\",\n    \"notifications_mark_read\": \"Merkitse luetuksi\",\n    \"prefs_reservations_description\": \"Voit varata topikien nimiä henkilökohtaiseen käyttöön täältä. Aiheen varaaminen antaa sinulle topikin omistajuuden ja voit määrittää topikkiin liittyviä käyttöoikeuksia muille käyttäjille.\",\n    \"notifications_attachment_copy_url_title\": \"Kopioi liitteen URL-osoite leikepöydälle\",\n    \"account_usage_title\": \"Käytössä\",\n    \"account_basics_tier_upgrade_button\": \"Päivitä Pro-versioon\",\n    \"prefs_users_description_no_sync\": \"Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.\",\n    \"account_tokens_dialog_title_edit\": \"Muokkaa käyttöoikeustunnusta\",\n    \"nav_button_publish_message\": \"Julkaise ilmoitus\",\n    \"prefs_users_table_base_url_header\": \"Palvelun URL\",\n    \"notifications_click_copy_url_title\": \"Kopioi linkin URL-osoite leikepöydälle\",\n    \"publish_dialog_attach_reset\": \"Poista liitteen URL-osoite\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} päivittäisiä viestejä\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Valittu taso sallii vähemmän varattuja topikeita kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään yksi varaus</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.\",\n    \"common_copy_to_clipboard\": \"Kopioi leikepöydälle\",\n    \"alert_not_supported_description\": \"Selaimesi ei tue ilmoituksia\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Topikki on jo varattu\",\n    \"message_bar_publish\": \"Julkaise viesti\",\n    \"alert_grant_description\": \"Myönnä selaimelle lupa näyttää työpöytäilmoituksia.\",\n    \"prefs_users_table_user_header\": \"Käyttäjä\",\n    \"error_boundary_stack_trace\": \"Pinon jälki\",\n    \"prefs_users_dialog_password_label\": \"Salasana\",\n    \"prefs_notifications_delete_after_one_week\": \"Viikon kuluttua\",\n    \"publish_dialog_priority_low\": \"Matala tärkeys\",\n    \"publish_dialog_priority_label\": \"Prioriteetti\",\n    \"prefs_reservations_delete_button\": \"Poista topikin oikeudet\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(ei tasoa)\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Ilmoitukset poistetaan automaattisesti viikon kuluttua\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Peruuta\",\n    \"notifications_attachment_copy_url_button\": \"Kopioi URL\",\n    \"account_basics_tier_payment_overdue\": \"Maksusi on myöhässä. Päivitä maksutapasi, tai tilisi poistetaan pian.\",\n    \"publish_dialog_title_placeholder\": \"Ilmoituksen otsikko, esim. Levytilan hälytys\",\n    \"account_basics_tier_description\": \"Tilisi taso\",\n    \"account_basics_phone_numbers_description\": \"Puheluilmoituksia varten\",\n    \"prefs_reservations_dialog_title_add\": \"Varaa topikki\",\n    \"account_basics_tier_free\": \"Maksuton\",\n    \"account_upgrade_dialog_cancel_warning\": \"Tämä <strong>peruuttaa tilauksesi</strong> ja alentaa tilisi {{date}}. Tuona päivänä topikit sekä palvelimen välimuistissa olevat viestit <strong>poistetaan</strong>.\",\n    \"notifications_click_copy_url_button\": \"Kopioi linkki\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"subscribe_dialog_subscribe_title\": \"Tilaa topikki\",\n    \"nav_topics_title\": \"Tilatut aiheet\",\n    \"prefs_notifications_sound_title\": \"Ilmoitusääni\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Oletusprioriteetti ja korkeammat\",\n    \"prefs_reservations_table_access_header\": \"Oikeudet\",\n    \"action_bar_show_menu\": \"Näytä valikko\",\n    \"action_bar_settings\": \"Asetukset\",\n    \"notifications_copied_to_clipboard\": \"Kopioitu leikepöydälle\",\n    \"account_delete_dialog_button_cancel\": \"Peruuta\",\n    \"publish_dialog_delay_placeholder\": \"Toimituksen viivästyminen, esim. {{unixTimestamp}}, {{relativeTime}} tai \\\"{{naturalLanguage}}\\\" (vain englanti)\",\n    \"account_tokens_table_copied_to_clipboard\": \"Käyttöoikeustunnus kopioitu\",\n    \"alert_grant_title\": \"Ilmoitukset on poistettu käytöstä\",\n    \"account_tokens_dialog_expires_x_hours\": \"Tunnus vanhenee {{hours}} tunnin kuluttua\",\n    \"prefs_users_edit_button\": \"Muokkaa käyttäjää\",\n    \"account_upgrade_dialog_title\": \"Muuta tilitasoa\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Ei vahvistettuja puhelinnumeroita\",\n    \"priority_low\": \"matala\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Tilaa napsauttamalla\",\n    \"account_basics_password_description\": \"Vaihda tilisi salasana\",\n    \"publish_dialog_call_label\": \"Puhelu\",\n    \"account_usage_calls_title\": \"Soitetut puhelut\",\n    \"error_boundary_description\": \"Näin ei selvästikään pitäisi tapahtua. Pahoittelut tästä.<br/>Jos sinulla on hetki aikaa, <githubLink>ilmoita tästä GitHubissa</githubLink> tai ilmoita meille <discordLink>Discordin</discordLink> tai <matrixLink>Matrix</matrixLink> kautta.\",\n    \"signup_form_toggle_password_visibility\": \"Näytä/piilota salasana\",\n    \"login_link_signup\": \"Rekisteröidy\",\n    \"publish_dialog_message_label\": \"Viesti\",\n    \"publish_dialog_attached_file_title\": \"Liitetiedosto:\",\n    \"priority_min\": \"min\",\n    \"action_bar_sign_in\": \"Kirjaudu sisään\",\n    \"action_bar_unsubscribe\": \"Peruuta tilaus\",\n    \"account_basics_tier_basic\": \"Perus\",\n    \"signup_title\": \"Luo ntfy-tili\",\n    \"prefs_notifications_min_priority_description_any\": \"Näytetään kaikki ilmoitukset tärkeydestä riippumatta\",\n    \"error_boundary_gathering_info\": \"Kerää lisätietoja…\",\n    \"publish_dialog_priority_max\": \"Max. prioriteetti\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Yksityistä selaamista ei tueta\",\n    \"prefs_notifications_delete_after_one_day\": \"Yhden päivän jälkeen\",\n    \"error_boundary_title\": \"Voi ei, ntfy kaatui\",\n    \"action_bar_change_display_name\": \"Näyttönimen vaihtaminen\",\n    \"notifications_attachment_file_app\": \"Android-sovellustiedosto\",\n    \"alert_not_supported_context_description\": \"Ilmoituksia tuetaan vain HTTPS:n kautta. Tämä on <mdnLink>Ilmoitussovellusliittymän</mdnLink> rajoitus.\",\n    \"reservation_delete_dialog_action_keep_description\": \"Palvelimelle välimuistiin tallennetut viestit ja liitteet tulevat julkiseksi topikin nimen tietävälle henkilölle.\",\n    \"prefs_reservations_add_button\": \"Lisää varattu topik\",\n    \"prefs_reservations_title\": \"Varatut topikit\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Puhelinnumero kopioitu leikepöydälle\",\n    \"prefs_reservations_dialog_description\": \"Topikin varaaminen antaa sinulle aiheen omistajuuden ja voit määrittää aiheeseen liittyviä käyttöoikeuksia muille käyttäjille.\",\n    \"account_basics_tier_title\": \"Tilin tyyppi\",\n    \"account_usage_cannot_create_portal_session\": \"Laskutusportaalin avaaminen epäonnistui\",\n    \"account_tokens_delete_dialog_submit_button\": \"Poista tunnus pysyvästi\",\n    \"account_delete_description\": \"Poista tilisi pysyvästi\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"esim. +35812345678\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"esim. 123456\",\n    \"prefs_notifications_title\": \"Ilmoitukset\",\n    \"account_basics_tier_manage_billing_button\": \"Hallinnoi laskutusta\",\n    \"account_tokens_title\": \"Käyttöoikeudet\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"account_basics_username_description\": \"Hei, se olet sinä ❤\",\n    \"prefs_reservations_dialog_topic_label\": \"Topik\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Vahvista salasana\",\n    \"action_bar_reservation_edit\": \"Muokkaa varausta\",\n    \"publish_dialog_base_url_placeholder\": \"Palvelun URL-osoite, esim. https://example.com\",\n    \"prefs_users_title\": \"Hallinnoi käyttäjiä\",\n    \"account_basics_tier_interval_yearly\": \"vuosittain\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} Laskutetaan kuukausittain.\",\n    \"action_bar_clear_notifications\": \"Poista kaikki ilmoitukset\",\n    \"account_delete_dialog_button_submit\": \"Poista tili pysyvästi\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Soitto\",\n    \"account_basics_password_title\": \"Salasana\",\n    \"account_basics_password_dialog_new_password_label\": \"Uusi salasana\",\n    \"nav_upgrade_banner_label\": \"Päivitä ntfy Prohon\",\n    \"account_tokens_dialog_expires_unchanged\": \"Jätä viimeinen käyttöpäivä ennalleen\",\n    \"publish_dialog_delay_label\": \"Viive\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopioi pinon jälki\",\n    \"publish_dialog_button_send\": \"Lähetä\",\n    \"action_bar_reservation_delete\": \"Poista varaus\",\n    \"publish_dialog_button_cancel_sending\": \"Peruuta lähetys\",\n    \"account_tokens_dialog_title_delete\": \"Poista käyttöoikeustunnus\",\n    \"account_usage_of_limit\": \"limiitistä {{limit}}\",\n    \"publish_dialog_attach_placeholder\": \"Liitä tiedosto URL-osoitteen mukaan, esim. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_email_placeholder\": \"Osoite, johon ilmoitus välitetään, esim. urpo@example.com\",\n    \"notifications_attachment_link_expires\": \"linkki vanhenee {{date}}\",\n    \"action_bar_send_test_notification\": \"Lähetä testi-ilmoitus\",\n    \"reservation_delete_dialog_action_keep_title\": \"Säilytä välimuistissa olevat viestit ja liitteet\",\n    \"prefs_notifications_sound_no_sound\": \"Ei ääntä\",\n    \"account_upgrade_dialog_interval_yearly\": \"Vuosittain\",\n    \"publish_dialog_tags_label\": \"Tagit\",\n    \"signup_form_password\": \"Salasana\",\n    \"action_bar_reservation_limit_reached\": \"Raja saavutettu\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Kirjaudu nyt\",\n    \"publish_dialog_click_placeholder\": \"URL-osoite, joka avautuu, kun ilmoitusta napsautetaan\",\n    \"alert_not_supported_title\": \"Ilmoituksia ei tueta\",\n    \"account_tokens_dialog_button_cancel\": \"Peruuta\",\n    \"subscribe_dialog_error_user_anonymous\": \"Anonyymi\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} laskutetaan vuosittain. Säästä {{save}}.\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Korkea prioriteetti ja korkeammat\",\n    \"account_usage_basis_ip_description\": \"Tämän tilin käyttötilastot ja rajoitukset perustuvat IP-osoitteeseesi, joten ne voidaan jakaa muiden käyttäjien kanssa. Yllä esitetyt rajat ovat likimääräisiä perustuen olemassa oleviin rajoituksiin.\",\n    \"publish_dialog_priority_high\": \"Korkea prioriteetti\",\n    \"login_form_button_submit\": \"Kirjaudu\",\n    \"account_basics_password_dialog_title\": \"Vaihda salasana\",\n    \"priority_max\": \"max\",\n    \"notifications_attachment_file_image\": \"kuvatiedosto\",\n    \"account_usage_limits_reset_daily\": \"Käyttörajat nollataan päivittäin keskiyöllä (UTC)\",\n    \"account_usage_unlimited\": \"Rajoittamaton\",\n    \"prefs_users_delete_button\": \"Poista käyttäjä\",\n    \"publish_dialog_click_label\": \"Napsauta URL-osoitetta\",\n    \"prefs_notifications_min_priority_any\": \"Kaikki prioriteetit\",\n    \"account_tokens_dialog_expires_label\": \"Käyttöoikeustunnus vanhenee\",\n    \"publish_dialog_filename_label\": \"Tiedostonimi\",\n    \"publish_dialog_chip_attach_file_label\": \"Liitä paikallinen tiedosto\",\n    \"account_basics_phone_numbers_title\": \"Puhelinnumerot\",\n    \"prefs_notifications_delete_after_title\": \"Poista ilmoitukset\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"säästä {{discount}}%\",\n    \"signup_disabled\": \"Rekisteröityminen estetty\",\n    \"publish_dialog_drop_file_here\": \"Pudota tiedosto tähän\",\n    \"prefs_users_dialog_title_edit\": \"Muokkaa käyttäjää\",\n    \"account_basics_password_dialog_current_password_label\": \"Nykyinen salasana\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Matala prioriteetti ja korkeammat\",\n    \"action_bar_profile_title\": \"Profiili\",\n    \"account_tokens_dialog_button_update\": \"Päivitä tunnus\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} lopullinen tiedostokoko\",\n    \"publish_dialog_title_label\": \"Otsikko\",\n    \"prefs_reservations_table_everyone_write_only\": \"Minä voin julkaista ja tilata, kaikki voivat julkaista\",\n    \"prefs_appearance_title\": \"Ulkoasu\",\n    \"publish_dialog_topic_reset\": \"Resetoi topikki\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Nykyistä istuntotunnusta ei voi muokata tai poistaa\",\n    \"notifications_tags\": \"Tagit\",\n    \"prefs_notifications_sound_play\": \"Toista valittu ääni\",\n    \"account_tokens_table_last_access_header\": \"Viimeinen käynti\",\n    \"action_bar_profile_logout\": \"Kirjaudu ulos\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Liitetiedoston nimi\",\n    \"publish_dialog_priority_default\": \"Oletusprioriteetti\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Palvelimen URL\",\n    \"account_tokens_table_last_origin_tooltip\": \"Napsauta IP-osoitteesta {{ip}} etsiäksesi\",\n    \"account_usage_reservations_title\": \"Varatut topikit\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"Kuukausi\",\n    \"message_bar_show_dialog\": \"Näytä julkaisudialogi\",\n    \"publish_dialog_chip_attach_url_label\": \"Liitä tiedosto URL-osoitteen mukaan\",\n    \"account_usage_calls_none\": \"Tällä tilillä ei voi soittaa puheluita\",\n    \"notifications_click_open_button\": \"Avaa linkki\",\n    \"account_tokens_table_current_session\": \"Nykyinen selainistunto\",\n    \"account_upgrade_dialog_button_pay_now\": \"Maksa nyt ja tilaa\",\n    \"nav_upgrade_banner_description\": \"Varaa aiheita, lisää viestejä ja sähköposteja, sekä suurempia liitteitä\",\n    \"publish_dialog_call_reset\": \"Poista puhelu\",\n    \"publish_dialog_other_features\": \"Muut ominaisuudet:\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Käytä toista palvelinta\",\n    \"reservation_delete_dialog_action_delete_title\": \"Poista välimuistissa olevat viestit ja liitteet\",\n    \"signup_error_username_taken\": \"Käyttäjätunnus {{username}} on jo varattu\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Vahvistuskoodi\",\n    \"nav_button_subscribe\": \"Tilaa aihe\",\n    \"publish_dialog_topic_label\": \"Topikin nimi\",\n    \"reservation_delete_dialog_action_delete_description\": \"Välimuistissa olevat viestit ja liitteet poistetaan pysyvästi. Tätä toimintoa ei voi kumota.\",\n    \"alert_grant_button\": \"Myönnä nyt\",\n    \"account_basics_tier_paid_until\": \"Tilaus maksettu {{date}} asti, ja se uusitaan automaattisesti\",\n    \"account_usage_attachment_storage_description\": \"{{tiedostokoko}} per tiedosto, poistettu {{expiry}} jälkeen\",\n    \"publish_dialog_chip_click_label\": \"Napsauta URL-osoitetta\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Ilmoitukset poistetaan automaattisesti kuukauden kuluttua\",\n    \"common_cancel\": \"Peruuta\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Soita minulle\",\n    \"signup_already_have_account\": \"Onko sinulla jo tili? Kirjaudu sisään!\",\n    \"publish_dialog_call_item\": \"Soita puhelinnumeroon {{number}}\",\n    \"nav_button_account\": \"Tili\",\n    \"publish_dialog_click_reset\": \"Poista napsautettava URL-osoite\",\n    \"login_title\": \"Kirjaudu sisään ntfy-tilillesi\",\n    \"notifications_list\": \"Ilmoitusluettelo\",\n    \"common_save\": \"Tallenna\",\n    \"prefs_users_dialog_base_url_label\": \"Palvelun URL, esim. https://ntfy.sh\",\n    \"account_usage_emails_title\": \"Sähköpostit lähetetty\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"action_bar_reservation_add\": \"Varalla oleva aihe\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Valittu\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Päivitä tilaus\",\n    \"notifications_attachment_file_video\": \"videotiedosto\",\n    \"priority_high\": \"korkea\",\n    \"notifications_priority_x\": \"Prioriteetti {{priority}}\",\n    \"account_delete_dialog_billing_warning\": \"Tilin poistaminen peruuttaa myös laskutustilauksesi välittömästi. Et voi enää käyttää laskutuksen hallintapaneelia.\",\n    \"prefs_notifications_min_priority_description_max\": \"Näytä ilmoitukset, jos prioriteetti on 5 (max)\",\n    \"subscribe_dialog_login_description\": \"Tämä topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Valittu taso sallii vähemmän varattuja topikkeja kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään {{count}} varausta</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.\",\n    \"prefs_users_dialog_title_add\": \"Lisää käyttäjä\",\n    \"account_tokens_dialog_button_create\": \"Luo tunnus\",\n    \"nav_button_settings\": \"Asetukset\",\n    \"publish_dialog_priority_min\": \"Min. etusijalla\",\n    \"account_tokens_table_create_token_button\": \"Luo käyttöoikeustunnus\",\n    \"notifications_delete\": \"Poista\",\n    \"notifications_actions_not_supported\": \"Toimintoa ei tueta verkkosovelluksessa\",\n    \"notifications_actions_open_url_title\": \"Siirry osoitteeseen {{url}}\",\n    \"notifications_none_for_any_title\": \"Et ole saanut ilmoituksia.\",\n    \"notifications_none_for_topic_description\": \"Jos haluat lähettää ilmoituksia tähän topikkiin, lähetä PUT tai POST topikin URL-osoitteeseen.\",\n    \"notifications_none_for_any_description\": \"Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.\",\n    \"notifications_no_subscriptions_title\": \"Näyttää siltä, että sinulla ei ole vielä tilauksia.\",\n    \"notifications_none_for_topic_title\": \"Et ole vielä saanut ilmoituksia tästä aiheesta.\",\n    \"notifications_actions_http_request_title\": \"Lähetä HTTP {{method}} osoitteeseen {{url}}\",\n    \"reserve_dialog_checkbox_label\": \"Varaa aihe ja aseta pääsy\",\n    \"publish_dialog_progress_uploading\": \"Lähetetään …\",\n    \"publish_dialog_title_no_topic\": \"Julkaise ilmoitus\",\n    \"notifications_example\": \"Esimerkki\",\n    \"notifications_loading\": \"Ladataan ilmoituksia…\",\n    \"notifications_no_subscriptions_description\": \"Klikkaa \\\"{{linktext}}\\\" linkkiä luodaksesi tai tilataksesi aihe. Sen jälkeen voit lähettää viestejä PUT tai POST metodeilla ja saat ilmoituksesi täällä.\",\n    \"display_name_dialog_description\": \"Aseta vaihtoehtoinen nimi aiheelle, joka on näytetty tilaus-listassa. Tämä auttaa tunnistamaan aiheet helpommin, joilla on hankalat nimet.\",\n    \"publish_dialog_message_published\": \"Ilmoitus julkaistu\",\n    \"notifications_more_details\": \"Saadaksesi lisää tietoa, katso <websiteLink>verkkosivusto</websiteLink> tai <docsLink>dokumentaatio</docsLink>.\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"ylittää kiintiön, {{remainingBytes}} jäljellä\",\n    \"publish_dialog_title_topic\": \"Julkaise aiheeseen {{topic}}\",\n    \"display_name_dialog_placeholder\": \"Näyttönimi\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"ylittää {{fileSizeLimit}} tiedostokoon rajan ja määrän, {{remainingBytes}} jäljellä\",\n    \"publish_dialog_attachment_limits_file_reached\": \"ylittää {{fileSizeLimit}} tiedostokoon rajan\",\n    \"publish_dialog_progress_uploading_detail\": \"Lähetetään {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"display_name_dialog_title\": \"Vaihda näyttönimi\",\n    \"action_bar_mute_notifications\": \"Mykistä ilmoitukset\",\n    \"action_bar_unmute_notifications\": \"Poista ilmoitusten mykistys\",\n    \"alert_notification_permission_required_title\": \"Ilmoitukset eivät ole käytössä\",\n    \"alert_notification_permission_required_description\": \"Anna selaimelle lupa näyttää työpöytäilmoituksia\",\n    \"alert_notification_permission_required_button\": \"Myönnä lupa nyt\",\n    \"alert_notification_permission_denied_title\": \"Ilmoitukset on estetty\",\n    \"alert_notification_ios_install_required_title\": \"iOS-asennus vaaditaan\",\n    \"publish_dialog_checkbox_markdown\": \"Muotoile Markdownina\",\n    \"prefs_notifications_web_push_title\": \"Taustailmoitukset\",\n    \"prefs_appearance_theme_system\": \"Järjestelmä (oletus)\",\n    \"alert_notification_permission_denied_description\": \"Ota ilmoitukset uudelleen käyttöön selaimessa\",\n    \"prefs_appearance_theme_title\": \"Teema\",\n    \"prefs_appearance_theme_light\": \"Vaalea tila\",\n    \"prefs_notifications_web_push_enabled\": \"Käytössä palvelimelle {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Pois käytöstä\",\n    \"prefs_appearance_theme_dark\": \"Tumma tila\",\n    \"error_boundary_button_reload_ntfy\": \"Lataa ntfy uudelleen\",\n    \"web_push_subscription_expiring_title\": \"Ilmoitukset keskeytetään\",\n    \"web_push_subscription_expiring_body\": \"Avaa ntfy jatkaaksesi ilmoitusten vastaanottamista\",\n    \"web_push_unknown_notification_title\": \"Tuntematon ilmoitus vastaanotettu palvelimelta\",\n    \"alert_notification_ios_install_required_description\": \"Napauta Jaa-kuvaketta ja Lisää aloitusnäyttöön ottaaksesi ilmoitukset käyttöön iOS:ssä\",\n    \"prefs_notifications_web_push_disabled_description\": \"Ilmoituksia vastaanotetaan, kun verkkosovellus on käynnissä (WebSocket:in kautta)\",\n    \"web_push_unknown_notification_body\": \"Voit joutua päivittämään ntfy:n avaamalla verkkosovelluksen\",\n    \"notifications_actions_failed_notification\": \"Epäonnistunut toiminto\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Ilmoituksia muilta palvelimilta ei vastaanoteta, mikäli verkkosovellus ei ole avoinna\",\n    \"prefs_notifications_web_push_enabled_description\": \"Ilmoituksia vastaanotetaan siitä huolimatta, että verkkosovellus ei ole käynnissä (Web Push:n kautta)\"\n}\n"
  },
  {
    "path": "web/public/static/langs/fr.json",
    "content": "{\n    \"nav_topics_title\": \"Sujets souscrits\",\n    \"action_bar_settings\": \"Paramètres\",\n    \"action_bar_send_test_notification\": \"Envoyer une notification de test\",\n    \"action_bar_clear_notifications\": \"Effacer toutes les notifications\",\n    \"action_bar_unsubscribe\": \"Se désabonner\",\n    \"message_bar_type_message\": \"Tapez un message ici\",\n    \"notifications_attachment_open_button\": \"Ouvrir la pièce jointe\",\n    \"notifications_attachment_link_expires\": \"le lien expire {{date}}\",\n    \"message_bar_error_publishing\": \"Erreur lors de la publication de la notification\",\n    \"nav_button_all_notifications\": \"Toutes les notifications\",\n    \"nav_button_settings\": \"Paramètres\",\n    \"nav_button_documentation\": \"Documentation\",\n    \"alert_not_supported_description\": \"Les notifications ne sont pas prises en charge par votre navigateur\",\n    \"notifications_attachment_copy_url_title\": \"Copier l'URL de la pièce jointe dans le presse-papiers\",\n    \"notifications_attachment_open_title\": \"Aller à {{url}}\",\n    \"notifications_attachment_link_expired\": \"lien de téléchargement expiré\",\n    \"nav_button_publish_message\": \"Publier une notification\",\n    \"notifications_copied_to_clipboard\": \"Copié dans le presse-papiers\",\n    \"alert_not_supported_title\": \"Notifications non prises en charge\",\n    \"notifications_tags\": \"Étiquettes\",\n    \"notifications_attachment_copy_url_button\": \"Copier l'URL\",\n    \"notifications_click_copy_url_title\": \"Copier l'URL du lien dans le presse-papiers\",\n    \"notifications_click_copy_url_button\": \"Copier le lien\",\n    \"notifications_click_open_button\": \"Ouvrir le lien\",\n    \"notifications_none_for_topic_title\": \"Vous n'avez pas encore reçu de notifications pour ce sujet.\",\n    \"notifications_actions_open_url_title\": \"Aller à {{url}}\",\n    \"notifications_example\": \"Exemple\",\n    \"notifications_loading\": \"Chargement des notifications…\",\n    \"publish_dialog_progress_uploading\": \"Téléversement…\",\n    \"publish_dialog_priority_min\": \"Priorité minimum\",\n    \"publish_dialog_priority_low\": \"Basse priorité\",\n    \"publish_dialog_priority_default\": \"Priorité par défaut\",\n    \"publish_dialog_base_url_label\": \"URL du service\",\n    \"publish_dialog_base_url_placeholder\": \"URL du service, par ex. https://exemple.com\",\n    \"publish_dialog_title_label\": \"Titre\",\n    \"publish_dialog_message_label\": \"Message\",\n    \"publish_dialog_topic_label\": \"Nom du sujet\",\n    \"publish_dialog_message_placeholder\": \"Tapez un message ici\",\n    \"publish_dialog_tags_label\": \"Étiquettes\",\n    \"publish_dialog_email_label\": \"Courriel\",\n    \"publish_dialog_email_placeholder\": \"Adresse à laquelle transmettre la notification, par exemple phil@exemple.com\",\n    \"publish_dialog_chip_email_label\": \"Transférer vers le courriel\",\n    \"notifications_no_subscriptions_title\": \"Il semble que vous n’ayez pas encore d’abonnements.\",\n    \"publish_dialog_progress_uploading_detail\": \"Téléversement {{loaded}}/{{total}} ({{percent}} %) …\",\n    \"publish_dialog_message_published\": \"Notification publiée\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"dépasse la limite et le quota du fichier {{fileSizeLimit}}, {{remainingBytes}} restant\",\n    \"publish_dialog_priority_high\": \"Haute priorité\",\n    \"publish_dialog_priority_max\": \"Priorité maximum\",\n    \"publish_dialog_attachment_limits_file_reached\": \"Dépasse la limite du fichier {{fileSizeLimit}}\",\n    \"nav_button_subscribe\": \"S'abonner au sujet\",\n    \"notifications_no_subscriptions_description\": \"Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.\",\n    \"alert_notification_permission_required_title\": \"Les notifications sont désactivées\",\n    \"alert_notification_permission_required_description\": \"Autorisez votre navigateur à afficher les notifications du bureau\",\n    \"alert_notification_permission_required_button\": \"Accorder maintenant\",\n    \"notifications_none_for_any_title\": \"Vous n'avez reçu aucune notification.\",\n    \"publish_dialog_title_topic\": \"Publier vers {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publier la notification\",\n    \"notifications_more_details\": \"Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.\",\n    \"publish_dialog_title_placeholder\": \"Titre de la notification, par ex. Alerte d'espace disque\",\n    \"publish_dialog_topic_placeholder\": \"Nom du sujet, par ex. phil_alerts\",\n    \"publish_dialog_delay_placeholder\": \"Délai de réception, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)\",\n    \"publish_dialog_other_features\": \"Autres fonctionnalités :\",\n    \"notifications_actions_not_supported\": \"Cette action n'est pas supportée dans l'application web\",\n    \"notifications_actions_http_request_title\": \"Envoyer une requête HTTP {{method}} à {{url}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"quota dépassé, {{remainingBytes}} restants\",\n    \"publish_dialog_tags_placeholder\": \"Liste d'étiquettes séparée par des virgules, par ex. avertissement, backup-srv1\",\n    \"publish_dialog_priority_label\": \"Priorité\",\n    \"publish_dialog_click_label\": \"URL du clic\",\n    \"publish_dialog_click_placeholder\": \"URL ouverte lors d'un clic sur la notification\",\n    \"publish_dialog_attach_label\": \"URL de la pièce jointe\",\n    \"publish_dialog_attach_placeholder\": \"Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_label\": \"Nom du fichier\",\n    \"notifications_none_for_topic_description\": \"Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l'URL du sujet.\",\n    \"notifications_none_for_any_description\": \"Pour envoyer des notifications à un sujet, faites simplement une requête PUT ou POST à l'URL du sujet. Voici un exemple utilisant un de vos sujets.\",\n    \"publish_dialog_filename_placeholder\": \"Nom du fichier joint\",\n    \"publish_dialog_delay_label\": \"Délai\",\n    \"publish_dialog_chip_click_label\": \"Cliquez sur l'URL\",\n    \"subscribe_dialog_subscribe_title\": \"S'abonner au sujet\",\n    \"subscribe_dialog_login_title\": \"Connexion nécessaire\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Priorité basse et au-dessus\",\n    \"common_cancel\": \"Annuler\",\n    \"error_boundary_button_copy_stack_trace\": \"Copier la trace d'appels\",\n    \"publish_dialog_attached_file_title\": \"Fichier joint :\",\n    \"publish_dialog_checkbox_publish_another\": \"Publier un autre\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nom du fichier joint\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Utiliser un autre serveur\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Annuler\",\n    \"prefs_notifications_sound_description_none\": \"Les notifications ne font aucun son quand elles arrivent\",\n    \"prefs_notifications_sound_description_some\": \"Les notifications jouent le son {{sound}} quand elles arrivent\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Montrer les notifications si leur priorité est {{number}} ({{name}}) ou plus\",\n    \"publish_dialog_button_cancel\": \"Annuler\",\n    \"publish_dialog_button_send\": \"Envoyer\",\n    \"publish_dialog_drop_file_here\": \"Déposez un fichier ici\",\n    \"emoji_picker_search_placeholder\": \"Chercher un émoji\",\n    \"subscribe_dialog_subscribe_description\": \"Le sujet n'est peut-être pas protégé par un mot de passe, choisissez un nom de sujet difficile à deviner. Une fois abonné, vous pouvez PUT/POST des notifications.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nom de sujet, par ex. alertes_de_phil\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"S'abonner\",\n    \"subscribe_dialog_login_description\": \"Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.\",\n    \"subscribe_dialog_login_username_label\": \"Nom d'utilisateur, par ex. phil\",\n    \"subscribe_dialog_login_button_login\": \"Se connecter\",\n    \"prefs_notifications_sound_title\": \"Son de notification\",\n    \"prefs_notifications_delete_after_never\": \"Jamais\",\n    \"prefs_users_table_base_url_header\": \"URL de service\",\n    \"subscribe_dialog_login_password_label\": \"Mot de passe\",\n    \"prefs_notifications_title\": \"Notifications\",\n    \"prefs_notifications_delete_after_title\": \"Supprimer les notifications\",\n    \"prefs_users_add_button\": \"Ajouter un utilisateur\",\n    \"common_back\": \"Retour\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonyme\",\n    \"prefs_notifications_sound_no_sound\": \"Aucun son\",\n    \"prefs_notifications_min_priority_title\": \"Priorité minimum\",\n    \"prefs_notifications_min_priority_description_any\": \"Montrer toutes les notifications, quelque soit leur priorité\",\n    \"prefs_notifications_min_priority_description_max\": \"Montrer les notifications si la priorité est 5 (max)\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Priorité par défaut et au-dessus\",\n    \"prefs_notifications_min_priority_max_only\": \"Seulement la priorité maximale\",\n    \"prefs_notifications_delete_after_three_hours\": \"Après trois heures\",\n    \"prefs_notifications_delete_after_one_day\": \"Après un jour\",\n    \"subscribe_dialog_error_user_not_authorized\": \"L'utilisateur {{username}} n'est pas autorisé\",\n    \"prefs_notifications_min_priority_any\": \"N'importe quelle priorité\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Priorité haute et au-dessus\",\n    \"prefs_users_dialog_base_url_label\": \"URL du service, par ex. https://ntfy.sh\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Les notifications sont supprimées automatiquement après une semaine\",\n    \"prefs_users_dialog_username_label\": \"Nom d'utilisateur, par ex. phil\",\n    \"prefs_users_dialog_password_label\": \"Mot de passe\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Les notifications sont supprimées automatiquement après un mois\",\n    \"prefs_users_title\": \"Gérer les utilisateurs\",\n    \"prefs_users_description\": \"Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.\",\n    \"prefs_users_table_user_header\": \"Utilisateur\",\n    \"prefs_users_dialog_title_edit\": \"Éditer l'utilisateur\",\n    \"common_add\": \"Ajouter\",\n    \"error_boundary_description\": \"Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"prefs_users_dialog_title_add\": \"Ajouter un utilisateur\",\n    \"error_boundary_stack_trace\": \"Trace de pile d'appels\",\n    \"error_boundary_gathering_info\": \"Récupérer plus d'information…\",\n    \"prefs_notifications_delete_after_one_week\": \"Après une semaine\",\n    \"prefs_notifications_delete_after_one_month\": \"Après un mois\",\n    \"prefs_notifications_delete_after_never_description\": \"Les notifications ne sont jamais supprimées automatiquement\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Les notifications sont supprimées automatiquement après trois heures\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Les notifications sont supprimées automatiquement après un jour\",\n    \"prefs_appearance_title\": \"Apparence\",\n    \"prefs_appearance_language_title\": \"Langue\",\n    \"priority_min\": \"min\",\n    \"priority_low\": \"basse\",\n    \"priority_default\": \"défault\",\n    \"priority_high\": \"haute\",\n    \"priority_max\": \"max\",\n    \"error_boundary_title\": \"Oh non, ntfy a planté\",\n    \"publish_dialog_chip_attach_url_label\": \"Joindre un fichier par URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Joindre un fichier local\",\n    \"publish_dialog_chip_delay_label\": \"Délayer l'envoi\",\n    \"publish_dialog_chip_topic_label\": \"Changer de sujet\",\n    \"publish_dialog_details_examples_description\": \"Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Annuler l'envoi\",\n    \"common_save\": \"Enregistrer\",\n    \"notifications_new_indicator\": \"Nouvelle notification\",\n    \"publish_dialog_delay_reset\": \"Retirer le délai de réception\",\n    \"notifications_list_item\": \"Notification\",\n    \"notifications_priority_x\": \"Priorité {{priority}}\",\n    \"notifications_mark_read\": \"Marquer comme lu\",\n    \"notifications_attachment_image\": \"Image jointe\",\n    \"notifications_delete\": \"Supprimer\",\n    \"notifications_attachment_file_video\": \"fichier vidéo\",\n    \"notifications_attachment_file_audio\": \"fichier audio\",\n    \"prefs_users_table\": \"Liste des utilisateurs\",\n    \"notifications_attachment_file_image\": \"fichier image\",\n    \"notifications_attachment_file_app\": \"fichier d'application Android\",\n    \"notifications_attachment_file_document\": \"autre document\",\n    \"prefs_notifications_sound_play\": \"Jouer le son sélectionné\",\n    \"error_boundary_unsupported_indexeddb_description\": \"L'application web ntfy a besoin d'IndexedDB pour fonctionner, mais votre navigateur ne supporte pas IndexedDB en navigation privée.<br/><br/>Bien que cela soit regrettable, il serait peu utile d'utiliser l'application web ntfy en navigation privée, car tout est stocké par votre navigateur. Vous pouvez vous renseigner plus amplement à ce propos <githubLink>dans ce ticket GitHub</githubLink>, ou en parler avec nous sur <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_show_menu\": \"Montrer le menu\",\n    \"action_bar_toggle_mute\": \"Mettre en sourdine/réactiver les notifications\",\n    \"action_bar_toggle_action_menu\": \"Ouvrir/fermer le menu d'actions\",\n    \"publish_dialog_emoji_picker_show\": \"Choisir un emoji\",\n    \"publish_dialog_topic_reset\": \"Réinitialiser le sujet\",\n    \"message_bar_publish\": \"Publier le message\",\n    \"nav_button_muted\": \"Notifications en sourdine\",\n    \"nav_button_connecting\": \"connexion en cours\",\n    \"notifications_list\": \"Liste des notifications\",\n    \"message_bar_show_dialog\": \"Montrer le formulaire de publication\",\n    \"action_bar_logo_alt\": \"Logo de ntfy\",\n    \"publish_dialog_click_reset\": \"Retirer l'URL du clic\",\n    \"publish_dialog_email_reset\": \"Retirer le transfert par courriel\",\n    \"publish_dialog_attach_reset\": \"Retirer l'URL de la pièce jointe\",\n    \"emoji_picker_search_clear\": \"Effacer la recherche\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL du service\",\n    \"prefs_users_edit_button\": \"Éditer l'utilisateur\",\n    \"prefs_users_delete_button\": \"Supprimer l'utilisateur\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Navigation privée non prise en charge\",\n    \"publish_dialog_attached_file_remove\": \"Retirer le fichier joint\",\n    \"signup_form_password\": \"Mot de passe\",\n    \"signup_form_confirm_password\": \"Confirmation du mot de passe\",\n    \"signup_disabled\": \"L'inscription est désactivée\",\n    \"signup_error_username_taken\": \"L'identifiant {{username}} est déjà utilisé\",\n    \"signup_error_creation_limit_reached\": \"Limite de création de comptes atteinte\",\n    \"login_title\": \"Se connecter à son compte Ntfy\",\n    \"login_form_button_submit\": \"Se connecter\",\n    \"login_link_signup\": \"S'inscrire\",\n    \"login_disabled\": \"La connection est désactivée\",\n    \"action_bar_account\": \"Compte\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Paramètres\",\n    \"action_bar_sign_in\": \"Se connecter\",\n    \"action_bar_sign_up\": \"Inscription\",\n    \"nav_button_account\": \"Compte\",\n    \"signup_title\": \"Créer un compte Ntfy\",\n    \"signup_form_username\": \"Identifiant\",\n    \"signup_form_button_submit\": \"S'inscrire\",\n    \"signup_already_have_account\": \"Vous avez déjà un compte ? Connectez-vous !\",\n    \"action_bar_profile_logout\": \"Se déconnecter\",\n    \"signup_form_toggle_password_visibility\": \"Afficher le mot de passe\",\n    \"action_bar_change_display_name\": \"Changer le nom affiché\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Cliquer pour s'abonner\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Impossible d'éditer ou de supprimer le jeton de la session actuelle\",\n    \"account_tokens_dialog_button_cancel\": \"Annuler\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Impossible de supprimer ou de modifier un utilisateur connecté\",\n    \"prefs_users_description_no_sync\": \"Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.\",\n    \"account_tokens_dialog_button_update\": \"Mettre à jour un jeton\",\n    \"nav_upgrade_banner_description\": \"Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges\",\n    \"display_name_dialog_description\": \"Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.\",\n    \"account_usage_basis_ip_description\": \"Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.\",\n    \"action_bar_reservation_add\": \"Réserver un sujet\",\n    \"action_bar_reservation_edit\": \"Changer la réservation\",\n    \"action_bar_reservation_delete\": \"Supprimer la réservation\",\n    \"action_bar_reservation_limit_reached\": \"Limite atteinte\",\n    \"nav_upgrade_banner_label\": \"Passer à ntfy Pro\",\n    \"display_name_dialog_title\": \"Changer le nom affiché\",\n    \"reserve_dialog_checkbox_label\": \"Réserver un sujet et en configurer l'accès\",\n    \"display_name_dialog_placeholder\": \"Nom affiché\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Générer un nom\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Sujet déjà réservé\",\n    \"account_basics_title\": \"Compte\",\n    \"account_basics_username_title\": \"Nom d'utilisateur\",\n    \"account_basics_username_description\": \"Hé, c'est toi ❤\",\n    \"account_basics_username_admin_tooltip\": \"Vous êtes Administrateur\",\n    \"account_basics_password_title\": \"Mot de passe\",\n    \"account_basics_password_description\": \"Changer le mot de passe de votre compte\",\n    \"account_basics_password_dialog_title\": \"Changer le mot de passe\",\n    \"account_basics_password_dialog_current_password_label\": \"Mot de passe actuel\",\n    \"account_basics_password_dialog_new_password_label\": \"Nouveau mot de passe\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmer le mot de passe\",\n    \"account_basics_password_dialog_button_submit\": \"Changer le mot de passe\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Mot de passe incorrect\",\n    \"account_usage_title\": \"Utilisation\",\n    \"account_usage_of_limit\": \"sur {{limit}}\",\n    \"account_usage_unlimited\": \"Illimité\",\n    \"account_usage_limits_reset_daily\": \"Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)\",\n    \"account_basics_tier_title\": \"Type de compte\",\n    \"account_basics_tier_description\": \"Le niveau de puissance de votre compte\",\n    \"account_basics_tier_admin\": \"Administrateur\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(avec le tarif {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(pas de tarif)\",\n    \"account_basics_tier_free\": \"Gratuit\",\n    \"account_basics_tier_upgrade_button\": \"Passer à Pro\",\n    \"account_basics_tier_change_button\": \"Changer\",\n    \"account_basics_tier_paid_until\": \"Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé\",\n    \"account_basics_tier_canceled_subscription\": \"Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Gérer la facturation\",\n    \"account_usage_messages_title\": \"Messages publiés\",\n    \"account_usage_emails_title\": \"Emails envoyés\",\n    \"account_usage_reservations_title\": \"Sujets réservés\",\n    \"account_usage_reservations_none\": \"Pas de sujet réservé pour ce compte\",\n    \"account_usage_attachment_storage_title\": \"Stockage des pièces jointes\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} par fichier, supprimé après {{expiry}}\",\n    \"account_usage_cannot_create_portal_session\": \"Impossible d'ouvrir le portail de facturation\",\n    \"account_delete_title\": \"Supprimer le compte\",\n    \"account_delete_description\": \"Supprimer définitivement votre compte\",\n    \"account_basics_tier_basic\": \"Basique\",\n    \"account_delete_dialog_description\": \"Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.\",\n    \"account_delete_dialog_label\": \"Mot de passe\",\n    \"account_delete_dialog_button_cancel\": \"Annuler\",\n    \"account_delete_dialog_button_submit\": \"Supprimer définitivement le compte\",\n    \"account_delete_dialog_billing_warning\": \"Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.\",\n    \"account_upgrade_dialog_title\": \"Changer le tarif du compte\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Proratisation</strong> : Lors d'un changement vers le haut entre plans payants, la différence de prix sera <strong>facturée immédiatement</strong>. En cas de diminutions vers un plan plus économique, la balance sera utilisée pour le paiement des factures suivantes.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Paramètres</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} sujets réservés\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} messages journaliers\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} emails journaliers\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} par fichier\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} stockage total\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Sélectionné\",\n    \"account_upgrade_dialog_tier_current_label\": \"Actuel\",\n    \"account_upgrade_dialog_button_cancel\": \"Annuler\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"S'inscrire maintenant\",\n    \"account_upgrade_dialog_button_pay_now\": \"Payer maintenant et s'abonner\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Annuler l'abonnement\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Mettre à jour l'abonnement\",\n    \"account_tokens_title\": \"Jetons d'accès\",\n    \"account_tokens_table_token_header\": \"Jeton\",\n    \"account_tokens_table_label_header\": \"Étiquette\",\n    \"account_tokens_table_last_access_header\": \"Dernier accès\",\n    \"account_tokens_table_expires_header\": \"Expire\",\n    \"account_tokens_table_never_expires\": \"N'expire jamais\",\n    \"account_tokens_table_current_session\": \"Session de navigation actuelle\",\n    \"common_copy_to_clipboard\": \"Copier dans le presse-papier\",\n    \"account_tokens_table_copied_to_clipboard\": \"Jeton d'accès copié\",\n    \"account_tokens_table_create_token_button\": \"Créer un jeton d'accès\",\n    \"account_tokens_table_last_origin_tooltip\": \"Depuis l'adresse IP {{ip}}, cliquer pour rechercher\",\n    \"account_tokens_dialog_title_create\": \"Créer un jeton d'accès\",\n    \"account_tokens_dialog_title_edit\": \"Modifier le jeton d'accès\",\n    \"account_tokens_dialog_title_delete\": \"Supprimer le jeton d'accès\",\n    \"account_tokens_dialog_label\": \"Étiquette, par ex. Notifications Radarr\",\n    \"account_tokens_dialog_button_create\": \"Créer un jeton\",\n    \"account_tokens_dialog_expires_label\": \"Le jeton d'accès expire dans\",\n    \"account_tokens_dialog_expires_unchanged\": \"Laisser la date d'expiration inchangée\",\n    \"account_tokens_dialog_expires_x_hours\": \"Le jeton expire dans {{hours}} heures\",\n    \"account_tokens_dialog_expires_x_days\": \"Le jeton expire dans {{days}} jours\",\n    \"account_tokens_dialog_expires_never\": \"Le jeton n'expire jamais\",\n    \"account_tokens_delete_dialog_title\": \"Supprimer le jeton d'accès\",\n    \"account_tokens_delete_dialog_submit_button\": \"Supprimer définitivement le jeton\",\n    \"prefs_reservations_title\": \"Sujets réservés\",\n    \"prefs_reservations_limit_reached\": \"Vous avez atteint votre limite de réservation de sujets.\",\n    \"prefs_reservations_add_button\": \"Ajouter un sujet réservé\",\n    \"prefs_reservations_edit_button\": \"Modifier l'accès d'un sujet\",\n    \"prefs_reservations_delete_button\": \"Réinitialiser l'accès d'un sujet\",\n    \"prefs_reservations_table\": \"Tableau des sujets réservés\",\n    \"prefs_reservations_table_topic_header\": \"Sujet\",\n    \"prefs_reservations_table_access_header\": \"Accès\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Seulement moi peut publier et m'abonner\",\n    \"prefs_reservations_table_everyone_read_only\": \"Je peux publier et m'abonner, tout le monde peut s'abonner\",\n    \"prefs_reservations_table_everyone_write_only\": \"Je peux publier et m'abonner, tout le monde peut publier\",\n    \"prefs_reservations_table_everyone_read_write\": \"Tout le monde peut publier et s'abonner\",\n    \"prefs_reservations_table_not_subscribed\": \"Pas abonné\",\n    \"prefs_reservations_dialog_title_add\": \"Réserver un sujet\",\n    \"prefs_reservations_dialog_title_edit\": \"Modifier un sujet réservé\",\n    \"prefs_reservations_dialog_title_delete\": \"Supprimé un sujet réservé\",\n    \"prefs_reservations_dialog_description\": \"Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.\",\n    \"prefs_reservations_dialog_topic_label\": \"Sujet\",\n    \"prefs_reservations_dialog_access_label\": \"Accès\",\n    \"reservation_delete_dialog_description\": \"Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Garder les messages et pièces jointes mises en cache\",\n    \"reservation_delete_dialog_action_keep_description\": \"Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Supprimer les messages et pièces jointes mises en cache\",\n    \"reservation_delete_dialog_action_delete_description\": \"Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.\",\n    \"reservation_delete_dialog_submit_button\": \"Supprimer un sujet réservé\",\n    \"alert_not_supported_context_description\": \"Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la <mdnLink>Notifications API</mdnLink>.\",\n    \"account_basics_tier_payment_overdue\": \"Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.\",\n    \"account_upgrade_dialog_cancel_warning\": \"Cela va <strong>annuler votre abonnement</strong> et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur <strong>seront supprimés</strong>.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Paramètres</Link>.\",\n    \"account_tokens_description\": \"Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la <Link>documentation</Link> pour en savoir plus.\",\n    \"account_tokens_delete_dialog_description\": \"Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. <strong>Cette action ne peut pas être annulée</strong>.\",\n    \"prefs_reservations_description\": \"Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.\",\n    \"account_basics_tier_interval_yearly\": \"annuel\",\n    \"account_upgrade_dialog_interval_yearly\": \"Annuel\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"économisez {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Aucun sujet(s) réservé(s)\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} par an. Prélevé mensuellement.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Pour des questions en rapport avec la facturation, se référer à notre <Link>site internet</Link>.\",\n    \"account_basics_tier_interval_monthly\": \"mensuel\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensuel\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"économisez jusqu'à {{discount}}%\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mois\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} prélevé annuellement. Économisez {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement.\",\n    \"publish_dialog_call_label\": \"Appel téléphonique\",\n    \"account_basics_phone_numbers_title\": \"Numéros de téléphone\",\n    \"account_basics_phone_numbers_dialog_description\": \"Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.\",\n    \"account_basics_phone_numbers_description\": \"Pour des notifications par appel téléphoniques\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Pas encore de numéros de téléphone\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Numéro de téléphone copié dans le presse-papier\",\n    \"account_basics_phone_numbers_dialog_title\": \"Ajouter un numéro de téléphone\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Numéro de téléphone\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"Ex : +33701020304\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Envoyer un SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Appelez moi\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Code de vérification\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"Ex : 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Code de confirmarion\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Appeler\",\n    \"account_usage_calls_none\": \"Aucun appels téléphoniques ne peut être fait avec ce compte\",\n    \"publish_dialog_call_reset\": \"Supprimer les appels téléphoniques\",\n    \"publish_dialog_chip_call_label\": \"Appel téléphonique\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} message journalier\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} mail journalier\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} appels journaliers\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Aucun appel\",\n    \"publish_dialog_call_item\": \"Appeler le numéro {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Aucun numéro de téléphone vérifié\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} sujet réservé\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} appels journaliers\",\n    \"account_usage_calls_title\": \"Appels téléphoniques passés\",\n    \"action_bar_mute_notifications\": \"Désactiver les notifications\",\n    \"action_bar_unmute_notifications\": \"Réactiver les notifications\",\n    \"alert_notification_permission_denied_title\": \"Les notifications sont bloquées\",\n    \"alert_notification_permission_denied_description\": \"Veuillez les réactiver dans votre navigateur\",\n    \"alert_notification_ios_install_required_description\": \"Cliquez sur l'icône Partager, puis Sur l'écran d'accueil pour activer les notifications sur iOS\",\n    \"alert_notification_ios_install_required_title\": \"Installation iOS nécessaire\",\n    \"notifications_actions_failed_notification\": \"Échec de l'action\",\n    \"publish_dialog_checkbox_markdown\": \"Formater en Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Les notifications provenant d'autres serveurs ne seront pas reçues tant que l'application web n'est pas ouverte\",\n    \"prefs_notifications_web_push_title\": \"Notifications en arrière-plan\",\n    \"prefs_notifications_web_push_enabled_description\": \"Les notifications sont reçues même quand l'application web n'est pas en cours d'exécution (via Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Les notifications sont reçues quand l'application web est en cours d'exécution (via WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Activé pour {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Désactivé\",\n    \"prefs_appearance_theme_title\": \"Thème\",\n    \"prefs_appearance_theme_system\": \"Système (défaut)\",\n    \"prefs_appearance_theme_dark\": \"Mode sombre\",\n    \"prefs_appearance_theme_light\": \"Mode clair\",\n    \"error_boundary_button_reload_ntfy\": \"Recharger ntfy\",\n    \"web_push_subscription_expiring_title\": \"Les notifications seront suspendues\",\n    \"web_push_subscription_expiring_body\": \"Ouvrez ntfy pour continuer à recevoir les notifications\",\n    \"web_push_unknown_notification_title\": \"Notification inconnue reçue du serveur\",\n    \"web_push_unknown_notification_body\": \"Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Un utilisateur provisionné ne peut pas être modifié ou supprimé\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Impossible de modifier ou de supprimer le jeton provisionné\"\n}\n"
  },
  {
    "path": "web/public/static/langs/gl.json",
    "content": "{\n    \"common_cancel\": \"Cancelar\",\n    \"common_save\": \"Gardar\",\n    \"common_add\": \"Engadir\",\n    \"signup_disabled\": \"O rexistro está desactivado\",\n    \"signup_error_username_taken\": \"O identificador {{username}} xa está collido\",\n    \"login_title\": \"Accede á túa conta ntfy\",\n    \"action_bar_send_test_notification\": \"Enviar notificación de proba\",\n    \"action_bar_clear_notifications\": \"Limpar todas as notificacións\",\n    \"action_bar_unsubscribe\": \"Retirar subscrición\",\n    \"action_bar_profile_settings\": \"Axustes\",\n    \"message_bar_type_message\": \"Escribe aquí a mensaxe\",\n    \"notifications_copied_to_clipboard\": \"Copiada ao portapapeis\",\n    \"notifications_attachment_image\": \"Imaxe anexa\",\n    \"notifications_attachment_copy_url_title\": \"Copiar URL do anexo ao portapapeis\",\n    \"notifications_attachment_copy_url_button\": \"Copiar URL\",\n    \"notifications_attachment_open_title\": \"Ir a {{url}}\",\n    \"notifications_attachment_file_audio\": \"ficheiro de audio\",\n    \"notifications_attachment_file_app\": \"ficheiro de app Android\",\n    \"notifications_attachment_file_document\": \"outro documento\",\n    \"notifications_click_copy_url_title\": \"Copiar URL da ligazón ao portapapeis\",\n    \"notifications_click_copy_url_button\": \"Copiar ligazón\",\n    \"notifications_actions_open_url_title\": \"Ir a {{url}}\",\n    \"notifications_none_for_topic_description\": \"Para enviar notificacións a este tema, simplemente usa PUT ou POST co URL do tema.\",\n    \"notifications_no_subscriptions_description\": \"Preme en \\\"{{linktext}} para crear ou subscribirte a un tema. Após, podes enviar mensaxes vía PUT ou POST e recibirás aquí as notificacións.\",\n    \"display_name_dialog_description\": \"Establecer un nome alternativo para o tema que será mostrado na lista de subscrición. Isto axudará a identificar os temas que teñan nomes complicados.\",\n    \"publish_dialog_tags_label\": \"Etiquetas\",\n    \"publish_dialog_tags_placeholder\": \"Lista de etiquetas separadas por vírgulas, ex. aviso, tarefa1\",\n    \"publish_dialog_priority_label\": \"Prioridade\",\n    \"publish_dialog_click_label\": \"URL a premer\",\n    \"publish_dialog_click_placeholder\": \"URL que se abre ao premer na notificación\",\n    \"publish_dialog_click_reset\": \"Desbotar o URL a premer\",\n    \"common_back\": \"Atrás\",\n    \"common_copy_to_clipboard\": \"Copiar ao portapapeis\",\n    \"signup_title\": \"Crear unha conta ntfy\",\n    \"signup_form_username\": \"Identificador\",\n    \"signup_form_password\": \"Contrasinal\",\n    \"signup_form_confirm_password\": \"Confirmar contrasinal\",\n    \"signup_form_button_submit\": \"Crear conta\",\n    \"login_form_button_submit\": \"Acceder\",\n    \"login_link_signup\": \"Crear conta\",\n    \"login_disabled\": \"O acceso está desactivado\",\n    \"action_bar_show_menu\": \"Mostrar menú\",\n    \"action_bar_toggle_mute\": \"Acalar/Reactivar as notificacións\",\n    \"message_bar_error_publishing\": \"Erro ao publicar a notificación\",\n    \"message_bar_publish\": \"Publicar mensaxe\",\n    \"nav_topics_title\": \"Temas subscritos\",\n    \"nav_button_documentation\": \"Documentación\",\n    \"nav_button_publish_message\": \"Publicar notificación\",\n    \"nav_button_subscribe\": \"Subscribirse ao tema\",\n    \"nav_button_muted\": \"Notificacións acaladas\",\n    \"nav_button_connecting\": \"conectando\",\n    \"nav_upgrade_banner_label\": \"Mellorar a ntfy Pro\",\n    \"alert_not_supported_description\": \"O teu navegador non ten soporte para notificacións\",\n    \"notifications_priority_x\": \"Prioridade {{priority}}\",\n    \"notifications_attachment_link_expires\": \"a ligazón caduca o {{date}}\",\n    \"notifications_attachment_link_expired\": \"a ligazón de descarga caducou\",\n    \"notifications_attachment_file_image\": \"ficheiro de imaxe\",\n    \"notifications_attachment_file_video\": \"ficheiro de vídeo\",\n    \"notifications_actions_not_supported\": \"Acción non soportada na aplicación web\",\n    \"notifications_actions_http_request_title\": \"Enviar HTTP {{method}} a {{url}}\",\n    \"notifications_none_for_topic_title\": \"Aínda non recibiches ningunha notificación para este tema.\",\n    \"reserve_dialog_checkbox_label\": \"Reservar tema e configurar acceso\",\n    \"notifications_loading\": \"Cargando notificacións…\",\n    \"publish_dialog_base_url_placeholder\": \"URL do servizo, ex. https://exemplo.com\",\n    \"publish_dialog_topic_label\": \"Nome do tema\",\n    \"publish_dialog_topic_placeholder\": \"Nome do tema, ex. alertas_equipo\",\n    \"publish_dialog_topic_reset\": \"Restablecer tema\",\n    \"publish_dialog_title_label\": \"Título\",\n    \"publish_dialog_title_placeholder\": \"Título das notificacións, ex. Alerta de reunión\",\n    \"publish_dialog_message_label\": \"Mensaxe\",\n    \"publish_dialog_message_placeholder\": \"Escribe aquí a mensaxe\",\n    \"publish_dialog_email_label\": \"Correo electrónico\",\n    \"signup_form_toggle_password_visibility\": \"Cambiar visibilidade do contrasinal\",\n    \"signup_already_have_account\": \"Xa tes unha conta? Accede!\",\n    \"signup_error_creation_limit_reached\": \"Acadouse o límite de creación de contas\",\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_settings\": \"Axustes\",\n    \"action_bar_account\": \"Conta\",\n    \"action_bar_change_display_name\": \"Cambiar nome público\",\n    \"action_bar_reservation_add\": \"Reservar tema\",\n    \"action_bar_reservation_edit\": \"Cambiar a reserva\",\n    \"action_bar_reservation_delete\": \"Desbotar a reserva\",\n    \"action_bar_reservation_limit_reached\": \"Acadouse o límite\",\n    \"action_bar_toggle_action_menu\": \"Abrir/Pechar menú de accións\",\n    \"action_bar_profile_title\": \"Perfil\",\n    \"action_bar_profile_logout\": \"Pechar sesión\",\n    \"action_bar_sign_in\": \"Acceder\",\n    \"action_bar_sign_up\": \"Crear conta\",\n    \"message_bar_show_dialog\": \"Mostrar diálogo para publicar\",\n    \"nav_button_all_notifications\": \"Todas as notificacións\",\n    \"nav_button_account\": \"Conta\",\n    \"nav_button_settings\": \"Axustes\",\n    \"nav_upgrade_banner_description\": \"Reserva temas, máis mensaxes e correos electrónicos así como anexos máis grandes\",\n    \"alert_grant_title\": \"As notificacións están desactivadas\",\n    \"alert_grant_description\": \"Concede permiso no navegador para mostrar notificacións de escritorio.\",\n    \"alert_grant_button\": \"Conceder agora\",\n    \"alert_not_supported_title\": \"Non hai soporte para notificacións\",\n    \"alert_not_supported_context_description\": \"Só hai soporte para notificacións ao usar HTTPS. Esta é unha limitación da <mdnLink>API de Notificacións</mdnLink>.\",\n    \"notifications_list\": \"Lista de notificacións\",\n    \"notifications_list_item\": \"Notificación\",\n    \"notifications_mark_read\": \"Marcar como lida\",\n    \"notifications_delete\": \"Eliminar\",\n    \"notifications_tags\": \"Etiquetas\",\n    \"notifications_new_indicator\": \"Nova notificación\",\n    \"notifications_attachment_open_button\": \"Abrir anexo\",\n    \"notifications_click_open_button\": \"Abrir ligazón\",\n    \"notifications_none_for_any_title\": \"Non recibiches ningunha notificación.\",\n    \"notifications_none_for_any_description\": \"Para enviar notificacións ao tema, simplemente usa PUT ou POST ao URL do tema. Aquí tes un exemplo usando un dos teus temas.\",\n    \"notifications_no_subscriptions_title\": \"Semella que aínda non tes subscricións.\",\n    \"notifications_example\": \"Exemplo\",\n    \"display_name_dialog_title\": \"Cambiar nonme público\",\n    \"display_name_dialog_placeholder\": \"Nome público\",\n    \"publish_dialog_title_topic\": \"Publicar en {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publicar notificación\",\n    \"publish_dialog_progress_uploading\": \"Enviando…\",\n    \"publish_dialog_progress_uploading_detail\": \"Enviando {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Notificación publicada\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"supera o límite de ficheiros e cota {{fileSizeLimit}}, quedan {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_reached\": \"supera o límite para ficheiros {{fileSizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"supera a cota, quedan {{remainingBytes}}\",\n    \"publish_dialog_emoji_picker_show\": \"Elixe emoji\",\n    \"publish_dialog_priority_min\": \"Prioridade Mínima\",\n    \"publish_dialog_priority_low\": \"Prioridade baixa\",\n    \"publish_dialog_priority_default\": \"Prioridade por defecto\",\n    \"publish_dialog_priority_high\": \"Prioridade alta\",\n    \"publish_dialog_priority_max\": \"Prioridade Máxima\",\n    \"publish_dialog_base_url_label\": \"URL do servizo\",\n    \"notifications_more_details\": \"Para máis información, visita o <websiteLink>sitio web</websiteLink> ou le a <docsLink>documentación</docsLink>.\",\n    \"publish_dialog_call_label\": \"Chamada de teléfono\",\n    \"publish_dialog_call_reset\": \"Retirar chamada de teléfono\",\n    \"publish_dialog_delay_placeholder\": \"Adiar a entrega, ex. {{unixTimestamp}}, {{relativeTime}}, ou \\\"{{naturalLanguage}}\\\" (Só en inglés)\",\n    \"publish_dialog_other_features\": \"Outras características:\",\n    \"publish_dialog_chip_click_label\": \"Premer en URL\",\n    \"publish_dialog_chip_email_label\": \"Reenvío por correo\",\n    \"publish_dialog_chip_call_label\": \"Chamada de teléfono\",\n    \"publish_dialog_chip_attach_url_label\": \"Anexar ficheiro por URL\",\n    \"publish_dialog_button_cancel_sending\": \"Cancelar o envío\",\n    \"publish_dialog_button_cancel\": \"Cancelar\",\n    \"publish_dialog_button_send\": \"Enviar\",\n    \"publish_dialog_attached_file_title\": \"Ficheiro anexo:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nome do ficheiro anexo\",\n    \"publish_dialog_drop_file_here\": \"Soltar aquí o ficheiro\",\n    \"emoji_picker_search_placeholder\": \"Buscar emoji\",\n    \"subscribe_dialog_subscribe_title\": \"Subscribirse a un tema\",\n    \"publish_dialog_call_item\": \"Número de teléfono {{number}}\",\n    \"publish_dialog_email_placeholder\": \"Enderezo ao que reenviar a notificación, ex. xoana@exemplo.com\",\n    \"publish_dialog_email_reset\": \"Retirar reenvío ao correo\",\n    \"publish_dialog_attach_label\": \"URL do anexo\",\n    \"publish_dialog_attach_placeholder\": \"Anexa un ficheiro por URL, ex. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Retirar URL do anexo\",\n    \"publish_dialog_filename_placeholder\": \"Nome do ficheiro anexo\",\n    \"publish_dialog_filename_label\": \"Nome do ficheiro\",\n    \"publish_dialog_delay_label\": \"Adiar\",\n    \"publish_dialog_delay_reset\": \"Retirar o adiadamento da entrega\",\n    \"publish_dialog_chip_attach_file_label\": \"Anexar ficheiro local\",\n    \"publish_dialog_chip_delay_label\": \"Entrega adiada\",\n    \"publish_dialog_chip_topic_label\": \"Cambiar tema\",\n    \"publish_dialog_details_examples_description\": \"Para ver exemplos e unha descrición polo miúdo das ferramentas de envío, le a <docsLink>documentación</docsLink>.\",\n    \"publish_dialog_checkbox_publish_another\": \"Publicar outra\",\n    \"emoji_picker_search_clear\": \"Limpar busca\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Números de teléfono non verificados\",\n    \"publish_dialog_attached_file_remove\": \"Retirar ficheiro anexo\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Sen chamadas\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Para preguntas sobre pagamentos, <Link>contacta con nós</Link> directamente.\",\n    \"account_tokens_dialog_title_create\": \"Crear token de acceso\",\n    \"prefs_reservations_dialog_title_edit\": \"Editar tema reservado\",\n    \"priority_default\": \"por defecto\",\n    \"prefs_notifications_min_priority_title\": \"Prioridade mínima\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} chamadas de teléfono diarias\",\n    \"account_upgrade_dialog_tier_current_label\": \"Actual\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"prefs_notifications_delete_after_never\": \"Nunca\",\n    \"prefs_users_description\": \"Engadir/eliminar usuarias dos temas protexidos. Ten en conta que as credenciais gárdanse na almacenaxe local do navegador.\",\n    \"subscribe_dialog_subscribe_description\": \"Os temas poden non estar protexidos con contrasinal, asi que escolle un nome que non sexa fácil de pesquisar. Unha vez suscrito, podes notificar con PUT/POST.\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"aforro ata un {{discount}}%\",\n    \"account_tokens_dialog_label\": \"Etiqueta, ex. notificación de Radarr\",\n    \"account_tokens_table_expires_header\": \"Caducidade\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Axuste</strong>: ao mellorar a un plan de pagamento superior, a diferencia vaise <strong>cobrar inmediatamente</strong>. Se degradas a conta a un plan inferior a diferencia usarase para pagar futuros períodos de pagamento.\",\n    \"prefs_reservations_dialog_access_label\": \"Acceso\",\n    \"account_usage_attachment_storage_title\": \"Almacenaxe dos anexos\",\n    \"prefs_users_dialog_username_label\": \"Identificador, ex. xoana\",\n    \"prefs_reservations_table_not_subscribed\": \"Non subscrita\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} correos diarios\",\n    \"prefs_notifications_min_priority_max_only\": \"Só prioridade máxima\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} chamadas de teléfono diarias\",\n    \"prefs_notifications_sound_description_some\": \"As notificacións sonan co ton {{sound}} ao chegar\",\n    \"prefs_reservations_edit_button\": \"Editar acceso ao tema\",\n    \"account_tokens_dialog_expires_never\": \"O token non caduca\",\n    \"subscribe_dialog_login_title\": \"Require inciar sesión\",\n    \"account_tokens_dialog_expires_x_days\": \"O token caduca en {{days}} días\",\n    \"prefs_reservations_table_everyone_read_only\": \"Podo publicar e subscribirme, calquera pode subscribirse\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Só eu podo publicar e subscribirme\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} tema reservado\",\n    \"subscribe_dialog_login_button_login\": \"Acceder\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Sen temas reservados\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Non se pode eliminar ou editar unha usuaria coa sesión iniciada\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"As notificacións autoelimínanse após tres horas\",\n    \"prefs_notifications_delete_after_three_hours\": \"Após tres horas\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Mostrar as notificacións se a prioridade é {{number}} {{name}} ou superior\",\n    \"reservation_delete_dialog_description\": \"Ao eliminar a reserva cedes a propiedade do tema, e permites que outras persoas poidan reservalo. Podes manter ou eliminar as mensaxes e anexos existentes.\",\n    \"prefs_reservations_table_everyone_read_write\": \"Calquera pode publicar e subscribirse\",\n    \"prefs_reservations_dialog_title_delete\": \"Eliminar a reserva do tema\",\n    \"prefs_users_table\": \"Táboa de usuarias\",\n    \"prefs_reservations_table_topic_header\": \"Tema\",\n    \"reservation_delete_dialog_submit_button\": \"Eliminar a reserva\",\n    \"prefs_reservations_limit_reached\": \"Acadaches o límite de temas que podes reservar.\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensual\",\n    \"prefs_users_add_button\": \"Engadir usuaria\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mensaxes diarias\",\n    \"prefs_appearance_language_title\": \"Idioma\",\n    \"prefs_notifications_delete_after_one_day_description\": \"As notificacións autoelimínanse após un día\",\n    \"account_tokens_table_never_expires\": \"Non caduca\",\n    \"account_tokens_delete_dialog_title\": \"Desbotar token de acceso\",\n    \"prefs_notifications_delete_after_one_month\": \"Após un mes\",\n    \"account_tokens_delete_dialog_description\": \"Antes de borrar o token de acceso mira que ningunha aplicación ou programa o está usando. <strong>Esta acción non pode desfacerse</strong>.\",\n    \"account_upgrade_dialog_button_cancel\": \"Cancelar\",\n    \"account_tokens_table_label_header\": \"Etiqueta\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Para preguntas sobre pagamentos, vai ao noso <Link>sitiio web</Link>.\",\n    \"prefs_notifications_delete_after_never_description\": \"As notificacións non se eliminarán nunca automáticamente\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} temas reservados\",\n    \"prefs_notifications_sound_description_none\": \"As notificacións non reproducen un ton ao chegar\",\n    \"account_tokens_description\": \"Usar tokens de acceso ao publicar e subscribirte a través da API de ntfy, así non tes que enviar as credenciais. Le a <Link>documentación</Link> para saber máis.\",\n    \"prefs_reservations_table\": \"Táboa cos temas reservados\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Cancelar subscrición\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} correo diario\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} por ficheiro\",\n    \"prefs_reservations_description\": \"Podes reservar nomes de temas para uso personal. Ao reservar un tema tes a propiedade sobre del, e permíteche definir os permisos de acceso para outras usuarias sobre o tema.\",\n    \"prefs_users_description_no_sync\": \"Usuarias e contrasinais non están sincronizados coa túa conta.\",\n    \"account_tokens_dialog_title_edit\": \"Editar token de acceso\",\n    \"prefs_users_table_base_url_header\": \"URL do servizo\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{mensaxes}} mensaxe diaria\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"O nivel seleccionado permite reservar menos temas que o nivel actual. Antes de cambiar de nivel, <strong>elimina unha reserva polo menos</strong>. Podes eliminar as reservas nos <Link>Axustes</Link>.\",\n    \"prefs_users_table_user_header\": \"Usuaria\",\n    \"error_boundary_stack_trace\": \"Trazas do problema\",\n    \"prefs_users_dialog_password_label\": \"Contrasinal\",\n    \"prefs_notifications_delete_after_one_week\": \"Após unha semana\",\n    \"prefs_reservations_delete_button\": \"Restablecer acceso ao tema\",\n    \"prefs_notifications_delete_after_one_week_description\": \"As notificacións autoelimínanse após unha semana\",\n    \"error_boundary_unsupported_indexeddb_description\": \"A app ntfy web precisa a función IndexedDB, e o teu navegador non ten soporte para IndexedDB no modo privado.<br/><br/>Aínda que é unha mágoa, tampouco ten moito senso usar a app ntfy web en modo privado, porque todo se garda na almacenaxe do navegador. Podes aprender máis sobre isto <githubLink>neste tema de GitHub</githubLink>, ou comentarnos o que che parece en <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Cancelar\",\n    \"account_basics_tier_description\": \"O nivel da túa conta\",\n    \"prefs_reservations_dialog_title_add\": \"Reservar tema\",\n    \"account_upgrade_dialog_cancel_warning\": \"Isto vai <strong>cancelar a túa subscrición</strong>, e degradar a túa conta o {{date}}. Nesa data, as reservas de temas así como as mensaxes na caché do servidor <strong>van ser eliminadas</strong>.\",\n    \"prefs_notifications_sound_title\": \"Ton da notificación\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioridade por defecto e superior\",\n    \"prefs_reservations_table_access_header\": \"Acceso\",\n    \"account_tokens_table_copied_to_clipboard\": \"Copiouse o token de acceso\",\n    \"account_tokens_dialog_expires_x_hours\": \"O token caduca en {{hours}} horas\",\n    \"prefs_users_edit_button\": \"Editar usuaria\",\n    \"account_upgrade_dialog_title\": \"Cambiar facturación da conta\",\n    \"priority_low\": \"baixa\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Preme para subscribirte\",\n    \"error_boundary_description\": \"Isto non debería pasar. Lamentámolo. <br/>Se tes un minuto, <githubLink>informa en GitHub</githubLink>, ou fáinolo saber en <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"priority_min\": \"min\",\n    \"prefs_notifications_min_priority_description_any\": \"Mostrar todas as notificacións, obviando a prioridade\",\n    \"error_boundary_gathering_info\": \"Obter máis info…\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Non hai soporte para a navegación privada\",\n    \"prefs_notifications_delete_after_one_day\": \"Após un día\",\n    \"error_boundary_title\": \"vaite!, ntfy fallou\",\n    \"reservation_delete_dialog_action_keep_description\": \"As mensaxes e anexos que están no servidor serán visibles públicamente para quen saiba o nome do tema.\",\n    \"prefs_reservations_add_button\": \"Engadir tema reservado\",\n    \"prefs_reservations_title\": \"Temas reservados\",\n    \"prefs_reservations_dialog_description\": \"Ao reservar un tema tes a propiedade sobre el, e permíteche definir os permisos de acceso para outras usuarias.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Eliminar definitivamente o token\",\n    \"prefs_notifications_title\": \"Notificacións\",\n    \"account_tokens_title\": \"Tokens de acceso\",\n    \"prefs_reservations_dialog_topic_label\": \"Tema\",\n    \"prefs_users_title\": \"Xestionar usuarias\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} anual. Pagamento mensual.\",\n    \"account_tokens_dialog_expires_unchanged\": \"Deixar a data de caducidade sen cambiar\",\n    \"error_boundary_button_copy_stack_trace\": \"Copiar trazas do problema\",\n    \"account_tokens_dialog_title_delete\": \"Eliminar token de acceso\",\n    \"reservation_delete_dialog_action_keep_title\": \"Manter as mensaxes e anexos gardados\",\n    \"prefs_notifications_sound_no_sound\": \"Sen ton\",\n    \"account_upgrade_dialog_interval_yearly\": \"Anual\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Crea unha conta\",\n    \"account_tokens_dialog_button_cancel\": \"Cancelar\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} cobrado anualmente. Aforro {{save}}.\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Prioridade alta e superior\",\n    \"priority_max\": \"máx\",\n    \"prefs_users_delete_button\": \"Eliminar usuaria\",\n    \"prefs_notifications_min_priority_any\": \"Calquera prioridade\",\n    \"account_tokens_dialog_expires_label\": \"O token caduca o\",\n    \"prefs_notifications_delete_after_title\": \"Desbotar notificacións\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"aforro {{discount}}%\",\n    \"prefs_users_dialog_title_edit\": \"Editar usuaria\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Prioridade baixa e superior\",\n    \"account_tokens_dialog_button_update\": \"Actualizar token\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} almacenaxe total\",\n    \"prefs_reservations_table_everyone_write_only\": \"Podo publicar e subscribirme, calquera pode publicar\",\n    \"prefs_appearance_title\": \"Aparencia\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Non se pode editar ou desbotar o token da sesión actual\",\n    \"prefs_notifications_sound_play\": \"Reproducir ton seleccionado\",\n    \"account_tokens_table_last_access_header\": \"Último acceso\",\n    \"account_tokens_table_last_origin_tooltip\": \"Desde o enderezo IP {{ip}}, preme para detalles\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mes\",\n    \"account_tokens_table_current_session\": \"Sesión do navegador actual\",\n    \"account_upgrade_dialog_button_pay_now\": \"Paga e subscríbete\",\n    \"reservation_delete_dialog_action_delete_title\": \"Eliminar mensaxes e anexos gardados\",\n    \"reservation_delete_dialog_action_delete_description\": \"As mensaxes e anexos vanse borrar definitivamente. Esta acción non ten volta.\",\n    \"prefs_notifications_delete_after_one_month_description\": \"As notificacións autoelimínanse após un mes\",\n    \"prefs_users_dialog_base_url_label\": \"URL do servizo, ex. https://ntfy.sh\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Seleccionado\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Actualizar subscrición\",\n    \"priority_high\": \"alta\",\n    \"account_delete_dialog_billing_warning\": \"Ao eliminar a conta tamén cancelas o pagamento das subscricións. Non poderás volver acceder ao taboleiro de pagamentos.\",\n    \"prefs_notifications_min_priority_description_max\": \"Mostrar notificacións se a prioridade é 5 (máx)\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"O nivel seleccionado permite reservar menos temas que o nivel actual. Antes de cambiar de nivel, <strong>elimina {{count}} reservas polo menos</strong>. Podes eliminar as reservas nos <Link>Axustes</Link>.\",\n    \"prefs_users_dialog_title_add\": \"Engadir usuaria\",\n    \"account_tokens_dialog_button_create\": \"Crear token\",\n    \"account_tokens_table_create_token_button\": \"Crear token de acceso\",\n    \"account_basics_tier_interval_monthly\": \"mensual\",\n    \"account_basics_tier_canceled_subscription\": \"A sua suscripción foi cancelada e vostede será degradado a unha conta gratuita o {{date}}.\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Contrasinal incorrecto\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Número de teléfono\",\n    \"account_basics_password_dialog_button_submit\": \"Modificar contrasinal\",\n    \"account_basics_username_title\": \"Identificador\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Código de confirmación\",\n    \"account_usage_messages_title\": \"Mesaxes publicados\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Enviar SMS\",\n    \"account_basics_tier_change_button\": \"Cambiar\",\n    \"account_basics_phone_numbers_dialog_description\": \"Para usar a característica de chamadas de teléfono, vostede debe engadir e verificar ao menos un número de teléfono. A verificación pode ser realizada vía SMS ou a través de chamada.\",\n    \"account_delete_title\": \"Eliminar a conta\",\n    \"account_delete_dialog_label\": \"Contrasinal\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(con tier {{tier}})\",\n    \"subscribe_dialog_login_username_label\": \"Identificador, ex. xoana\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Identificador {{username}} non autorizado\",\n    \"account_basics_title\": \"Conta\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Aínda non hay números de teléfono\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Xerar nome\",\n    \"subscribe_dialog_login_password_label\": \"Contrasinal\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Subscribirse\",\n    \"account_basics_phone_numbers_dialog_title\": \"Engadir número de teléfono\",\n    \"account_basics_username_admin_tooltip\": \"É vostede Admin\",\n    \"account_delete_dialog_description\": \"Isto borrará permanentemente a conta, incluido todos os datos almacenados no servidor. Despois do borrado, o teu identificador non estará dispoñible durante 7 días. Se realmente queres proceder, por favor confirma co contrasinal na caixa inferior.\",\n    \"account_usage_reservations_none\": \"Non hai temas reservados para esta conta\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nome do tema, ex. alertas_xoana\",\n    \"account_usage_title\": \"Uso\",\n    \"account_basics_tier_upgrade_button\": \"Mexorar a Pro\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Tema xa reservado\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(sen tier)\",\n    \"account_basics_tier_payment_overdue\": \"O pago está retrasado. Por favor, revise o seu método de pago o a súa conta será degradada pronto.\",\n    \"account_basics_phone_numbers_description\": \"Para notificacións telefónicas\",\n    \"account_basics_tier_free\": \"De balde\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"account_delete_dialog_button_cancel\": \"Cancelar\",\n    \"account_basics_password_description\": \"Modificar o contrasinal da conta\",\n    \"account_usage_calls_title\": \"Chamadas realizadas\",\n    \"account_basics_tier_basic\": \"Básico\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Número de teléfono copiado no portapapeis\",\n    \"account_basics_tier_title\": \"Tipo de conta\",\n    \"account_usage_cannot_create_portal_session\": \"Non foi posible abrir o portal de pagos\",\n    \"account_delete_description\": \"Eliminar a conta de xeito definitivo\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"ex. +1222333444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"ex. 123456\",\n    \"account_basics_tier_manage_billing_button\": \"Xestionar pagos\",\n    \"account_basics_username_description\": \"Ei, es ti ❤\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmar contrasinal\",\n    \"account_basics_tier_interval_yearly\": \"anual\",\n    \"account_delete_dialog_button_submit\": \"Borrar permanentemente a conta\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Chamada\",\n    \"account_basics_password_title\": \"Contrasinal\",\n    \"account_basics_password_dialog_new_password_label\": \"Novo contrasinal\",\n    \"account_usage_of_limit\": \"de {{limit}}\",\n    \"subscribe_dialog_error_user_anonymous\": \"anónimo\",\n    \"account_usage_basis_ip_description\": \"As estatísticas de uso e límites para esta conta están basados na IP, polo que poden estar compartidas con outras usuarias. Os limites mostrados son aproximados, baseados nos límites das taxas existentes.\",\n    \"account_basics_password_dialog_title\": \"Modificar contrasinal\",\n    \"account_usage_limits_reset_daily\": \"Límite de uso é reiniciado diariamente a medianoite (UTC(\",\n    \"account_usage_unlimited\": \"Sen límites\",\n    \"account_basics_phone_numbers_title\": \"Números de teléfono\",\n    \"account_basics_password_dialog_current_password_label\": \"Contrasinal actual\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL do servizo\",\n    \"account_usage_reservations_title\": \"Temas reservados\",\n    \"account_usage_calls_none\": \"Non se poden realizar chamadas con esta conta\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Usar outro servidor\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Código de verificación\",\n    \"account_basics_tier_paid_until\": \"Suscripción pagada ata {{date}}, e vaise auto-renovar\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} por arquivo, borrado despois de {{expiry}}\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Chámame\",\n    \"account_usage_emails_title\": \"Emails enviados\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"subscribe_dialog_login_description\": \"Este tema está protexido por contrasinal. Por favor, escribe as credenciais para subscribirte.\",\n    \"action_bar_mute_notifications\": \"Acalar notificacións\",\n    \"action_bar_unmute_notifications\": \"Reactivar notificacións\",\n    \"alert_notification_permission_required_title\": \"Notificacións desactivadas\",\n    \"alert_notification_permission_required_description\": \"Concederlle permisos ao navegador para mostrar notificacións de escritorio\",\n    \"alert_notification_permission_required_button\": \"Conceder\",\n    \"alert_notification_permission_denied_title\": \"Notificacións bloqueadas\",\n    \"alert_notification_permission_denied_description\": \"Por favor reactívaas no navegador\",\n    \"alert_notification_ios_install_required_title\": \"Require instalación iOS\",\n    \"alert_notification_ios_install_required_description\": \"Preme na icona Compartir e Engadir a Pantalla de Inicio para activar as notificacións en iOS\",\n    \"notifications_actions_failed_notification\": \"Non se puido realizar a acción\",\n    \"publish_dialog_checkbox_markdown\": \"Dar formato Markdow\",\n    \"prefs_notifications_web_push_title\": \"Notificacións en segundo plano\",\n    \"prefs_notifications_web_push_enabled_description\": \"Recíbense notificacións incluso se a app web non está en execución (vía Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Recíbense as notificacións cando a app web está en execución (vía WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Activadas para {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Desactivadas\",\n    \"prefs_appearance_theme_title\": \"Decorado\",\n    \"prefs_appearance_theme_system\": \"Sistema (por defecto)\",\n    \"prefs_appearance_theme_dark\": \"Modo escuro\",\n    \"prefs_appearance_theme_light\": \"Modo claro\",\n    \"error_boundary_button_reload_ntfy\": \"Recargar ntfy\",\n    \"web_push_subscription_expiring_title\": \"Vanse pausar as notificacións\",\n    \"web_push_subscription_expiring_body\": \"Abrir ntfy para seguir recibindo notificacións\",\n    \"web_push_unknown_notification_title\": \"Recibida unha notificación descoñecida desde o servidor\",\n    \"web_push_unknown_notification_body\": \"Poderías ter que actualizar ntfy abrindo a app web\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Unha usuaria predefinida non se pode editar ou eliminar\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Non se pode editar un token de usuaria predefinida\"\n}\n"
  },
  {
    "path": "web/public/static/langs/he.json",
    "content": "{\n    \"common_cancel\": \"ביטול\",\n    \"common_save\": \"שמירה\",\n    \"common_add\": \"הוספה\",\n    \"common_back\": \"חזרה\",\n    \"common_copy_to_clipboard\": \"העתקה ללוח הגזירים\",\n    \"signup_title\": \"יצירת חשבון ntfy\",\n    \"signup_form_username\": \"שם משתמש\",\n    \"signup_form_password\": \"סיסמה\",\n    \"signup_form_confirm_password\": \"אישור סיסמה\",\n    \"signup_form_button_submit\": \"הרשמה\",\n    \"signup_form_toggle_password_visibility\": \"הצגת/הסתרת סיסמה\",\n    \"signup_already_have_account\": \"כבר יש לך חשבון? אפשר להיכנס איתו!\",\n    \"signup_disabled\": \"הרשמה כבויה\",\n    \"signup_error_username_taken\": \"שם המשתמש {{username}} כבר תפוס\",\n    \"signup_error_creation_limit_reached\": \"הגעת למגבלת יצירת חשבונות\",\n    \"login_title\": \"כניסה לחשבון ה־ntfy שלך\",\n    \"login_form_button_submit\": \"כניסה\",\n    \"login_link_signup\": \"הרשמה\",\n    \"login_disabled\": \"הכניסה מושבתת\",\n    \"action_bar_show_menu\": \"הצגת תפריט\",\n    \"action_bar_logo_alt\": \"הלוגו של ntfy\",\n    \"action_bar_settings\": \"הגדרות\",\n    \"action_bar_account\": \"חשבון\",\n    \"action_bar_change_display_name\": \"החלפת שם תצוגה\",\n    \"action_bar_reservation_add\": \"שימור נושא\",\n    \"action_bar_reservation_edit\": \"החלפת מצב שימור\",\n    \"action_bar_reservation_delete\": \"הסרת שימור\",\n    \"action_bar_reservation_limit_reached\": \"הגעת למגבלה\",\n    \"action_bar_send_test_notification\": \"שליחת התראת ניסוי\",\n    \"action_bar_clear_notifications\": \"לפנות את כל ההתראות\",\n    \"action_bar_mute_notifications\": \"השתקת התראות\",\n    \"action_bar_unmute_notifications\": \"ביטול השתקת התראות\",\n    \"action_bar_unsubscribe\": \"ביטול מינוי\",\n    \"notifications_list_item\": \"התראה\",\n    \"notifications_mark_read\": \"סימון כנקראה\",\n    \"notifications_delete\": \"מחיקה\",\n    \"notifications_copied_to_clipboard\": \"הועתקה ללוח הגזירים\",\n    \"notifications_tags\": \"תגיות\",\n    \"notifications_priority_x\": \"עדיפות {{priority}}\",\n    \"notifications_new_indicator\": \"התראה חדשה\",\n    \"notifications_attachment_copy_url_button\": \"העתקת כתובת\",\n    \"notifications_attachment_open_title\": \"מעבר אל {{url}}\",\n    \"notifications_attachment_open_button\": \"פתיחת צרופה\",\n    \"notifications_attachment_link_expires\": \"תוקף הקישור פג ב־{{date}}\",\n    \"notifications_attachment_link_expired\": \"תוקף קישור ההורדה פג\",\n    \"notifications_actions_failed_notification\": \"פעולה לא מוצלחת\",\n    \"notifications_none_for_topic_title\": \"לא קיבלת התראות בנושא הזה עדיין.\",\n    \"notifications_none_for_topic_description\": \"כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.\",\n    \"notifications_none_for_any_title\": \"לא קיבלת התראות כלל.\",\n    \"notifications_no_subscriptions_title\": \"נראה שלא נרשמת למינויים עדיין.\",\n    \"action_bar_toggle_mute\": \"השתקת/הפעלת התראות\",\n    \"action_bar_toggle_action_menu\": \"פתיחת/סגירת תפריט הפעולות\",\n    \"action_bar_profile_title\": \"פרופיל\",\n    \"action_bar_profile_settings\": \"הגדרות\",\n    \"action_bar_profile_logout\": \"יציאה\",\n    \"action_bar_sign_in\": \"כניסה\",\n    \"action_bar_sign_up\": \"הרשמה\",\n    \"message_bar_type_message\": \"כאן ניתן להקליד הודעה\",\n    \"message_bar_error_publishing\": \"שגיאה בפרסום ההתראה\",\n    \"message_bar_show_dialog\": \"הצגת חלונית פרסום\",\n    \"message_bar_publish\": \"פרסום הודעה\",\n    \"nav_topics_title\": \"נושאים שנרשמת אליהם\",\n    \"nav_button_all_notifications\": \"כל ההתראות\",\n    \"nav_button_account\": \"חשבון\",\n    \"nav_button_settings\": \"הגדרות\",\n    \"nav_button_documentation\": \"תיעוד\",\n    \"nav_button_publish_message\": \"פרסום התראה\",\n    \"nav_button_subscribe\": \"הרשמה לנושא\",\n    \"nav_button_muted\": \"התראות הושתקו\",\n    \"nav_button_connecting\": \"מתחבר\",\n    \"nav_upgrade_banner_label\": \"שדרוג ל־ntfy Pro\"\n}\n"
  },
  {
    "path": "web/public/static/langs/hu.json",
    "content": "{\n    \"action_bar_send_test_notification\": \"Teszt értesítés küldése\",\n    \"action_bar_clear_notifications\": \"Összes értesítés törlése\",\n    \"alert_not_supported_description\": \"A böngésződ nem támogatja az értesítések fogadását\",\n    \"action_bar_settings\": \"Beállítások\",\n    \"action_bar_unsubscribe\": \"Leiratkozás\",\n    \"message_bar_type_message\": \"Írd ide az üzenetet\",\n    \"message_bar_error_publishing\": \"Hiba történt az értesítés elküldése közben\",\n    \"nav_button_all_notifications\": \"Összes értesítés\",\n    \"nav_topics_title\": \"Feliratkozott témák\",\n    \"alert_notification_permission_required_title\": \"Az értesítések le vannak tiltva\",\n    \"alert_notification_permission_required_description\": \"Engedélyezd a böngésződnek, hogy asztali értesítéseket jelenítsen meg\",\n    \"nav_button_settings\": \"Beállítások\",\n    \"nav_button_documentation\": \"Dokumentáció\",\n    \"nav_button_publish_message\": \"Értesítés küldése\",\n    \"alert_notification_permission_required_button\": \"Engedélyezés\",\n    \"alert_not_supported_title\": \"Az értesítések nincsenek támogatva\",\n    \"notifications_copied_to_clipboard\": \"Vágólapra másolva\",\n    \"notifications_tags\": \"Címkék\",\n    \"notifications_attachment_copy_url_title\": \"Másolja vágólapra a csatolmány URL-ét\",\n    \"notifications_attachment_copy_url_button\": \"URL másolása\",\n    \"notifications_attachment_open_title\": \"Menjen a(z) {{url}} címre\",\n    \"notifications_attachment_open_button\": \"Csatolmány megnyitása\",\n    \"notifications_attachment_link_expired\": \"A letöltési link lejárt\",\n    \"notifications_attachment_link_expires\": \"A hivatkozás {{date}}-kor jár le\",\n    \"nav_button_subscribe\": \"Feliratkozás témára\",\n    \"notifications_click_copy_url_title\": \"Másolja vágólapra a hivatkozás URL-ét\",\n    \"notifications_actions_open_url_title\": \"Menjen a(z) {{url}} címre\",\n    \"notifications_actions_not_supported\": \"A művelet nem támogatott a webes alkalmazásban\",\n    \"notifications_actions_http_request_title\": \"Küldjön HTTP {{method}} kérést a(z) {{url}} címre\",\n    \"notifications_none_for_topic_title\": \"Még nem érkezett értesítés erre a témára.\",\n    \"notifications_none_for_any_title\": \"Még nem érkezett egy értesítés sem.\",\n    \"notifications_none_for_any_description\": \"Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.\",\n    \"notifications_no_subscriptions_title\": \"Úgy tűnik, még nem iratkoztál fel egy témára sem.\",\n    \"publish_dialog_message_published\": \"Értesítés elküldve\",\n    \"notifications_example\": \"Példa\",\n    \"notifications_no_subscriptions_description\": \"Kattints a \\\"{{linktext}}\\\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.\",\n    \"publish_dialog_priority_low\": \"Alacsony prioritás\",\n    \"publish_dialog_priority_default\": \"Közepes prioritás\",\n    \"publish_dialog_priority_high\": \"Magas prioritás\",\n    \"notifications_more_details\": \"További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.\",\n    \"publish_dialog_title_no_topic\": \"Értesítés küldése\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"túllépi a kvótát, {{remainingBytes}} maradt\",\n    \"publish_dialog_priority_min\": \"Legkisebb prioritás\",\n    \"publish_dialog_base_url_label\": \"A szolgáltatás URL-e\",\n    \"publish_dialog_base_url_placeholder\": \"A szolgáltatás URL-e, pl: https://example.com\",\n    \"publish_dialog_topic_label\": \"Téma neve\",\n    \"publish_dialog_priority_max\": \"Legmagasabb prioritás\",\n    \"publish_dialog_topic_placeholder\": \"Téma neve, pl: jozsi_riasztasai\",\n    \"publish_dialog_title_label\": \"Cím\",\n    \"publish_dialog_title_placeholder\": \"Értesítés címe, pl: Fogy a szabad hely\",\n    \"publish_dialog_message_label\": \"Üzenet\",\n    \"publish_dialog_message_placeholder\": \"Írj ide egy üzenetet\",\n    \"publish_dialog_tags_label\": \"Címkék\",\n    \"publish_dialog_tags_placeholder\": \"Címkék vesszővel elválasztva, pl: fontos,srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioritás\",\n    \"publish_dialog_click_label\": \"URL\",\n    \"publish_dialog_click_placeholder\": \"Webcím, ami megnyílik, ha az értesítésre kattintanak\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com\",\n    \"publish_dialog_attach_label\": \"Csatolmány URL-e\",\n    \"publish_dialog_filename_label\": \"Fájlnév\",\n    \"publish_dialog_filename_placeholder\": \"Csatolmány fájlneve\",\n    \"publish_dialog_delay_label\": \"Késleltetés\",\n    \"publish_dialog_delay_placeholder\": \"Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \\\"{{naturalLanguage}}\\\" (Csak angolul)\",\n    \"publish_dialog_other_features\": \"Egyéb lehetőségek:\",\n    \"publish_dialog_chip_click_label\": \"Kattintási URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Helyi fájl csatolása\",\n    \"publish_dialog_chip_delay_label\": \"Késleltetett kézbesítés\",\n    \"publish_dialog_chip_topic_label\": \"Téma megváltoztatása\",\n    \"publish_dialog_button_cancel_sending\": \"Küldés megállítása\",\n    \"publish_dialog_button_cancel\": \"Mégsem\",\n    \"publish_dialog_checkbox_publish_another\": \"Küldök még egyet\",\n    \"publish_dialog_attached_file_title\": \"Csatolt fájl:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Csatolmány fájlneve\",\n    \"publish_dialog_drop_file_here\": \"Ejtsd ide a fájlt\",\n    \"emoji_picker_search_placeholder\": \"Emoji keresése\",\n    \"publish_dialog_details_examples_description\": \"Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Használjon másik szervert\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Feliratkozás\",\n    \"subscribe_dialog_login_title\": \"Be kell jelentkezni\",\n    \"subscribe_dialog_subscribe_description\": \"A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.\",\n    \"subscribe_dialog_login_description\": \"Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.\",\n    \"subscribe_dialog_login_username_label\": \"Felhasználónév, pl: jozsi\",\n    \"subscribe_dialog_login_password_label\": \"Jelszó\",\n    \"common_back\": \"Vissza\",\n    \"subscribe_dialog_login_button_login\": \"Belépés\",\n    \"subscribe_dialog_error_user_anonymous\": \"névtelen\",\n    \"subscribe_dialog_error_user_not_authorized\": \"A(z) {{username}} felhasználónak nincs hozzáférése\",\n    \"prefs_notifications_min_priority_description_any\": \"Minden értesítést mutat, prioritástól függetlenül\",\n    \"prefs_notifications_min_priority_description_max\": \"Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg\",\n    \"prefs_notifications_min_priority_any\": \"Bármilyen prioritás\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Alacsony prioritás, vagy magasabb\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Magas, vagy legmagasabb prioritás\",\n    \"prefs_notifications_min_priority_max_only\": \"Csak a legmagasabb prioritás\",\n    \"prefs_notifications_sound_title\": \"Értesítés hangja\",\n    \"prefs_notifications_sound_description_none\": \"Az értesítések nem fognak hangot adni, amikor megérkeznek\",\n    \"prefs_notifications_sound_no_sound\": \"Hang nélkül\",\n    \"prefs_notifications_delete_after_one_week\": \"1 hét után\",\n    \"prefs_notifications_delete_after_one_month\": \"1 hónap után\",\n    \"prefs_notifications_delete_after_never_description\": \"Az értesítések soha nem lesznek automatikusan törölve\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"A 3 óránál régebbi értesítések automatikus törlése\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Az egy napnál régebbi értesítések automatikus törlése\",\n    \"prefs_users_description\": \"Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.\",\n    \"prefs_users_table_user_header\": \"Felhasználó\",\n    \"prefs_users_table_base_url_header\": \"Szerver címe\",\n    \"prefs_users_dialog_title_edit\": \"Felhasználó szerkesztése\",\n    \"prefs_users_dialog_username_label\": \"Felhasználónév, pl: jozsi\",\n    \"prefs_users_dialog_password_label\": \"Jelszó\",\n    \"common_add\": \"Hozzáadás\",\n    \"prefs_users_dialog_base_url_label\": \"Szerver címe, pl: https://ntfy.sh\",\n    \"notifications_loading\": \"Értesítések betöltése …\",\n    \"publish_dialog_progress_uploading\": \"Feltöltés …\",\n    \"notifications_click_copy_url_button\": \"Hivatkozás másolása\",\n    \"notifications_click_open_button\": \"Hivatkozás megnyitása\",\n    \"publish_dialog_progress_uploading_detail\": \"Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"notifications_none_for_topic_description\": \"Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.\",\n    \"prefs_notifications_delete_after_one_day\": \"1 nap után\",\n    \"publish_dialog_attach_placeholder\": \"Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_chip_email_label\": \"Továbbítás email-ben\",\n    \"publish_dialog_chip_attach_url_label\": \"Fájl csatolása URL-lel\",\n    \"publish_dialog_button_send\": \"Küldés\",\n    \"subscribe_dialog_subscribe_title\": \"Feliratkozás témára\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Mégsem\",\n    \"prefs_notifications_min_priority_title\": \"Legkisebb megjelenítendő prioritás\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Közepes prioritás, vagy magasabb\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Az egy hétnél régebbi értesítések automatikus törlése\",\n    \"prefs_users_add_button\": \"Felhasználó hozzáadása\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Téma neve, pl: jozsi_riasztasai\",\n    \"prefs_notifications_title\": \"Értesítések\",\n    \"error_boundary_button_copy_stack_trace\": \"Verem nyomkövetés másolása\",\n    \"prefs_notifications_delete_after_title\": \"Régi értesítések törlése\",\n    \"prefs_notifications_delete_after_three_hours\": \"3 óra után\",\n    \"error_boundary_title\": \"Jaj ne, az ntfy összeomlott\",\n    \"prefs_notifications_delete_after_never\": \"Soha\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Az egy hónapnál régebbi értesítések automatikus törlése\",\n    \"prefs_appearance_title\": \"Megjelenés\",\n    \"priority_default\": \"közepes\",\n    \"priority_high\": \"magas\",\n    \"priority_max\": \"legmagasabb\",\n    \"priority_min\": \"legkisebb\",\n    \"error_boundary_gathering_info\": \"Több információ…\",\n    \"publish_dialog_attachment_limits_file_reached\": \"túllépi a fájlméret korlátot ({{fileSizeLimit}})\",\n    \"prefs_users_title\": \"Felhasználók kezelése\",\n    \"common_cancel\": \"Mégsem\",\n    \"common_save\": \"Mentés\",\n    \"prefs_users_dialog_title_add\": \"Felhasználó hozzáadása\",\n    \"prefs_appearance_language_title\": \"Nyelv\",\n    \"priority_low\": \"alacsony\",\n    \"error_boundary_stack_trace\": \"Verem nyomkövetés\",\n    \"publish_dialog_title_topic\": \"A {{topic}} téma értesítése\",\n    \"prefs_notifications_sound_description_some\": \"Az értesítéseket a(z) {{sound}} hang fogja jelezni\",\n    \"error_boundary_description\": \"Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>.\",\n    \"action_bar_show_menu\": \"Menü mutatása\",\n    \"action_bar_toggle_mute\": \"Üzenetek némítása/bekapcsolása\",\n    \"notifications_list_item\": \"Értesítés\",\n    \"error_boundary_unsupported_indexeddb_description\": \"A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.<br/><br/>Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat <githubLink>ezen a Github oldalon</githubLink>, vagy beszéljen velünk <discordLink>Discord-on</discordLink> vagy <matrixLink>Matrix-on</matrixLink>.\",\n    \"notifications_priority_x\": \"Prioritás {{prioritás}}\",\n    \"message_bar_show_dialog\": \"Küldött üzenetek megjelenítése\",\n    \"action_bar_logo_alt\": \"ntfy logó\",\n    \"action_bar_toggle_action_menu\": \"Tevékenységkezelő nyitása/zárása\",\n    \"message_bar_publish\": \"Üzenet küldése\",\n    \"nav_button_muted\": \"Értesítések némítva\",\n    \"nav_button_connecting\": \"csatlakozás\",\n    \"notifications_list\": \"Értesítés lista\",\n    \"notifications_mark_read\": \"Jelölés olvasottként\",\n    \"notifications_delete\": \"Törlés\",\n    \"notifications_new_indicator\": \"Új értesítés\",\n    \"notifications_attachment_image\": \"Csatolt kép\",\n    \"notifications_attachment_file_image\": \"Kép fájl\",\n    \"notifications_attachment_file_video\": \"Videó fájl\",\n    \"notifications_attachment_file_audio\": \"Hang fájl\",\n    \"notifications_attachment_file_app\": \"Android alkalmazás fájl\",\n    \"notifications_attachment_file_document\": \"egyéb dokumentum\",\n    \"publish_dialog_emoji_picker_show\": \"Emoji kiválasztása\",\n    \"publish_dialog_topic_reset\": \"Téma visszaállítása\",\n    \"publish_dialog_click_reset\": \"URL kattintás törlése\",\n    \"publish_dialog_email_reset\": \"Email továbbítás törlése\",\n    \"publish_dialog_attach_reset\": \"Csatolt URL törlése\",\n    \"publish_dialog_delay_reset\": \"Késleltetett kézbesítés törlése\",\n    \"publish_dialog_attached_file_remove\": \"Csatolt fájl törlése\",\n    \"emoji_picker_search_clear\": \"Keresés törlése\",\n    \"prefs_notifications_sound_play\": \"Kijelölt hang lejátszása\",\n    \"prefs_users_table\": \"Felhasználó táblázat\",\n    \"prefs_users_edit_button\": \"Felhasználó szerkesztése\",\n    \"prefs_users_delete_button\": \"Felhasználó törlése\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Privát böngészés nem támogatott\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Szolgáltató URL\",\n    \"signup_form_username\": \"Felhasználónév\",\n    \"signup_form_password\": \"Jelszó\",\n    \"signup_form_button_submit\": \"Regisztráció\",\n    \"login_form_button_submit\": \"Bejelentkezés\",\n    \"login_link_signup\": \"Regisztráció\",\n    \"login_disabled\": \"Bejelentkezés kikapcsolva\",\n    \"action_bar_change_display_name\": \"Megjelenített név módosítása\",\n    \"action_bar_profile_logout\": \"Kijelentkezés\",\n    \"action_bar_sign_in\": \"Bejelentkezés\",\n    \"action_bar_sign_up\": \"Regisztráció\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"nav_button_account\": \"Fiók\",\n    \"common_copy_to_clipboard\": \"Másolás vágólapra\",\n    \"action_bar_reservation_limit_reached\": \"Limit elérve\",\n    \"login_title\": \"Jelentkezz be a ntfy felhasználódba\",\n    \"signup_title\": \"Hozz létre egy ntfy felhasználói fiókot\",\n    \"signup_form_confirm_password\": \"Jelszó megerősítése\",\n    \"signup_already_have_account\": \"Már van felhasználód? Jelentkezz be!\",\n    \"action_bar_account\": \"Fiók\",\n    \"action_bar_profile_settings\": \"Beállítások\",\n    \"signup_error_username_taken\": \"A felhasználónév {{username}} már foglalt\",\n    \"signup_error_creation_limit_reached\": \"Felhasználói regisztráció limit elérve\",\n    \"action_bar_mute_notifications\": \"Értesítések némítása\",\n    \"action_bar_unmute_notifications\": \"Értesítések némításának feloldása\",\n    \"alert_notification_permission_denied_title\": \"Az értesítések blokkolva vannak\",\n    \"alert_notification_permission_denied_description\": \"Kérjük kapcsold őket vissza a böngésződben\",\n    \"alert_notification_ios_install_required_title\": \"iOS telepítés szükséges\",\n    \"alert_not_supported_context_description\": \"Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Notifications API</mdnLink> korlátozása.\",\n    \"signup_form_toggle_password_visibility\": \"Jelszó láthatóságának kapcsolása\",\n    \"signup_disabled\": \"A regisztráció le van tiltva\",\n    \"action_bar_reservation_add\": \"Téma fenntartása\",\n    \"action_bar_reservation_edit\": \"Foglalás módosítása\",\n    \"action_bar_reservation_delete\": \"Foglalás törlése\",\n    \"nav_upgrade_banner_label\": \"Frissítés ntfy Pro-ra\",\n    \"nav_upgrade_banner_description\": \"Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése\",\n    \"alert_notification_ios_install_required_description\": \"Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren\"\n}\n"
  },
  {
    "path": "web/public/static/langs/id.json",
    "content": "{\n    \"notifications_click_copy_url_title\": \"Salin URL tautan ke papan klip\",\n    \"alert_not_supported_title\": \"Notifikasi tidak didukung\",\n    \"notifications_click_copy_url_button\": \"Salin tautan\",\n    \"notifications_no_subscriptions_description\": \"Klik pada tautan \\\"{{linktext}}\\\" untuk membuat atau berlangganan ke sebuah topik. Setelah itu, Anda dapat mengirim pesan via PUT atau POST dan Anda akan menerima notifikasi di sini.\",\n    \"notifications_example\": \"Contoh\",\n    \"subscribe_dialog_subscribe_description\": \"Topik mungkin tidak dilindungi oleh kata sandi, jadi pilih sebuah nama yang tidak mudah untuk ditebak. Setelah berlangganan, Anda dapat PUT/POST notifikasi.\",\n    \"subscribe_dialog_login_title\": \"Login dibutuhkan\",\n    \"prefs_appearance_language_title\": \"Bahasa\",\n    \"nav_button_all_notifications\": \"Semua notifikasi\",\n    \"notifications_none_for_any_title\": \"Anda belum menerima notifikasi apa pun.\",\n    \"action_bar_settings\": \"Pengaturan\",\n    \"action_bar_send_test_notification\": \"Kirim notifikasi uji coba\",\n    \"action_bar_clear_notifications\": \"Hapus semua notifikasi\",\n    \"action_bar_unsubscribe\": \"Batalkan langganan\",\n    \"message_bar_type_message\": \"Ketika sebuah pesan di sini\",\n    \"message_bar_error_publishing\": \"Terjadi kesalahan mempublikasikan notifikasi\",\n    \"publish_dialog_title_label\": \"Judul\",\n    \"publish_dialog_message_label\": \"Pesan\",\n    \"nav_button_settings\": \"Pengaturan\",\n    \"nav_button_documentation\": \"Dokumentasi\",\n    \"common_add\": \"Tambahkan\",\n    \"nav_topics_title\": \"Topik yang dilanggani\",\n    \"nav_button_subscribe\": \"Berlangganan ke topik\",\n    \"alert_notification_permission_required_title\": \"Notifikasi dinonaktifkan\",\n    \"alert_notification_permission_required_description\": \"Berikan izin ke peramban web Anda untuk menampilkan notifikasi desktop\",\n    \"alert_not_supported_description\": \"Notifikasi tidak didukung dalam peramban Anda\",\n    \"notifications_attachment_open_title\": \"Pergi ke {{url}}\",\n    \"notifications_attachment_open_button\": \"Buka lampiran\",\n    \"notifications_attachment_link_expires\": \"tautan kadaluwarsa {{date}}\",\n    \"notifications_attachment_link_expired\": \"tautan unduhan kadaluwarsa\",\n    \"notifications_actions_open_url_title\": \"Pergi ke {{url}}\",\n    \"notifications_click_open_button\": \"Buka tautan\",\n    \"publish_dialog_topic_placeholder\": \"Nama topik, mis. pemberitahuan_andi\",\n    \"nav_button_publish_message\": \"Publikasikan notifikasi\",\n    \"alert_notification_permission_required_button\": \"Berikan sekarang\",\n    \"notifications_copied_to_clipboard\": \"Disalin ke papan klip\",\n    \"notifications_tags\": \"Tanda\",\n    \"notifications_attachment_copy_url_title\": \"Salin URL lampiran ke papan klip\",\n    \"notifications_attachment_copy_url_button\": \"Salin URL\",\n    \"notifications_none_for_topic_title\": \"Anda belum menerima notifikasi apa pun untuk topik ini.\",\n    \"notifications_none_for_topic_description\": \"Untuk mengirimkan notifikasi ke topik ini, tinggal PUT atau POST ke URL topik.\",\n    \"notifications_none_for_any_description\": \"Untuk mengirimkan notifikasi ke sebuah topik, tinggal PUT atau POST ke URL topik. Ini adalah contoh menggunakan salah satu topik Anda.\",\n    \"notifications_no_subscriptions_title\": \"Sepertinya Anda belum memiliki langganan apa pun.\",\n    \"publish_dialog_title_topic\": \"Publikasikan ke {{topic}}\",\n    \"subscribe_dialog_login_description\": \"Topik ini dilindungi oleh kata sandi. Mohon masukkan nama pengguna dan kata sandi untuk berlangganan.\",\n    \"prefs_notifications_min_priority_title\": \"Prioritas minimum\",\n    \"error_boundary_gathering_info\": \"Dapatkan info lanjut …\",\n    \"publish_dialog_title_no_topic\": \"Publikasikan notifikasi\",\n    \"publish_dialog_progress_uploading\": \"Mengunggah …\",\n    \"notifications_more_details\": \"Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.\",\n    \"publish_dialog_progress_uploading_detail\": \"Mengunggah {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Notifikasi dipublikasikan\",\n    \"notifications_loading\": \"Memuat notifikasi …\",\n    \"publish_dialog_base_url_label\": \"URL Layanan\",\n    \"publish_dialog_title_placeholder\": \"Judul notifikasi, contoh: Peringatan ruang penyimpanan disk\",\n    \"publish_dialog_tags_label\": \"Tanda\",\n    \"publish_dialog_priority_label\": \"Prioritas\",\n    \"publish_dialog_base_url_placeholder\": \"URL Layanan, mis. https://contoh.com\",\n    \"publish_dialog_attach_placeholder\": \"Lampirkan file dengan URL, mis. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_delay_label\": \"Jeda\",\n    \"publish_dialog_chip_topic_label\": \"Ubah topik\",\n    \"publish_dialog_button_cancel_sending\": \"Batalkan pengiriman\",\n    \"publish_dialog_button_send\": \"Kirim\",\n    \"publish_dialog_attachment_limits_file_reached\": \"melebihi batasan file {{fileSizeLimit}\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"melebihi batasan file dan kuota {{fileSizeLimit}}, hanya {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"melebihi kuota, hanya {{remainingBytes}}\",\n    \"publish_dialog_priority_min\": \"Prioritas minimal\",\n    \"publish_dialog_priority_low\": \"Prioritas rendah\",\n    \"publish_dialog_priority_default\": \"Prioritas bawaan\",\n    \"publish_dialog_priority_high\": \"Prioritas tinggi\",\n    \"publish_dialog_priority_max\": \"Prioritas maksimal\",\n    \"publish_dialog_topic_label\": \"Nama topik\",\n    \"publish_dialog_message_placeholder\": \"Tulis pesan di sini\",\n    \"publish_dialog_click_label\": \"Klik URL\",\n    \"publish_dialog_tags_placeholder\": \"Daftar label yang dipisahkan koma, contoh: peringatan, cadangan-srv1\",\n    \"publish_dialog_click_placeholder\": \"URL yang dibuka ketika notifikasi diklik\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Alamat untuk meneruskan notifikasi, contoh: phil@example.com\",\n    \"publish_dialog_attach_label\": \"URL Lampiran\",\n    \"publish_dialog_filename_label\": \"Nama File\",\n    \"publish_dialog_filename_placeholder\": \"Nama file lampiran\",\n    \"publish_dialog_delay_placeholder\": \"Penjedaan pengiriman, mis. {{unixTimestamp}}, {{relativeTime}}, atau \\\"{{naturalLanguage}}\\\" (hanya Inggris)\",\n    \"publish_dialog_other_features\": \"Fitur lainnya:\",\n    \"publish_dialog_chip_click_label\": \"Klik URL\",\n    \"publish_dialog_chip_email_label\": \"Teruskan ke email\",\n    \"publish_dialog_chip_attach_url_label\": \"Lampirkan file dengan URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Lampirkan file lokal\",\n    \"publish_dialog_chip_delay_label\": \"Jeda pengiriman\",\n    \"publish_dialog_button_cancel\": \"Batal\",\n    \"publish_dialog_details_examples_description\": \"Untuk contoh dan deskripsi yang rinci oleh semua fitur pengiriman, lihat <docsLink>dokumentasi</docsLink>.\",\n    \"publish_dialog_checkbox_publish_another\": \"Publikasi yang lain\",\n    \"publish_dialog_attached_file_title\": \"File yang dilampirkan:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nama file lampiran\",\n    \"publish_dialog_drop_file_here\": \"Lepaskan file di sini\",\n    \"emoji_picker_search_placeholder\": \"Cari emoji\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Batal\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Berlangganan\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonim\",\n    \"prefs_notifications_min_priority_any\": \"Prioritas apa saja\",\n    \"prefs_notifications_delete_after_title\": \"Hapus notifikasi\",\n    \"prefs_notifications_delete_after_three_hours\": \"Setelah tiga jam\",\n    \"prefs_notifications_delete_after_one_day\": \"Setelah satu hari\",\n    \"prefs_users_add_button\": \"Tambahkan pengguna\",\n    \"prefs_users_dialog_username_label\": \"Nama pengguna, mis. andi\",\n    \"subscribe_dialog_subscribe_title\": \"Berlangganan ke topik\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nama topik, mis. pemberitahuan_andi\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Gunakan server lain\",\n    \"subscribe_dialog_login_username_label\": \"Nama pengguna, mis. Andi\",\n    \"subscribe_dialog_login_button_login\": \"Masuk\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Pengguna {{username}} tidak diizinkan\",\n    \"prefs_notifications_title\": \"Notifikasi\",\n    \"prefs_notifications_sound_no_sound\": \"Tidak ada suara\",\n    \"prefs_users_table_user_header\": \"Pengguna\",\n    \"prefs_users_dialog_base_url_label\": \"URL Layanan, mis. https://ntfy.sh\",\n    \"common_save\": \"Simpan\",\n    \"prefs_appearance_title\": \"Tampilan\",\n    \"subscribe_dialog_login_password_label\": \"Kata sandi\",\n    \"common_back\": \"Kembali\",\n    \"prefs_notifications_sound_title\": \"Suara notifikasi\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Prioritas rendah dan lebih tinggi\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioritas bawaan dan lebih tinggi\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Prioritas tinggi dan lebih tinggi\",\n    \"prefs_notifications_min_priority_max_only\": \"Hanya prioritas maks\",\n    \"prefs_notifications_delete_after_never\": \"Tidak pernah\",\n    \"prefs_notifications_delete_after_one_week\": \"Setelah satu minggu\",\n    \"prefs_notifications_delete_after_one_month\": \"Setelah satu bulan\",\n    \"prefs_users_title\": \"Kelola pengguna\",\n    \"prefs_users_description\": \"Tambahkan/hapus pengguna untuk topik yang dilindungi di sini. Dicatat bahwa nama pengguna dan kata sandi disimpan dalam penyimpanan lokal peramban.\",\n    \"prefs_users_table_base_url_header\": \"URL Layanan\",\n    \"prefs_users_dialog_title_add\": \"Tambahkan pengguna\",\n    \"prefs_users_dialog_title_edit\": \"Edit pengguna\",\n    \"prefs_users_dialog_password_label\": \"Kata sandi\",\n    \"common_cancel\": \"Batal\",\n    \"error_boundary_title\": \"Aduh, ntfy mogok\",\n    \"error_boundary_description\": \"Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_stack_trace\": \"Jejak tumpukan\",\n    \"error_boundary_button_copy_stack_trace\": \"Salin jejak tumpukan\",\n    \"prefs_notifications_sound_description_some\": \"Notifikasi memainkan suara {{sound}} ketika diterima\",\n    \"prefs_notifications_min_priority_description_any\": \"Menampilkan semua notifikasi, apa pun prioritasnya\",\n    \"prefs_notifications_min_priority_description_max\": \"Tampilkan notifikasi jika prioritas adalah 5 (maks)\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Notifikasi dihapus secara otomatis setelah tiga jam\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Notifikasi dihapus secara otomatis setelah satu minggu\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Notifikasi dihapus secara otomatis setelah satu bulan\",\n    \"priority_low\": \"rendah\",\n    \"priority_high\": \"tinggi\",\n    \"priority_max\": \"maks\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Tampilkan notifikasi jika prioritas {{number}} ({{name}}) atau lebih\",\n    \"prefs_notifications_sound_description_none\": \"Notifikasi tidak boleh memainkan suara apa pun ketika diterima\",\n    \"prefs_notifications_delete_after_never_description\": \"Notifikasi tidak pernah dihapus secara otomatis\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Notifikasi dihapus secara otomatis setelah satu hari\",\n    \"priority_default\": \"bawaan\",\n    \"priority_min\": \"min\",\n    \"notifications_actions_not_supported\": \"Tindakan tidak didukung di aplikasi web\",\n    \"notifications_actions_http_request_title\": \"Kirim {{method}} HTTP ke {{url}}\",\n    \"action_bar_show_menu\": \"Tampilkan menu\",\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_toggle_mute\": \"Bisu/suarakan notifikasi\",\n    \"action_bar_toggle_action_menu\": \"Buka/tutup menu tindakan\",\n    \"message_bar_show_dialog\": \"Tampilkan dialog publikasi\",\n    \"message_bar_publish\": \"Publikasikan pesan\",\n    \"nav_button_muted\": \"Notifikasi dibisukan\",\n    \"nav_button_connecting\": \"menghubungkan\",\n    \"notifications_list\": \"Daftar notifikasi\",\n    \"notifications_list_item\": \"Notifikasi\",\n    \"notifications_mark_read\": \"Tandai sebagai dibaca\",\n    \"notifications_delete\": \"Hapus\",\n    \"notifications_priority_x\": \"Prioritas {{priority}}\",\n    \"notifications_new_indicator\": \"Notifikasi baru\",\n    \"notifications_attachment_image\": \"Lampiran gambar\",\n    \"notifications_attachment_file_image\": \"file gambar\",\n    \"notifications_attachment_file_video\": \"file\",\n    \"notifications_attachment_file_audio\": \"file audio\",\n    \"notifications_attachment_file_app\": \"file aplikasi Android\",\n    \"notifications_attachment_file_document\": \"dokumen lainnya\",\n    \"publish_dialog_emoji_picker_show\": \"Pilih emoji\",\n    \"publish_dialog_topic_reset\": \"Atur ulang topik\",\n    \"publish_dialog_click_reset\": \"Hapus URL klik\",\n    \"publish_dialog_email_reset\": \"Hapus terusan email\",\n    \"publish_dialog_attach_reset\": \"Hapus URL lampiran\",\n    \"publish_dialog_delay_reset\": \"Hapus pengiriman telat\",\n    \"publish_dialog_attached_file_remove\": \"Hapus file yang dilampirkan\",\n    \"emoji_picker_search_clear\": \"Hapus pencarian\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL layanan\",\n    \"prefs_notifications_sound_play\": \"Mainkan suara yang dipilih\",\n    \"prefs_users_table\": \"Tabel pengguna\",\n    \"prefs_users_edit_button\": \"Edit pengguna\",\n    \"prefs_users_delete_button\": \"Hapus pengguna\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.<br/><br/>Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya <githubLink>di masalah GitHub ini</githubLink>, atau berbicara dengan kami di <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Penjelajahan privat tidak didukung\",\n    \"signup_form_confirm_password\": \"Konfirmasi kata sandi\",\n    \"signup_form_button_submit\": \"Daftar\",\n    \"signup_form_toggle_password_visibility\": \"Alih keterlihatan kata sandi\",\n    \"signup_already_have_account\": \"Sudah punya akun? Masuk!\",\n    \"signup_disabled\": \"Pendaftaran dinonaktifkan\",\n    \"signup_error_username_taken\": \"Nama pengguna {{username}} telah digunakan\",\n    \"signup_error_creation_limit_reached\": \"Batasan pembuatan akun tercapai\",\n    \"login_title\": \"Masuk ke akun ntfy Anda\",\n    \"login_disabled\": \"Pemasukan dinonaktifkan\",\n    \"action_bar_account\": \"Akun\",\n    \"action_bar_change_display_name\": \"Ubah nama tampilan\",\n    \"action_bar_reservation_add\": \"Reservasi topik\",\n    \"action_bar_reservation_edit\": \"Ubah reservasi\",\n    \"action_bar_reservation_delete\": \"Hapus reservasi\",\n    \"action_bar_reservation_limit_reached\": \"Batasan tercapai\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Pengaturan\",\n    \"action_bar_profile_logout\": \"Keluar\",\n    \"nav_button_account\": \"Akun\",\n    \"display_name_dialog_placeholder\": \"Nama tampilan\",\n    \"reserve_dialog_checkbox_label\": \"Reservasi topik dan atur akses\",\n    \"nav_upgrade_banner_description\": \"Reservasikan topik, lebih banyak pesan & surel, dan lampiran lebih besar\",\n    \"signup_title\": \"Buat sebuah akun ntfy\",\n    \"signup_form_password\": \"Kata sandi\",\n    \"login_link_signup\": \"Daftar\",\n    \"action_bar_sign_up\": \"Daftar\",\n    \"signup_form_username\": \"Nama pengguna\",\n    \"login_form_button_submit\": \"Masuk\",\n    \"action_bar_sign_in\": \"Masuk\",\n    \"nav_upgrade_banner_label\": \"Tingkatkan ke ntfy Pro\",\n    \"alert_not_supported_context_description\": \"Notifikasi hanya didukung melalui HTTPS. Ini adalah batasan <mdnLink>API Notifikasi</mdnLink>.\",\n    \"display_name_dialog_title\": \"Ubah nama tampilan\",\n    \"display_name_dialog_description\": \"Tetapkan nama alternatif untuk sebuah topik yang ditampilkan di daftar langganan. Ini membantu mengidentifikasi topik dengan nama yang rumit dengan lebih mudah.\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Topik sudah direservasi\",\n    \"account_basics_username_title\": \"Nama pengguna\",\n    \"account_basics_username_admin_tooltip\": \"Anda adalah Admin\",\n    \"account_basics_password_title\": \"Kata sandi\",\n    \"account_basics_password_description\": \"Ubah kata sandi akun Anda\",\n    \"account_basics_password_dialog_title\": \"Ubah kata sandi\",\n    \"account_basics_password_dialog_current_password_label\": \"Kata sandi saat ini\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Konfirmasi kata sandi\",\n    \"account_basics_password_dialog_button_submit\": \"Ubah kata sandi\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Kata sandi salah\",\n    \"account_usage_title\": \"Penggunaan\",\n    \"account_usage_of_limit\": \"dari {{limit}}\",\n    \"account_usage_unlimited\": \"Tidak terbatas\",\n    \"account_usage_limits_reset_daily\": \"Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)\",\n    \"account_basics_tier_title\": \"Jenis akun\",\n    \"account_basics_tier_description\": \"Tingkat daya akun Anda\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(tidak ada peringkat)\",\n    \"account_basics_tier_basic\": \"Dasaran\",\n    \"account_basics_tier_change_button\": \"Ubah\",\n    \"account_basics_tier_paid_until\": \"Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis\",\n    \"account_basics_tier_canceled_subscription\": \"Langganan Anda dibatalkan dan akan diturunkan ke akun gratis pada {{date}}.\",\n    \"account_usage_messages_title\": \"Pesan terkirim\",\n    \"account_usage_emails_title\": \"Surel terkirim\",\n    \"account_usage_reservations_title\": \"Topik yang telah direservasi\",\n    \"account_usage_reservations_none\": \"Tidak ada topik yang telah direservasi untuk akun ini\",\n    \"account_usage_attachment_storage_title\": \"Penyimpanan lampiran\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} per berkas, dihapus setelah {{expiry}}\",\n    \"account_delete_title\": \"Hapus akun\",\n    \"account_delete_description\": \"Hapus akun Anda secara permanen\",\n    \"account_delete_dialog_label\": \"Kata sandi\",\n    \"account_delete_dialog_button_cancel\": \"Batal\",\n    \"account_delete_dialog_button_submit\": \"Hapus akun secara permanen\",\n    \"account_usage_cannot_create_portal_session\": \"Tidak dapat membuka portal tagihan\",\n    \"account_delete_dialog_billing_warning\": \"Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.\",\n    \"account_upgrade_dialog_title\": \"Ubah peringkat akun\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Prorasi</strong>: Saat melakukan upgrade antar paket berbayar, selisih harga akan <strong>langsung dibebankan ke</strong>. Saat menurunkan ke tingkat yang lebih rendah, saldo akan digunakan untuk membayar periode penagihan di masa mendatang.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, <strong>silakan menghapus setidaknya {{count}} reservasi</strong>. Anda dapat menghapus reservasi di <Link>Pengaturan</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} topik yang telah direservasi\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} pesan harian\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} surel harian\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per berkas\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} jumlah penyimpanan\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Dipilih\",\n    \"account_upgrade_dialog_tier_current_label\": \"Saat ini\",\n    \"account_upgrade_dialog_button_cancel\": \"Batal\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Daftar sekarang\",\n    \"account_upgrade_dialog_button_pay_now\": \"Bayar sekaramg dan berlangganan\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Batalkan langganan\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Perbarui langganan\",\n    \"account_tokens_title\": \"Token akses\",\n    \"account_tokens_description\": \"Gunakan token akses saat mengirim dan berlangganan melalui API ntfy, sehingga Anda tidak perlu mengirimkan kredensial akun Anda. Lihat <Link>dokumentasi</Link> untuk mempelajari lebih lanjut.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Label\",\n    \"account_tokens_table_last_access_header\": \"Akses terakhir\",\n    \"account_tokens_table_expires_header\": \"Kedaluwarsa\",\n    \"account_tokens_table_never_expires\": \"Tidak pernah kedaluwarsa\",\n    \"account_tokens_table_current_session\": \"Sesi peramban saat ini\",\n    \"common_copy_to_clipboard\": \"Salin ke papan klip\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token akses disalin\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Tidak dapat menyunting atau menghapus token sesi saat ini\",\n    \"account_tokens_table_create_token_button\": \"Buat token akses\",\n    \"account_tokens_dialog_expires_unchanged\": \"Tinggalkan tanggal kedaluwarsa tidak terganti\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token kedaluwarsa dalam {{hours}} jam\",\n    \"account_tokens_dialog_expires_x_days\": \"Token kedaluwarsa dalam {{days}} hari\",\n    \"account_tokens_dialog_expires_never\": \"Token tidak pernah kedaluwarsa\",\n    \"account_tokens_delete_dialog_title\": \"Hapus token akses\",\n    \"account_tokens_delete_dialog_description\": \"Sebelum menghapus sebuah token akses, pastikan bahwa tidak ada aplikasi atau skrip yang sedang menggunakannya secara aktif. <strong>Tindakan ini tidak dapat diurungkan</strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Hapus token secara permanan\",\n    \"prefs_reservations_title\": \"Topik yang direservasi\",\n    \"reservation_delete_dialog_action_keep_title\": \"Jaga tembolok pesan dan lampiran\",\n    \"reservation_delete_dialog_action_keep_description\": \"Tembolok pesan dan lampiran yang berada di server akan terlihat secara publik untuk orang-orang dengan pengetahuan nama topik.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Hapus tembolok pesan dan lampiran\",\n    \"reservation_delete_dialog_action_delete_description\": \"Tembolok pesan dan lampiran akan dihapus secara permanen. Tindakan ini tidak dapat diurungkan.\",\n    \"reservation_delete_dialog_submit_button\": \"Hapus reservasi\",\n    \"prefs_reservations_table_everyone_read_only\": \"Saya dapat mengirim dan berlangganan, semuanya dapat berlangganan\",\n    \"prefs_reservations_dialog_title_edit\": \"Sunting reservasi topik\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Buat nama\",\n    \"account_basics_title\": \"Akun\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(dengan peringkat {{tier}})\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_tokens_dialog_expires_label\": \"Token akses kedaluwarsa dalam\",\n    \"account_basics_username_description\": \"Hei, itu Anda ❤\",\n    \"account_basics_password_dialog_new_password_label\": \"Kata sandi baru\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"account_basics_tier_upgrade_button\": \"Tingkatkan ke Pro\",\n    \"account_basics_tier_payment_overdue\": \"Pembayaran Anda telah jatuh tempo. Mohon perbarui metode pembayaran Anda, atau akun Anda akan segera diturunkan.\",\n    \"account_basics_tier_manage_billing_button\": \"Kelola pembayaran\",\n    \"account_tokens_dialog_title_delete\": \"Hapus token akses\",\n    \"account_usage_basis_ip_description\": \"Statistik dan batasan pengguna untuk akun ini berdasarkan alamat IP Anda, sehingga mereka mungkin terbagi dengan pengguna lain. Batasan yang ditampilkan di atas adalah perkiraan berdasarkan batas tarif yang sudah ada.\",\n    \"account_delete_dialog_description\": \"Ini akan menghapus akun Anda secara permanen, termasuk semua data yang telah disimpan di server ini. Setelah penghapusan, nama pengguna Anda akan tidak tersedia selama 7 hari. Jika Anda ingin melanjutkan, silakan mengonfirmasi dengan kata sandi Anda di kotak bawah.\",\n    \"account_upgrade_dialog_cancel_warning\": \"Ini akan <strong>membatalkan langganan Anda</strong>, dan menurunkan akun Anda pada tanggal {{date}}. Pada tanggal itu, reservasi topik maupun tembolok pesan di server <strong>akan dihapus</strong>.\",\n    \"prefs_reservations_table_everyone_write_only\": \"Saya dapat mengirim dan berlangganan, semuanya dapat mengirim\",\n    \"account_tokens_table_last_origin_tooltip\": \"Dari alamat IP {{ip}}, klik untuk melihat\",\n    \"account_tokens_dialog_label\": \"Label, mis. notifikasi Radarr\",\n    \"account_tokens_dialog_button_create\": \"Buat token\",\n    \"prefs_reservations_description\": \"Anda dapat mereservasi nama topik untuk penggunaan pribadi di sini. Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, <strong>silakan menghapus setidaknya satu reservasi</strong>. Anda dapat menghapus reservasi di <Link>Pengaturan</Link>.\",\n    \"account_tokens_dialog_button_cancel\": \"Batal\",\n    \"account_tokens_dialog_title_create\": \"Buat token akses\",\n    \"account_tokens_dialog_title_edit\": \"Sunting token akses\",\n    \"account_tokens_dialog_button_update\": \"Perbarui token\",\n    \"prefs_reservations_add_button\": \"Tambahkan reservasi topik\",\n    \"prefs_reservations_table\": \"Tabel topik yang telah direservasi\",\n    \"prefs_reservations_table_topic_header\": \"Topik\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Tidak dapat menghapus atau menyunting pengguna yang telah masuk\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Hanya saya yang dapat mengirim dan berlangganan\",\n    \"prefs_reservations_table_everyone_read_write\": \"Semuanya dapat mengirim dan berlangganan\",\n    \"prefs_users_description_no_sync\": \"Pengguna dan kata sandi tidak disinkronkan ke akun Anda.\",\n    \"prefs_reservations_limit_reached\": \"Anda telah mencapai batasan reservasi topik.\",\n    \"prefs_reservations_edit_button\": \"Sunting akses topik\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klik untuk berlangganan\",\n    \"prefs_reservations_delete_button\": \"Atur ulang akses topik\",\n    \"prefs_reservations_table_access_header\": \"Akses\",\n    \"prefs_reservations_dialog_title_add\": \"Reservasi topik\",\n    \"prefs_reservations_dialog_title_delete\": \"Hapus reservasi topik\",\n    \"prefs_reservations_table_not_subscribed\": \"Tidak berlangganan\",\n    \"prefs_reservations_dialog_description\": \"Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.\",\n    \"prefs_reservations_dialog_topic_label\": \"Topik\",\n    \"prefs_reservations_dialog_access_label\": \"Akses\",\n    \"reservation_delete_dialog_description\": \"Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya.\",\n    \"account_upgrade_dialog_interval_yearly\": \"Setiap tahun\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"Ditagih {{price}} setiap tahun. Hemat {{save}}.\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"hemat {{discount}}%\",\n    \"account_upgrade_dialog_interval_monthly\": \"Setiap bulan\",\n    \"account_basics_tier_interval_monthly\": \"setiap bulan\",\n    \"account_basics_tier_interval_yearly\": \"setiap tahun\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"hemat sampai {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Tidak ada topik yang direservasi\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"bulan\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} per bulan. Ditagih setiap bulan.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Untuk pertanyaan penagihan, silakan <Link>hubungi kami</Link> secara langsung.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Untuk pertanyaan penagihan, silakan menuju ke <Link>situs web</Link> kami.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} topik yang direservasi\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} surel harian\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} pesan harian\",\n    \"publish_dialog_call_label\": \"Panggilan telepon\",\n    \"publish_dialog_call_placeholder\": \"Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'\",\n    \"account_basics_phone_numbers_title\": \"Nomor telepon\",\n    \"account_basics_phone_numbers_dialog_description\": \"Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Belum ada nomor telepon\",\n    \"account_basics_phone_numbers_dialog_title\": \"Tambahkan nomor telepon\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Nomor telepon\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"mis. +62222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Kirim SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Panggil\",\n    \"account_usage_calls_title\": \"Panggilan telepon dilakukan\",\n    \"account_usage_calls_none\": \"Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} panggilan telepon harian\",\n    \"publish_dialog_call_reset\": \"Hapus panggilan telepon\",\n    \"account_basics_phone_numbers_description\": \"Untuk notifikasi panggilan telepon\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Nomor telepon disalin ke papan klip\",\n    \"publish_dialog_chip_call_label\": \"Panggilan telepon\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Panggil saya\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"mis. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Konfirmasi kode\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} panggilan telepon harian\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Tidak ada panggilan telepon\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Kode verifikasi\",\n    \"publish_dialog_call_item\": \"Panggil nomor telepon {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Tidak ada nomor telepon terverifikasi\",\n    \"action_bar_unmute_notifications\": \"Nyalakan notifikasi\",\n    \"alert_notification_permission_denied_title\": \"Notifikasi sedang diblokir\",\n    \"alert_notification_permission_denied_description\": \"Silakan aktifkan lagi dalam peramban Anda\",\n    \"alert_notification_ios_install_required_title\": \"Pemasangan iOS diperlukan\",\n    \"alert_notification_ios_install_required_description\": \"Klik ikon Bagikan dan Tambahkan ke Layar Beranda untuk mengaktifkan notifikasi di iOS\",\n    \"notifications_actions_failed_notification\": \"Tindakan tidak berhasil\",\n    \"publish_dialog_checkbox_markdown\": \"Format sebagai Markdown\",\n    \"prefs_notifications_web_push_title\": \"Notifikasi latar belakang\",\n    \"prefs_notifications_web_push_enabled_description\": \"Notifikasi diterima bahkan ketika aplikasi web tidak berjalan (melalui Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Notifikasi diterima ketika aplikasi web berjalan (melalui WebSocket)\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"error_boundary_button_reload_ntfy\": \"Muat ulang ntfy\",\n    \"action_bar_mute_notifications\": \"Matikan notifikasi\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Notifikasi dari server lain tidak akan diterima ketika aplikasi web tidak buka\",\n    \"prefs_notifications_web_push_enabled\": \"Diaktifkan untuk {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Dinonaktifkan\",\n    \"prefs_appearance_theme_dark\": \"Mode gelap\",\n    \"prefs_appearance_theme_system\": \"Sistem (bawaan)\",\n    \"prefs_appearance_theme_light\": \"Mode terang\",\n    \"web_push_subscription_expiring_title\": \"Notifikasi akan dijeda\",\n    \"web_push_subscription_expiring_body\": \"Buka ntfy untuk terus menerima notifikasi\",\n    \"web_push_unknown_notification_title\": \"Notifikasi yang tidak diketahui diterima dari server\",\n    \"web_push_unknown_notification_body\": \"Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Tidak dapat mengedit atau menghapus token yang disediakan\"\n}\n"
  },
  {
    "path": "web/public/static/langs/it.json",
    "content": "{\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_settings\": \"Impostazioni\",\n    \"action_bar_clear_notifications\": \"Cancella tutte le notifiche\",\n    \"action_bar_unsubscribe\": \"Annulla l'iscrizione\",\n    \"action_bar_toggle_action_menu\": \"Apri/chiudi il menu delle azioni\",\n    \"message_bar_type_message\": \"Digita un messaggio qui\",\n    \"message_bar_error_publishing\": \"Errore durante la pubblicazione della notifica\",\n    \"message_bar_show_dialog\": \"Mostra la finestra di dialogo di pubblicazione\",\n    \"message_bar_publish\": \"Pubblica messaggio\",\n    \"nav_topics_title\": \"Argomenti a cui si è iscritti\",\n    \"nav_button_all_notifications\": \"Tutte le notifiche\",\n    \"nav_button_settings\": \"Impostazioni\",\n    \"nav_button_publish_message\": \"Pubblica notifica\",\n    \"nav_button_subscribe\": \"Iscriviti all'argomento\",\n    \"nav_button_muted\": \"Notifiche disattivate\",\n    \"nav_button_connecting\": \"connessione\",\n    \"alert_notification_permission_required_title\": \"Le notifiche sono disabilitate\",\n    \"alert_notification_permission_required_button\": \"Concedi ora\",\n    \"notifications_list\": \"Elenco notifiche\",\n    \"notifications_list_item\": \"Notifica\",\n    \"notifications_mark_read\": \"Segna come letto\",\n    \"notifications_delete\": \"Elimina\",\n    \"notifications_copied_to_clipboard\": \"Copiato negli appunti\",\n    \"notifications_tags\": \"Tags\",\n    \"notifications_priority_x\": \"Priorità {{priority}}\",\n    \"notifications_new_indicator\": \"Nuova notifica\",\n    \"notifications_attachment_image\": \"Immagine allegata\",\n    \"notifications_attachment_copy_url_title\": \"Copia l'URL dell'allegato negli appunti\",\n    \"notifications_attachment_copy_url_button\": \"Copia URL\",\n    \"notifications_attachment_open_title\": \"Vai a {{url}}\",\n    \"notifications_attachment_open_button\": \"Apri allegato\",\n    \"notifications_attachment_link_expires\": \"Il collegamento scade il {{date}}\",\n    \"notifications_attachment_link_expired\": \"collegamento per il download scaduto\",\n    \"notifications_attachment_file_image\": \"file immagine\",\n    \"notifications_attachment_file_video\": \"file video\",\n    \"action_bar_toggle_mute\": \"Abilita/disabilita le notifiche\",\n    \"notifications_attachment_file_document\": \"altro documento\",\n    \"notifications_click_copy_url_button\": \"Copia collegamento\",\n    \"notifications_click_open_button\": \"Apri collegamento\",\n    \"notifications_actions_open_url_title\": \"Vai a {{url}}\",\n    \"notifications_actions_not_supported\": \"Azione non supportata nell'app Web\",\n    \"notifications_none_for_topic_title\": \"Non hai ancora ricevuto alcuna notifica per questo argomento.\",\n    \"notifications_none_for_topic_description\": \"Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL dell'argomento.\",\n    \"notifications_none_for_any_title\": \"Non hai ricevuto alcuna notifica.\",\n    \"notifications_no_subscriptions_title\": \"Sembra che tu non abbia ancora abbonamenti.\",\n    \"notifications_example\": \"Esempio\",\n    \"notifications_more_details\": \"Per ulteriori informazioni, consulta il <websiteLink>sito web</websiteLink> o <docsLink>documentazione</docsLink>.\",\n    \"notifications_loading\": \"Caricamento notifiche in corso…\",\n    \"publish_dialog_title_topic\": \"Pubblica su {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Pubblica notifica\",\n    \"publish_dialog_progress_uploading\": \"Caricamento in corso…\",\n    \"publish_dialog_progress_uploading_detail\": \"Caricamento {{loaded}}/{{total}} ({{percent}}%)…\",\n    \"publish_dialog_message_published\": \"Notifica pubblicata\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"supera {{fileSizeLimit}} limite di file e quota, {{remainingBytes}} rimanenti\",\n    \"publish_dialog_attachment_limits_file_reached\": \"supera di {{fileSizeLimit}} il limite dei file\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"supera la quota, {{remainingBytes}} rimanenti\",\n    \"publish_dialog_emoji_picker_show\": \"Scegli emoji\",\n    \"publish_dialog_priority_min\": \"Min. priorità\",\n    \"publish_dialog_priority_low\": \"Bassa priorità\",\n    \"publish_dialog_priority_default\": \"Priorità predefinita\",\n    \"publish_dialog_priority_high\": \"Priorità alta\",\n    \"publish_dialog_priority_max\": \"Max. priorità\",\n    \"publish_dialog_base_url_label\": \"URL del servizio\",\n    \"publish_dialog_base_url_placeholder\": \"URL del servizio, ad es. https://esempio.com\",\n    \"publish_dialog_topic_label\": \"Nome argomento\",\n    \"publish_dialog_topic_placeholder\": \"Nome argomento, ad es. avvisi_di_phil\",\n    \"publish_dialog_topic_reset\": \"Reimposta argomento\",\n    \"publish_dialog_title_label\": \"Titolo\",\n    \"publish_dialog_title_placeholder\": \"Titolo della notifica, ad es. Avviso di spazio su disco\",\n    \"publish_dialog_message_label\": \"Messaggio\",\n    \"publish_dialog_message_placeholder\": \"Digita un messaggio qui\",\n    \"publish_dialog_tags_label\": \"Tags\",\n    \"publish_dialog_priority_label\": \"Priorità\",\n    \"publish_dialog_click_label\": \"Clicca URL\",\n    \"publish_dialog_click_reset\": \"Rimuovi l'URL del clic\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Indirizzo a cui inoltrare la notifica, ad es. phil@example.com\",\n    \"publish_dialog_email_reset\": \"Rimuovi inoltro email\",\n    \"publish_dialog_attach_label\": \"URL Allegato\",\n    \"publish_dialog_attach_reset\": \"Rimuovi l'URL dell'allegato\",\n    \"publish_dialog_filename_label\": \"Nome del file\",\n    \"publish_dialog_filename_placeholder\": \"Nome file allegato\",\n    \"publish_dialog_delay_placeholder\": \"Consegna ritardata, ad es. {{unixTimestamp}}, {{relativeTime}} o \\\"{{naturalLanguage}}\\\" (solo in inglese)\",\n    \"publish_dialog_delay_reset\": \"Rimuovere la consegna ritardata\",\n    \"publish_dialog_other_features\": \"Altre funzionalità:\",\n    \"publish_dialog_chip_click_label\": \"Fare clic su URL\",\n    \"publish_dialog_chip_email_label\": \"Inoltra a e-mail\",\n    \"publish_dialog_chip_attach_url_label\": \"Allega il file tramite URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Allega file locale\",\n    \"publish_dialog_chip_delay_label\": \"Ritardo nella consegna\",\n    \"publish_dialog_button_cancel_sending\": \"Annulla l'invio\",\n    \"publish_dialog_button_cancel\": \"Annulla\",\n    \"publish_dialog_button_send\": \"Invia\",\n    \"publish_dialog_checkbox_publish_another\": \"Pubblica un altro\",\n    \"publish_dialog_attached_file_title\": \"File allegato:\",\n    \"publish_dialog_attached_file_remove\": \"Rimuovi il file allegato\",\n    \"publish_dialog_drop_file_here\": \"Trascina il file qui\",\n    \"emoji_picker_search_clear\": \"Cancella ricerca\",\n    \"subscribe_dialog_subscribe_title\": \"Iscriviti all'argomento\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nome dell'argomento, ad es. avvisi_di_phil\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL del servizio\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Annulla\",\n    \"subscribe_dialog_login_title\": \"Accesso richiesto\",\n    \"subscribe_dialog_login_username_label\": \"Nome utente, ad es. phil\",\n    \"subscribe_dialog_login_button_login\": \"Accesso\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonimo\",\n    \"prefs_notifications_sound_title\": \"Suono di notifica\",\n    \"prefs_notifications_sound_description_some\": \"Le notifiche riproducono il suono {{sound}} quando arrivano\",\n    \"prefs_notifications_sound_no_sound\": \"Nessun suono\",\n    \"prefs_notifications_min_priority_description_any\": \"Visualizzazione di tutte le notifiche, indipendentemente dalla priorità\",\n    \"prefs_notifications_min_priority_description_max\": \"Mostra notifiche se la priorità è 5 (max)\",\n    \"prefs_notifications_min_priority_any\": \"Qualsiasi priorità\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Priorità bassa e superiore\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Priorità alta e superiore\",\n    \"prefs_notifications_min_priority_max_only\": \"Solo priorità massima\",\n    \"prefs_notifications_delete_after_never\": \"Mai\",\n    \"prefs_notifications_delete_after_three_hours\": \"Dopo tre ore\",\n    \"prefs_notifications_delete_after_one_day\": \"Dopo un giorno\",\n    \"prefs_notifications_delete_after_never_description\": \"Le notifiche non vengono mai eliminate automaticamente\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Le notifiche vengono eliminate automaticamente dopo un giorno\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Le notifiche vengono eliminate automaticamente dopo una settimana\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Le notifiche vengono eliminate automaticamente dopo un mese\",\n    \"prefs_users_title\": \"Gestisci gli utenti\",\n    \"prefs_users_description\": \"Aggiungi/rimuovi utenti per i tuoi argomenti protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.\",\n    \"prefs_users_table\": \"Tabella utenti\",\n    \"prefs_users_add_button\": \"Aggiungi utente\",\n    \"prefs_users_edit_button\": \"Modifica utente\",\n    \"prefs_users_delete_button\": \"Elimina utente\",\n    \"prefs_users_table_user_header\": \"Utente\",\n    \"prefs_users_table_base_url_header\": \"URL del servizio\",\n    \"prefs_users_dialog_title_add\": \"Aggiungi utente\",\n    \"prefs_users_dialog_title_edit\": \"Modifica utente\",\n    \"prefs_users_dialog_base_url_label\": \"URL del servizio, ad es. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Nome utente, ad es. phil\",\n    \"prefs_users_dialog_password_label\": \"Password\",\n    \"common_cancel\": \"Annulla\",\n    \"common_add\": \"Aggiungere\",\n    \"common_save\": \"Salva\",\n    \"prefs_appearance_title\": \"Aspetto\",\n    \"prefs_appearance_language_title\": \"Lingua\",\n    \"priority_min\": \"min\",\n    \"priority_low\": \"basso\",\n    \"priority_default\": \"predefinito\",\n    \"priority_high\": \"alto\",\n    \"priority_max\": \"max\",\n    \"error_boundary_title\": \"Oh no, ntfy è andato in crash\",\n    \"error_boundary_description\": \"Questo ovviamente non dovrebbe accadere. Mi dispiace molto per questo.<br/>Se hai un minuto, per favore <githubLink>segnala su GitHub</githubLink>, o faccelo sapere tramite <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink> .\",\n    \"error_boundary_button_copy_stack_trace\": \"Copia traccia dello stack\",\n    \"error_boundary_stack_trace\": \"Traccia dello stack\",\n    \"error_boundary_gathering_info\": \"Raccogli più informazioni…\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Navigazione privata non supportata\",\n    \"action_bar_show_menu\": \"Mostra menu\",\n    \"action_bar_send_test_notification\": \"Inviare una notifica di prova\",\n    \"alert_not_supported_description\": \"Le notifiche non sono supportate nel tuo browser\",\n    \"nav_button_documentation\": \"Documentazione\",\n    \"notifications_actions_http_request_title\": \"Invia HTTP {{method}} a {{url}}\",\n    \"alert_notification_permission_required_description\": \"Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop\",\n    \"alert_not_supported_title\": \"Notifiche non supportate\",\n    \"notifications_attachment_file_app\": \"file app Android\",\n    \"notifications_no_subscriptions_description\": \"Fai clic sul collegamento \\\"{{linktext}}\\\" per creare o iscriverti a un argomento. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.\",\n    \"notifications_attachment_file_audio\": \"file audio\",\n    \"notifications_none_for_any_description\": \"Per inviare notifiche a un argomento, è sufficiente PUT o POST all'URL dell'argomento. Ecco un esempio utilizzando uno dei tuoi argomenti.\",\n    \"notifications_click_copy_url_title\": \"Copia l'URL del collegamento negli appunti\",\n    \"prefs_notifications_sound_description_none\": \"Le notifiche non emettono alcun suono quando arrivano\",\n    \"publish_dialog_delay_label\": \"Ritardo\",\n    \"publish_dialog_tags_placeholder\": \"Elenco di tag separato da virgole, ad es. avviso, backup-srv1\",\n    \"publish_dialog_click_placeholder\": \"URL che viene aperto quando si fa clic sulla notifica\",\n    \"publish_dialog_attach_placeholder\": \"Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_chip_topic_label\": \"Cambia argomento\",\n    \"publish_dialog_details_examples_description\": \"Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nome file allegato\",\n    \"emoji_picker_search_placeholder\": \"Cerca emoji\",\n    \"subscribe_dialog_subscribe_description\": \"Gli argomenti potrebbero non essere protetti da password, quindi scegli un nome che non sia facile da indovinare. Una volta iscritto, puoi inviare le notifiche tramite PUT/POST.\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Usa un altro server\",\n    \"subscribe_dialog_login_password_label\": \"Password\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Iscriviti\",\n    \"prefs_notifications_sound_play\": \"Riproduci il suono selezionato\",\n    \"prefs_notifications_min_priority_title\": \"Priorità minima\",\n    \"subscribe_dialog_login_description\": \"Questo argomento è protetto da password. Per favore inserisci nome utente e password per iscriverti.\",\n    \"common_back\": \"Indietro\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Utente {{username}} non autorizzato\",\n    \"prefs_notifications_title\": \"Notifiche\",\n    \"prefs_notifications_delete_after_title\": \"Elimina le notifiche\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Priorità predefinita e superiore\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Mostra le notifiche se la priorità è {{number}} ({{name}}) o superiore\",\n    \"prefs_notifications_delete_after_one_week\": \"Dopo una settimana\",\n    \"prefs_notifications_delete_after_one_month\": \"Dopo un mese\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Le notifiche vengono eliminate automaticamente dopo tre ore\",\n    \"error_boundary_unsupported_indexeddb_description\": \"L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.\",\n    \"nav_upgrade_banner_label\": \"Passa alla versione Pro di ntfy\",\n    \"alert_not_supported_context_description\": \"Le Notifiche sono supportate solo tramite HTTPS. Questa è una limitazione delle <mdnLink>Notifications API</mdnLink>.\",\n    \"account_basics_password_dialog_new_password_label\": \"Nuova password\",\n    \"action_bar_profile_logout\": \"Esci\",\n    \"account_basics_tier_interval_monthly\": \"mensile\",\n    \"account_basics_tier_interval_yearly\": \"annuale\",\n    \"account_basics_tier_upgrade_button\": \"Passa alla versione Pro\",\n    \"account_basics_tier_change_button\": \"Cambia\",\n    \"account_basics_tier_paid_until\": \"Abbonamento pagato fino a {{data}}, e si rinnoverà automaticamente\",\n    \"account_basics_tier_payment_overdue\": \"Il pagamento è scaduto. La preghiamo di aggiornare il suo metodo di pagamento, altrimenti il suo account verrà presto declassato.\",\n    \"account_basics_tier_canceled_subscription\": \"L'abbonamento è stato annullato e sarà declassato ad account gratuito a partire dalla {{data}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Gestire la fatturazione\",\n    \"account_usage_messages_title\": \"Messaggi pubblicati\",\n    \"account_usage_reservations_title\": \"Argomenti riservati\",\n    \"account_usage_reservations_none\": \"Non ci sono argomenti riservati per questo account\",\n    \"signup_form_toggle_password_visibility\": \"Imposta la visibilità della password\",\n    \"signup_already_have_account\": \"Hai già un account? Accedi!\",\n    \"signup_disabled\": \"Registrazione disabilitata\",\n    \"signup_title\": \"Crea un account ntfy\",\n    \"signup_form_username\": \"Nome utente\",\n    \"signup_form_password\": \"Password\",\n    \"signup_form_confirm_password\": \"Conferma password\",\n    \"signup_form_button_submit\": \"Registrazione\",\n    \"signup_error_username_taken\": \"Il nome utente {{username}} è già utilizzato\",\n    \"signup_error_creation_limit_reached\": \"Il limite per la creazione di account è stato raggiunto\",\n    \"login_title\": \"Accedi al tuo account ntfy\",\n    \"login_form_button_submit\": \"Accedi\",\n    \"login_link_signup\": \"Registrati\",\n    \"login_disabled\": \"L'accesso è disabilitato\",\n    \"action_bar_account\": \"Account\",\n    \"action_bar_change_display_name\": \"Cambia il nome da visualizzare\",\n    \"action_bar_reservation_limit_reached\": \"Limite raggiunto\",\n    \"action_bar_profile_title\": \"Profilo\",\n    \"action_bar_profile_settings\": \"Impostazioni\",\n    \"action_bar_reservation_add\": \"Riserva un argomento\",\n    \"action_bar_reservation_edit\": \"Modifica l'argomento riservato\",\n    \"action_bar_reservation_delete\": \"Rimuovi l'argomento riservato\",\n    \"action_bar_sign_in\": \"Accedi\",\n    \"action_bar_sign_up\": \"Registrati\",\n    \"nav_button_account\": \"Account\",\n    \"nav_upgrade_banner_description\": \"Riserva argomenti, più messaggi ed e-mail e allegati più grandi\",\n    \"display_name_dialog_description\": \"Imposta un nome alternativo per un argomento che viene visualizzato nell'elenco delle sottoscrizioni. Questo aiuta a identificare più facilmente gli argomenti con nomi complicati.\",\n    \"display_name_dialog_title\": \"Cambia il nome visualizzato\",\n    \"display_name_dialog_placeholder\": \"Nome visualizzato\",\n    \"reserve_dialog_checkbox_label\": \"Riserva un argomento e configura l'accesso\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Genera un nome\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Argomento già in uso\",\n    \"account_basics_title\": \"Account\",\n    \"account_basics_username_title\": \"Nome utente\",\n    \"account_basics_username_admin_tooltip\": \"Sei Amministratore\",\n    \"account_basics_password_title\": \"Password\",\n    \"account_basics_password_description\": \"Cambia la password del tuo account\",\n    \"account_basics_password_dialog_title\": \"Cambia la password\",\n    \"account_basics_password_dialog_current_password_label\": \"Password attuale\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Conferma la password\",\n    \"account_basics_password_dialog_button_submit\": \"Cambia la password\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Password errata\",\n    \"account_usage_title\": \"Utilizzo\",\n    \"account_usage_of_limit\": \"di {{limit}}\",\n    \"account_usage_unlimited\": \"Illimitato\",\n    \"account_usage_limits_reset_daily\": \"I limiti di utilizzo vengono azzerati ogni giorno a mezzanotte (orario UTC)\",\n    \"account_basics_tier_title\": \"Tipo di account\",\n    \"account_basics_tier_description\": \"Permessi del tuo account\",\n    \"account_basics_tier_admin\": \"Amministratore\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(con livello {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(nessun livello)\",\n    \"account_basics_tier_basic\": \"Base\",\n    \"account_basics_tier_free\": \"Gratuito\",\n    \"account_usage_emails_title\": \"Email inviate\",\n    \"account_usage_cannot_create_portal_session\": \"Impossibile aprire il portale di pagamento\",\n    \"account_delete_title\": \"Elimina account\",\n    \"account_basics_username_description\": \"Hey, sei tu ❤\",\n    \"publish_dialog_call_item\": \"Chiama numero {{number}}\",\n    \"common_copy_to_clipboard\": \"Copia negli appunti\",\n    \"publish_dialog_call_label\": \"Chiamata telefonica\",\n    \"publish_dialog_call_reset\": \"Rimuovi chiamata telefonica\",\n    \"publish_dialog_chip_call_label\": \"Chiamata telefonica\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Nessun numero verificato\",\n    \"account_basics_phone_numbers_title\": \"Numeri di telefono\",\n    \"account_basics_phone_numbers_dialog_description\": \"Per usare la funzionalità di notifica tramite chiamata telefonica, devi aggiungere e verificare almeno un numero di telefono. La verifica può essere fatta tramite SMS o chiamata telefonica.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} argomento riservato\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Per domande di fatturazione, <Link>contattaci</Link> direttamente.\",\n    \"account_upgrade_dialog_tier_current_label\": \"Attuale\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Numero di telefono\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Conferma codice\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Invia SMS\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Ancora nessun numero di telefono\",\n    \"account_basics_phone_numbers_dialog_title\": \"Aggiungi un numero di telefono\",\n    \"account_upgrade_dialog_button_cancel\": \"Annulla\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Per domande di fatturazione, visita per favore in nostro <Link>sito</Link>.\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Annulla iscrizione\",\n    \"account_basics_phone_numbers_description\": \"Per notifiche via chiamata\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Numero di telefono copiato negli appunti\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"es. +391234567890\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"es. 123456\",\n    \"account_tokens_title\": \"Token d'accesso\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} all'anno. Addebitato annualmente.\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Chiama\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Iscriviti ora\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} addebitato annualmente. Risparmia {{save}}.\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mese\",\n    \"account_upgrade_dialog_button_pay_now\": \"Paga ora e isciviti\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Codice di verifica\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Chiamami\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Selezionato\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Aggiorna iscrizione\",\n    \"account_usage_attachment_storage_title\": \"Archivio allegati\",\n    \"account_delete_dialog_description\": \"Il tuo account sarà permanentemente eliminato insieme a tutti i tuoi dati presenti sul server. Dopo l'eliminazione, il tuo nome utente non sarà disponibile per 7 giorni. Se desideri davvero procedere, inserisci la tua password nella seguente casella.\",\n    \"account_delete_dialog_button_cancel\": \"Annulla\",\n    \"account_usage_calls_title\": \"Chiamate effettuate\",\n    \"account_delete_description\": \"Elimina permanentemente il tuo account\",\n    \"account_delete_dialog_button_submit\": \"Elimina il tuo account permanentemente\",\n    \"account_usage_basis_ip_description\": \"Le statistiche di utilizzo e i limiti per questo account sono basati sul tuo indirizzo IP, quindi potrebbero essere in condivisione con altri utenti. I limiti mostrati sopra sono approssimazioni basate sui limiti esistenti.\",\n    \"account_usage_calls_none\": \"Questo account non può effettuare chiamate\",\n    \"account_delete_dialog_billing_warning\": \"Eliminando il tuo account perderai immediatamente il tuo abbonamento. Non potrai più accedere alla dashboard di fatturazione.\",\n    \"account_delete_dialog_label\": \"Password\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Nessun argomento riservato\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} messaggi giornalieri\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, <strong> si prega di eliminare almeno una prenotazione</strong>. È possibile rimuovere le prenotazioni nel <Link>Impostazioni</Link>.\",\n    \"alert_notification_permission_denied_title\": \"Le notifiche sono bloccate\",\n    \"alert_notification_permission_denied_description\": \"Per favore riabilitale nel tuo browser\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Le notifiche dagli altri server non saranno ricevute quando la web app non è in esecuzione\",\n    \"error_boundary_button_reload_ntfy\": \"Ricarica ntfy\",\n    \"action_bar_mute_notifications\": \"Silenzia notifiche\",\n    \"action_bar_unmute_notifications\": \"Riattiva audio notifiche\",\n    \"alert_notification_ios_install_required_title\": \"E' richiesta l'installazione di iOS\",\n    \"alert_notification_ios_install_required_description\": \"Fare clic sull'icona Condividi e Aggiungi alla schermata home per abilitare le notifiche su iOS\",\n    \"publish_dialog_checkbox_markdown\": \"Formatta come markdown\",\n    \"account_upgrade_dialog_interval_yearly\": \"Annualmente\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Etichetta\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Impossibile modificare o eliminare il token della sessione corrente\",\n    \"account_tokens_dialog_label\": \"Etichetta, ad esempio Notifiche Radarr\",\n    \"account_tokens_dialog_title_delete\": \"Elimina token di accesso\",\n    \"account_tokens_dialog_title_edit\": \"Modifica token di accesso\",\n    \"account_tokens_dialog_button_create\": \"Crea token\",\n    \"account_tokens_dialog_button_update\": \"Aggiorna token\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} email giornaliere\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} messaggi giornalieri\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per file\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} spazio di archiviazione totale\",\n    \"notifications_actions_failed_notification\": \"Azione non riuscita\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} per file, eliminato dopo {{expiry}}\",\n    \"account_upgrade_dialog_title\": \"Cambia livello account\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensilmente\",\n    \"account_upgrade_dialog_cancel_warning\": \"Questa azione <strong>annullerà il tuo abbonamento</strong> e declasserà il tuo account il {{date}}. In quella data, le prenotazioni degli argomenti e i messaggi memorizzati nella cache del server <strong>verranno eliminati</strong>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Il livello selezionato consente meno argomenti riservati rispetto al livello attuale. Prima di cambiare il livello, <strong>elimina almeno {{count}} prenotazioni</strong>. Puoi rimuovere le prenotazioni nelle <Link>Impostazioni</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} argomenti riservati\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} e-mail giornaliere\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} telefonate giornaliere\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} telefonate giornaliere\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Nessuna telefonata\",\n    \"account_tokens_description\": \"Utilizza i token di accesso quando pubblichi e ti iscrivi tramite l'API ntfy, così non dovrai inviare le credenziali del tuo account. Consulta la <Link>documentazione</Link> per saperne di più.\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token di accesso copiato\",\n    \"account_tokens_table_create_token_button\": \"Crea token di accesso\",\n    \"account_tokens_table_last_origin_tooltip\": \"Dall'indirizzo IP {{ip}}, clicca per cercare\",\n    \"account_tokens_dialog_title_create\": \"Crea token di accesso\",\n    \"account_tokens_dialog_button_cancel\": \"Annulla\",\n    \"web_push_unknown_notification_body\": \"Potrebbe essere necessario aggiornare ntfy aprendo l'app web\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Prorata</strong>: quando si esegue l'aggiornamento tra piani a pagamento, la differenza di prezzo verrà <strong>addebitata immediatamente</strong>. Quando si esegue il ritorna ad un livello inferiore, il saldo verrà utilizzato per pagare i periodi di fatturazione futuri.\",\n    \"account_tokens_table_last_access_header\": \"Ultimo accesso\",\n    \"account_tokens_table_expires_header\": \"Scade\",\n    \"account_tokens_table_never_expires\": \"Non scade mai\",\n    \"account_tokens_table_current_session\": \"Sessione corrente del browser\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"Risparmia fino al {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"conserva {{discount}}%\",\n    \"prefs_users_description_no_sync\": \"Gli utenti e le password non vengono sincronizzati con il tuo account.\",\n    \"prefs_reservations_title\": \"Argomenti riservati\",\n    \"prefs_reservations_table_access_header\": \"Accesso\",\n    \"reservation_delete_dialog_action_delete_title\": \"Elimina i messaggi e gli allegati memorizzati nella cache\",\n    \"reservation_delete_dialog_submit_button\": \"Elimina prenotazione\",\n    \"account_tokens_dialog_expires_label\": \"Il token di accesso scade tra\",\n    \"account_tokens_dialog_expires_unchanged\": \"Lascia la data di scadenza invariata\",\n    \"account_tokens_delete_dialog_submit_button\": \"Elimina definitivamente il token\",\n    \"prefs_reservations_description\": \"Qui puoi riservare i nomi degli argomenti per uso personale. Riservare un argomento ti dà la proprietà dell'argomento e ti consente di definire i permessi di accesso per altri utenti sull'argomento.\",\n    \"prefs_reservations_add_button\": \"Aggiungi argomento riservato\",\n    \"prefs_reservations_edit_button\": \"Modifica accesso argomento\",\n    \"prefs_reservations_delete_button\": \"Reimposta accesso argomento\",\n    \"prefs_reservations_table_everyone_read_only\": \"Posso pubblicare e iscrivermi, tutti possono iscriversi\",\n    \"prefs_reservations_table_not_subscribed\": \"Non iscritto\",\n    \"prefs_reservations_table_everyone_write_only\": \"Posso pubblicare ed iscrivermi, tutti possono pubblicare\",\n    \"prefs_reservations_table_everyone_read_write\": \"Tutti possono pubblicare e iscriversi\",\n    \"prefs_reservations_dialog_title_delete\": \"Elimina prenotazione argomento\",\n    \"prefs_reservations_dialog_description\": \"Prenotando un argomento ne diventi proprietario e puoi definire le autorizzazioni di accesso per altri utenti.\",\n    \"reservation_delete_dialog_action_keep_description\": \"I messaggi e gli allegati memorizzati nella cache del server diventeranno visibili al pubblico per le persone a conoscenza del nome dell'argomento.\",\n    \"reservation_delete_dialog_action_delete_description\": \"I messaggi e gli allegati memorizzati nella cache verranno eliminati definitivamente. Questa azione non può essere annullata.\",\n    \"prefs_reservations_limit_reached\": \"Hai raggiunto il limite di argomenti riservati.\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Clicca per iscriverti\",\n    \"prefs_reservations_dialog_title_add\": \"Prenota argomento\",\n    \"prefs_reservations_dialog_title_edit\": \"Modifica argomento riservato\",\n    \"account_tokens_dialog_expires_x_days\": \"Il token scade tra {{days}} giorni\",\n    \"account_tokens_dialog_expires_never\": \"Il token non scade mai\",\n    \"account_tokens_delete_dialog_title\": \"Elimina token di accesso\",\n    \"account_tokens_delete_dialog_description\": \"Prima di eliminare un token di accesso, assicurati che nessuna applicazione o script lo stia utilizzando attivamente. <strong>Questa azione non può essere annullata</strong>.\",\n    \"prefs_notifications_web_push_title\": \"Notifiche in background\",\n    \"prefs_notifications_web_push_enabled_description\": \"Le notifiche vengono ricevute anche quando l'app Web non è in esecuzione (tramite Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Le notifiche vengono ricevute quando l'app Web è in esecuzione (tramite WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Abilitato per {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Disabilitato\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Impossibile eliminare o modificare l'utente registrato\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_system\": \"Sistema (predefinito)\",\n    \"prefs_appearance_theme_dark\": \"Modalità scura\",\n    \"prefs_appearance_theme_light\": \"Modalità chiara\",\n    \"prefs_reservations_table_topic_header\": \"Argomento\",\n    \"prefs_reservations_dialog_access_label\": \"Accesso\",\n    \"reservation_delete_dialog_description\": \"La rimozione di una prenotazione comporta la rinuncia alla proprietà dell'argomento e consente ad altri di riservarlo. Puoi mantenere o eliminare i messaggi e gli allegati esistenti.\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Solo io posso pubblicare e iscrivermi\",\n    \"prefs_reservations_dialog_topic_label\": \"Argomento\",\n    \"reservation_delete_dialog_action_keep_title\": \"Mantieni i messaggi e gli allegati memorizzati nella cache\",\n    \"web_push_subscription_expiring_title\": \"Le notifiche verranno sospese\",\n    \"web_push_subscription_expiring_body\": \"Apri ntfy per continuare a ricevere notifiche\",\n    \"web_push_unknown_notification_title\": \"Notifica sconosciuta ricevuta dal server\",\n    \"account_tokens_dialog_expires_x_hours\": \"Il token scade tra {{hours}} ore\",\n    \"prefs_reservations_table\": \"Tabella argomenti riservati\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ja.json",
    "content": "{\n    \"message_bar_error_publishing\": \"通知送信エラー\",\n    \"nav_button_all_notifications\": \"全ての通知\",\n    \"nav_button_settings\": \"設定\",\n    \"notifications_click_open_button\": \"リンクを開く\",\n    \"action_bar_send_test_notification\": \"テスト通知を送信\",\n    \"action_bar_clear_notifications\": \"全ての通知を消去\",\n    \"action_bar_unsubscribe\": \"購読解除\",\n    \"nav_button_documentation\": \"ドキュメント\",\n    \"alert_not_supported_description\": \"通知機能はこのブラウザではサポートされていません\",\n    \"notifications_copied_to_clipboard\": \"クリップボードにコピーしました\",\n    \"notifications_example\": \"例\",\n    \"publish_dialog_title_topic\": \"{{topic}}に送信\",\n    \"publish_dialog_title_no_topic\": \"通知を送信\",\n    \"publish_dialog_progress_uploading\": \"アップロード中…\",\n    \"publish_dialog_progress_uploading_detail\": \"アップロード中 {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"通知送信済み\",\n    \"publish_dialog_title_label\": \"タイトル\",\n    \"publish_dialog_filename_label\": \"ファイル名\",\n    \"subscribe_dialog_login_description\": \"このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。\",\n    \"subscribe_dialog_login_username_label\": \"ユーザー名, 例) phil\",\n    \"subscribe_dialog_login_password_label\": \"パスワード\",\n    \"common_back\": \"戻る\",\n    \"subscribe_dialog_login_button_login\": \"ログイン\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"優先度高 およびそれ以上\",\n    \"prefs_notifications_min_priority_max_only\": \"優先度最高のみ\",\n    \"action_bar_settings\": \"設定\",\n    \"message_bar_type_message\": \"メッセージを入力してください\",\n    \"nav_topics_title\": \"購読しているトピック\",\n    \"nav_button_subscribe\": \"トピックを購読\",\n    \"alert_notification_permission_required_description\": \"ブラウザのデスクトップ通知を許可してください\",\n    \"alert_notification_permission_required_button\": \"許可する\",\n    \"notifications_attachment_link_expires\": \"リンクは {{date}} に失効します\",\n    \"notifications_click_copy_url_button\": \"リンクをコピー\",\n    \"notifications_none_for_topic_description\": \"トピックに通知を送信するには、トピックのURLにPUTかPOSTしてください。\",\n    \"nav_button_publish_message\": \"通知を送信\",\n    \"alert_notification_permission_required_title\": \"通知は無効化されています\",\n    \"alert_not_supported_title\": \"通知機能はサポートされていません\",\n    \"notifications_tags\": \"タグ\",\n    \"notifications_attachment_copy_url_button\": \"URLをコピー\",\n    \"notifications_attachment_open_title\": \"{{url}} に移動\",\n    \"notifications_attachment_link_expired\": \"ダウンロードリンクは失効しました\",\n    \"notifications_actions_open_url_title\": \"{{url}} に移動\",\n    \"notifications_attachment_copy_url_title\": \"添付URLをクリップボードにコピー\",\n    \"notifications_attachment_open_button\": \"添付ファイルを開く\",\n    \"notifications_click_copy_url_title\": \"リンクURLをクリップボードにコピー\",\n    \"notifications_none_for_topic_title\": \"このトピックではまだ通知を受信していません。\",\n    \"notifications_no_subscriptions_description\": \"「{{linktext}}」リンクをクリックしてトピックを作成または購読してください。その後、メッセージをPUTまたはPOSTで送信すると通知が受信できます。\",\n    \"publish_dialog_message_label\": \"メッセージ\",\n    \"publish_dialog_email_label\": \"メール\",\n    \"notifications_none_for_any_title\": \"まだ通知を受信していません。\",\n    \"publish_dialog_priority_max\": \"優先度 最高\",\n    \"publish_dialog_button_cancel_sending\": \"送信をキャンセル\",\n    \"publish_dialog_attach_label\": \"添付URL\",\n    \"notifications_none_for_any_description\": \"トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。\",\n    \"notifications_no_subscriptions_title\": \"まだ何も購読していないようです。\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"ファイル制限とクォータ {{fileSizeLimit}} を超えました、残り {{remainingBytes}}\",\n    \"publish_dialog_priority_label\": \"優先度\",\n    \"publish_dialog_click_label\": \"クリックURL\",\n    \"publish_dialog_email_placeholder\": \"通知を転送するアドレス, 例) phil@example.com\",\n    \"notifications_more_details\": \"詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。\",\n    \"publish_dialog_attachment_limits_file_reached\": \"ファイルサイズ制限 {{fileSizeLimit}} を超えました\",\n    \"publish_dialog_priority_min\": \"優先度 最低\",\n    \"publish_dialog_priority_low\": \"優先度 低\",\n    \"publish_dialog_priority_default\": \"優先度 通常\",\n    \"publish_dialog_base_url_label\": \"サービスURL\",\n    \"publish_dialog_other_features\": \"他の機能:\",\n    \"notifications_loading\": \"通知を読み込み中…\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"クォータを超過しました、残り{{remainingBytes}}\",\n    \"publish_dialog_priority_high\": \"優先度 高\",\n    \"publish_dialog_topic_placeholder\": \"トピック名の例 phil_alerts\",\n    \"publish_dialog_title_placeholder\": \"通知タイトル、例: ディスクスペース警告\",\n    \"publish_dialog_message_placeholder\": \"メッセージ本文を入力してください\",\n    \"publish_dialog_tags_label\": \"タグ\",\n    \"publish_dialog_tags_placeholder\": \"コンマ区切りでタグを列挙してください、例: warning, srv1-backup\",\n    \"publish_dialog_topic_label\": \"トピック名\",\n    \"publish_dialog_delay_label\": \"遅延\",\n    \"publish_dialog_click_placeholder\": \"通知をクリックしたときに開くURL\",\n    \"publish_dialog_filename_placeholder\": \"添付ファイルの名称\",\n    \"publish_dialog_button_send\": \"送信\",\n    \"publish_dialog_chip_click_label\": \"Click URL\",\n    \"publish_dialog_chip_email_label\": \"メールに転送\",\n    \"publish_dialog_details_examples_description\": \"送信機能の例や詳細な説明については、<docsLink>ドキュメント</docsLink>を参照してください。\",\n    \"error_boundary_description\": \"明らかに起きてはならないことです。本当に申し訳ありません。<br/>もし時間があれば、<githubLink>GitHubにこれを報告</githubLink>するか、<discordLink>Discord</discordLink>または<matrixLink>Matrix</matrixLink>で我々に知らせて下さい。\",\n    \"publish_dialog_chip_attach_url_label\": \"URLでファイルを添付\",\n    \"publish_dialog_chip_attach_file_label\": \"ローカルファイルを添付\",\n    \"publish_dialog_chip_topic_label\": \"トピックを変更\",\n    \"publish_dialog_chip_delay_label\": \"配信を遅延させる\",\n    \"publish_dialog_attached_file_title\": \"添付ファイル:\",\n    \"publish_dialog_button_cancel\": \"キャンセル\",\n    \"publish_dialog_checkbox_publish_another\": \"送信後開いたままにする\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"添付ファイル名\",\n    \"emoji_picker_search_placeholder\": \"絵文字を検索\",\n    \"subscribe_dialog_subscribe_title\": \"トピックを購読\",\n    \"prefs_users_title\": \"ユーザー管理\",\n    \"publish_dialog_drop_file_here\": \"ここにファイルをドロップしてください\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"トピック名 例: phils_alerts\",\n    \"prefs_notifications_min_priority_any\": \"全ての優先度\",\n    \"prefs_notifications_delete_after_three_hours\": \"3時間後\",\n    \"prefs_users_description\": \"保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。\",\n    \"prefs_users_add_button\": \"ユーザー追加\",\n    \"common_add\": \"追加\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"他のサーバーを使用\",\n    \"subscribe_dialog_error_user_not_authorized\": \"ユーザー名 {{username}} は許可されていません\",\n    \"prefs_notifications_delete_after_one_week\": \"1週間後\",\n    \"prefs_notifications_delete_after_one_month\": \"1か月後\",\n    \"subscribe_dialog_subscribe_description\": \"トピックはパスワード保護されないので、推測されにくい名前にしてください。購読した後、PUT/POSTで通知を送信できます。\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"キャンセル\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"購読\",\n    \"subscribe_dialog_login_title\": \"ログインが必要です\",\n    \"subscribe_dialog_error_user_anonymous\": \"匿名\",\n    \"prefs_notifications_title\": \"通知\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"優先度低 およびそれ以上\",\n    \"prefs_notifications_delete_after_never\": \"削除しない\",\n    \"prefs_notifications_delete_after_one_day\": \"1日後\",\n    \"prefs_notifications_sound_title\": \"通知音\",\n    \"prefs_notifications_sound_no_sound\": \"サウンドなし\",\n    \"prefs_notifications_min_priority_title\": \"表示する優先度\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"優先度通常 およびそれ以上\",\n    \"prefs_notifications_delete_after_title\": \"通知を削除\",\n    \"common_cancel\": \"キャンセル\",\n    \"common_save\": \"保存\",\n    \"prefs_users_table_user_header\": \"ユーザー名\",\n    \"prefs_users_dialog_title_add\": \"ユーザー追加\",\n    \"prefs_users_dialog_title_edit\": \"ユーザー編集\",\n    \"prefs_users_dialog_base_url_label\": \"サービスURL, 例) https://ntfy.sh\",\n    \"prefs_appearance_title\": \"外観\",\n    \"prefs_appearance_language_title\": \"言語\",\n    \"prefs_users_table_base_url_header\": \"サービスURL\",\n    \"prefs_users_dialog_username_label\": \"ユーザー名, 例) phil\",\n    \"prefs_users_dialog_password_label\": \"パスワード\",\n    \"error_boundary_title\": \"おっと、ntfyがクラッシュしました\",\n    \"error_boundary_button_copy_stack_trace\": \"スタックトレースをコピー\",\n    \"error_boundary_stack_trace\": \"スタックトレース\",\n    \"error_boundary_gathering_info\": \"更に情報を集める…\",\n    \"publish_dialog_base_url_placeholder\": \"サービスURL, 例) https://example.com\",\n    \"publish_dialog_attach_placeholder\": \"添付ファイルをURLで指定, 例) https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_delay_placeholder\": \"遅延時間, 例) {{unixTimestamp}}, {{relativeTime}}, or \\\"{{naturalLanguage}}\\\" (English only)\",\n    \"prefs_notifications_sound_description_none\": \"通知受信時に音を鳴らしません\",\n    \"prefs_notifications_sound_description_some\": \"通知受信時に {{sound}} を鳴らします\",\n    \"prefs_notifications_min_priority_description_any\": \"優先度に関係なく全ての通知を表示します\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"優先度が {{number}} ({{name}}) 以上の時に通知を表示します\",\n    \"prefs_notifications_delete_after_never_description\": \"通知は自動的に削除されません\",\n    \"prefs_notifications_delete_after_one_day_description\": \"通知は1日後に自動的に削除されます\",\n    \"prefs_notifications_delete_after_one_week_description\": \"通知は1週間後に自動的に削除されます\",\n    \"prefs_notifications_delete_after_one_month_description\": \"通知は1か月後に自動的に削除されます\",\n    \"priority_high\": \"高\",\n    \"priority_max\": \"最高\",\n    \"prefs_notifications_min_priority_description_max\": \"優先度が 5 (最高) の時にのみ通知を表示します\",\n    \"priority_default\": \"通常\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"通知は3時間後に自動的に削除されます\",\n    \"priority_low\": \"低\",\n    \"priority_min\": \"最低\",\n    \"notifications_actions_not_supported\": \"このアクションはWebアプリではサポートされていません\",\n    \"notifications_actions_http_request_title\": \"{{url}}にHTTP {{method}}を送信\",\n    \"prefs_users_edit_button\": \"ユーザーを編集\",\n    \"publish_dialog_attached_file_remove\": \"添付ファイルを削除\",\n    \"error_boundary_unsupported_indexeddb_description\": \"nfty webアプリは動作にIndexedDBを使用しますが、あなたのブラウザはプライベートブラウジングモード時にIndexedDBをサポートしていません。<br/><br/>これは残念なことですが、ntfy webアプリは全ての情報をブラウザストレージに保存して動作するため、プライベートブラウジングモードで利用するのはあまり意味がないかも知れません。詳細については <githubLink>GitHub issue</githubLink>を参照するか、<discordLink>Discord</discordLink>や<matrixLink>Matrix</matrixLink>の議論に参加してください。\",\n    \"action_bar_show_menu\": \"メニューを表示\",\n    \"action_bar_logo_alt\": \"ntfyロゴ\",\n    \"action_bar_toggle_mute\": \"通知をミュート／解除\",\n    \"action_bar_toggle_action_menu\": \"動作メニューを開く／閉じる\",\n    \"message_bar_show_dialog\": \"送信ダイアログを表示\",\n    \"message_bar_publish\": \"メッセージを送信\",\n    \"nav_button_muted\": \"ミュートされた通知\",\n    \"nav_button_connecting\": \"接続中\",\n    \"notifications_list\": \"通知一覧\",\n    \"notifications_new_indicator\": \"新しい通知\",\n    \"notifications_list_item\": \"通知\",\n    \"notifications_mark_read\": \"既読にする\",\n    \"notifications_delete\": \"削除\",\n    \"notifications_priority_x\": \"優先度 {{priority}}\",\n    \"notifications_attachment_image\": \"添付画像\",\n    \"notifications_attachment_file_image\": \"画像ファイル\",\n    \"notifications_attachment_file_video\": \"動画ファイル\",\n    \"notifications_attachment_file_audio\": \"音声ファイル\",\n    \"notifications_attachment_file_app\": \"Androidアプリファイル\",\n    \"notifications_attachment_file_document\": \"その他文書\",\n    \"publish_dialog_emoji_picker_show\": \"絵文字\",\n    \"publish_dialog_topic_reset\": \"トピックをリセット\",\n    \"publish_dialog_click_reset\": \"クリックURLを削除\",\n    \"publish_dialog_email_reset\": \"メール転送を削除\",\n    \"publish_dialog_attach_reset\": \"添付URLを削除\",\n    \"publish_dialog_delay_reset\": \"配信遅延を削除\",\n    \"emoji_picker_search_clear\": \"検索をクリア\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"サーバーURL\",\n    \"prefs_notifications_sound_play\": \"選択されたサウンドを再生\",\n    \"prefs_users_table\": \"ユーザー一覧\",\n    \"prefs_users_delete_button\": \"ユーザーを削除\",\n    \"error_boundary_unsupported_indexeddb_title\": \"プライベートブラウジングはサポートされていません\",\n    \"signup_form_username\": \"ユーザー名\",\n    \"signup_form_password\": \"パスワード\",\n    \"signup_form_confirm_password\": \"パスワードを確認\",\n    \"signup_already_have_account\": \"アカウントをお持ちならサインイン！\",\n    \"signup_disabled\": \"サインアップは無効化されています\",\n    \"signup_error_creation_limit_reached\": \"アカウント作成制限に達しました\",\n    \"login_title\": \"あなたのntfyアカウントにサインイン\",\n    \"login_link_signup\": \"サインアップ\",\n    \"login_disabled\": \"ログインは無効化されています\",\n    \"action_bar_account\": \"アカウント\",\n    \"action_bar_change_display_name\": \"表示名を変更する\",\n    \"action_bar_reservation_add\": \"トピックを予約する\",\n    \"action_bar_reservation_edit\": \"予約を編集する\",\n    \"action_bar_reservation_limit_reached\": \"制限に達しました\",\n    \"action_bar_profile_title\": \"プロファイル\",\n    \"action_bar_profile_settings\": \"設定\",\n    \"action_bar_profile_logout\": \"ログアウト\",\n    \"action_bar_sign_in\": \"サインイン\",\n    \"action_bar_sign_up\": \"サインアップ\",\n    \"nav_button_account\": \"アカウント\",\n    \"nav_upgrade_banner_label\": \"ntfy Proにアップグレード\",\n    \"display_name_dialog_title\": \"表示名を変更\",\n    \"display_name_dialog_placeholder\": \"表示名\",\n    \"signup_form_button_submit\": \"サインアップ\",\n    \"signup_form_toggle_password_visibility\": \"パスワードを表示/非表示\",\n    \"signup_title\": \"ntfyアカウントを作成する\",\n    \"login_form_button_submit\": \"サインイン\",\n    \"alert_not_supported_context_description\": \"通知はHTTPSのみサポートされています。これは<mdnLink>Notifications API</mdnLink>の制限によるものです。\",\n    \"nav_upgrade_banner_description\": \"トピックを予約、より多くのメッセージとメール、より大きい添付ファイル\",\n    \"signup_error_username_taken\": \"ユーザー名 {{username}} は既に使用されています\",\n    \"action_bar_reservation_delete\": \"予約を削除する\",\n    \"display_name_dialog_description\": \"購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。\",\n    \"reserve_dialog_checkbox_label\": \"トピックを保存してアクセスを編集\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"名前を生成\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"このトピックは予約済みです\",\n    \"account_basics_title\": \"アカウント\",\n    \"account_basics_tier_description\": \"アカウントのパワーレベル\",\n    \"account_basics_tier_admin\": \"管理者\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"（ティア {{tier}}）\",\n    \"account_basics_tier_free\": \"無料\",\n    \"account_usage_attachment_storage_description\": \"1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除\",\n    \"account_usage_basis_ip_description\": \"アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。\",\n    \"account_usage_cannot_create_portal_session\": \"支払いポータルを開けませんでした\",\n    \"account_delete_title\": \"アカウントを削除\",\n    \"account_delete_description\": \"アカウントを永久的に削除\",\n    \"account_delete_dialog_description\": \"サーバーに保存されている全てのデータを含むあなたのアカウント情報を削除します。削除後、あなたのユーザー名は7日間利用できません。もし本当に先に進めたい場合、下の入力欄にパスワードを入力して確認して下さい。\",\n    \"account_delete_dialog_label\": \"パスワード\",\n    \"account_delete_dialog_button_cancel\": \"キャンセル\",\n    \"account_delete_dialog_button_submit\": \"永久的にアカウントを削除\",\n    \"account_delete_dialog_billing_warning\": \"アカウントを削除するとサブスクリプション支払いも即時キャンセルされます。支払いダッシュボードにもアクセスできなくなります。\",\n    \"account_upgrade_dialog_title\": \"アカウントティアを変更\",\n    \"account_upgrade_dialog_cancel_warning\": \"これにより<strong>サブスクリプションをキャンセルし</strong>{{date}}にアカウントをダウングレードします。同日、トピック予約およびサーバーにキャッシュされたメッセージは<strong>削除されます</strong>。\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>追記</strong>。有料プランをアップグレードする場合、価格差は<strong>即座に請求されます</strong>。ダウングレードする場合、差額は次の請求期間の支払いに利用されます。\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"予約のトピック{{reservations}}件\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"日次メール{{emails}}件\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"日次メッセージ{{messages}}件\",\n    \"account_upgrade_dialog_tier_selected_label\": \"選択\",\n    \"account_upgrade_dialog_tier_current_label\": \"現在\",\n    \"account_upgrade_dialog_button_cancel\": \"キャンセル\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"サインアップ\",\n    \"account_upgrade_dialog_button_pay_now\": \"支払いしてサブスクライブする\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"サブスクリプションをキャンセル\",\n    \"account_upgrade_dialog_button_update_subscription\": \"サブスクリプションを更新\",\n    \"account_tokens_description\": \"ntfy APIで発行または購読する際にアクセストークンを使うことで、アカウント認証情報を送信する必要がなくなります。詳細は<Link>ドキュメント</Link>を確認して下さい。\",\n    \"account_tokens_table_token_header\": \"トークン\",\n    \"account_tokens_table_label_header\": \"ラベル\",\n    \"account_tokens_table_last_access_header\": \"最終アクセス\",\n    \"account_tokens_table_expires_header\": \"期限\",\n    \"account_tokens_table_never_expires\": \"無期限\",\n    \"account_tokens_table_current_session\": \"現在のブラウザセッション\",\n    \"common_copy_to_clipboard\": \"クリップボードにコピー\",\n    \"account_tokens_table_copied_to_clipboard\": \"アクセストークンをコピーしました\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"現在のセッショントークンは編集または削除できません\",\n    \"account_tokens_table_create_token_button\": \"アクセストークンを生成\",\n    \"account_tokens_table_last_origin_tooltip\": \"IPアドレス {{ip}} から、クリックして参照\",\n    \"account_tokens_dialog_title_create\": \"アクセストークンを生成\",\n    \"account_tokens_dialog_title_edit\": \"アクセストークンを編集\",\n    \"account_tokens_dialog_title_delete\": \"アクセストークンを削除\",\n    \"account_tokens_dialog_label\": \"ラベル、例：Radarr通知\",\n    \"account_tokens_dialog_button_create\": \"トークンを生成\",\n    \"account_tokens_dialog_button_update\": \"トークンを更新\",\n    \"account_tokens_dialog_button_cancel\": \"キャンセル\",\n    \"account_tokens_dialog_expires_label\": \"アクセストークン有効期限\",\n    \"account_tokens_dialog_expires_unchanged\": \"有効期限を変更しない\",\n    \"account_tokens_dialog_expires_x_hours\": \"トークンは {{hours}} 時間後に失効します\",\n    \"account_tokens_dialog_expires_x_days\": \"トークンは {{days}} 日後に失効します\",\n    \"account_tokens_dialog_expires_never\": \"トークン失効なし\",\n    \"account_tokens_delete_dialog_title\": \"アクセストークンを削除\",\n    \"account_tokens_delete_dialog_submit_button\": \"トークンを永久削除\",\n    \"prefs_users_description_no_sync\": \"ユーザー名とパスワードはアカウントと同期されません。\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"ログインしているユーザーは削除または編集できません\",\n    \"prefs_reservations_title\": \"予約されたトピック\",\n    \"prefs_reservations_description\": \"ここでトピック名を個人利用の為に予約する事ができます。トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。\",\n    \"prefs_reservations_add_button\": \"予約トピックを追加する\",\n    \"prefs_reservations_edit_button\": \"トピックへのアクセスを編集する\",\n    \"prefs_reservations_delete_button\": \"トピックへのアクセスをリセットする\",\n    \"prefs_reservations_table\": \"予約トピックの一覧\",\n    \"prefs_reservations_table_topic_header\": \"トピック\",\n    \"prefs_reservations_table_everyone_deny_all\": \"自分のみ発行と購読が可能\",\n    \"prefs_reservations_table_everyone_read_only\": \"自分は発行と購読が可能、誰でも購読可能\",\n    \"prefs_reservations_table_everyone_write_only\": \"自分は発行と購読可能、誰でも発行可能\",\n    \"prefs_reservations_table_everyone_read_write\": \"誰でも発行と購読が可能\",\n    \"prefs_reservations_table_not_subscribed\": \"購読されていません\",\n    \"prefs_reservations_table_click_to_subscribe\": \"クリックして購読\",\n    \"prefs_reservations_dialog_title_edit\": \"予約トピックを編集\",\n    \"prefs_reservations_dialog_title_delete\": \"トピック予約を削除\",\n    \"prefs_reservations_dialog_topic_label\": \"トピック\",\n    \"prefs_reservations_dialog_access_label\": \"アクセス\",\n    \"reservation_delete_dialog_action_keep_title\": \"キャッシュされたメッセージと添付ファイルを保持する\",\n    \"reservation_delete_dialog_action_keep_description\": \"サーバーにキャッシュされたメッセージと添付ファイルは公開されてトピック名を知っている人が閲覧できるようになります。\",\n    \"reservation_delete_dialog_action_delete_title\": \"キャッシュされたメッセージと添付ファイルを削除する\",\n    \"reservation_delete_dialog_action_delete_description\": \"キャッシュされたメッセージと添付ファイルは永久的に削除されます。この操作は元に戻せません。\",\n    \"account_basics_username_admin_tooltip\": \"あなたは管理者です\",\n    \"account_basics_password_title\": \"パスワード\",\n    \"account_basics_password_dialog_current_password_label\": \"現在のパスワード\",\n    \"account_usage_limits_reset_daily\": \"使用量制限は世界協定時 (UTC) の深夜に毎日リセットされます\",\n    \"account_basics_tier_basic\": \"ベーシック\",\n    \"account_basics_tier_paid_until\": \"サブスクリプションは{{date}}まで有効で、自動更新されます\",\n    \"account_basics_username_title\": \"ユーザー名\",\n    \"account_basics_username_description\": \"あなたのお名前です ❤\",\n    \"account_basics_password_description\": \"アカウントパスワードを変更\",\n    \"account_basics_password_dialog_title\": \"パスワード変更\",\n    \"account_basics_password_dialog_confirm_password_label\": \"パスワードを確認\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"パスワードが異なります\",\n    \"account_usage_of_limit\": \"： {{limit}}\",\n    \"account_usage_unlimited\": \"無制限\",\n    \"account_basics_tier_upgrade_button\": \"プロにアップグレード\",\n    \"account_basics_tier_manage_billing_button\": \"支払い方法を管理\",\n    \"account_basics_password_dialog_new_password_label\": \"新しいパスワード\",\n    \"account_basics_password_dialog_button_submit\": \"パスワードを変更\",\n    \"account_usage_title\": \"使用量\",\n    \"account_basics_tier_title\": \"アカウントタイプ\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"（ティアなし）\",\n    \"account_basics_tier_change_button\": \"変更\",\n    \"account_basics_tier_payment_overdue\": \"支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。\",\n    \"account_basics_tier_canceled_subscription\": \"あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。\",\n    \"account_usage_messages_title\": \"発行されたメッセージ\",\n    \"account_usage_reservations_none\": \"このアカウントで予約されたトピックはありません\",\n    \"account_usage_attachment_storage_title\": \"添付ストレージ\",\n    \"account_usage_emails_title\": \"送信済みメール\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、<strong>少なくとも1つの予約を削除してください</strong>。予約の削除は、<Link>設定</Link>で行うことができます。\",\n    \"account_usage_reservations_title\": \"予約されたトピック\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、<strong>少なくとも{{count}}個の予約を削除してください</strong>。予約の削除は、<Link>設定</Link>で行うことができます。\",\n    \"account_tokens_delete_dialog_description\": \"アクセストークンを削除する前に、アプリやスクリプトが利用中でないか確認して下さい。<strong>この操作は元に戻せません</strong>。\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"1ファイルあたり{{filesize}}\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"総ストレージ{{totalsize}}\",\n    \"account_tokens_title\": \"アクセストークン\",\n    \"prefs_reservations_limit_reached\": \"予約トピック数の上限に達しました。\",\n    \"prefs_reservations_table_access_header\": \"アクセス\",\n    \"prefs_reservations_dialog_title_add\": \"トピックを予約\",\n    \"prefs_reservations_dialog_description\": \"トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。\",\n    \"reservation_delete_dialog_description\": \"予約を削除するとトピックの所有権を失い、他の人が予約できるようになります。既存のメッセージや添付ファイルは保持または削除することができます。\",\n    \"reservation_delete_dialog_submit_button\": \"予約を削除\",\n    \"account_basics_tier_interval_monthly\": \"毎月\",\n    \"account_upgrade_dialog_interval_monthly\": \"毎月\",\n    \"account_upgrade_dialog_interval_yearly\": \"毎年\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"最大{{discount}}%節約\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"予約トピックなし\",\n    \"account_upgrade_dialog_billing_contact_email\": \"支払いについての問い合わせは、直接<Link>お問い合わせください</Link>。\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"{{discount}}%節約\",\n    \"account_basics_tier_interval_yearly\": \"毎年\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"月\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"年間{{price}}。月毎の支払い。\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"年間{{price}}の支払い。{{save}}節約。\",\n    \"account_upgrade_dialog_billing_contact_website\": \"支払いに関する質問は、<Link>ウェブサイト</Link>を参照して下さい。\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"毎日 {{messages}} メッセージ\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"予約済みトピック {{reservations}} 件\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"毎日メール {{emails}} 件\",\n    \"publish_dialog_call_label\": \"電話\",\n    \"publish_dialog_call_item\": \"電話番号 {{number}}\",\n    \"account_basics_phone_numbers_title\": \"電話番号\",\n    \"account_usage_calls_none\": \"このアカウントからは電話を発信できません\",\n    \"account_usage_calls_title\": \"電話を発信しました\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"電話 1日 {{calls}} 回\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"電話なし\",\n    \"publish_dialog_call_reset\": \"電話番号を削除\",\n    \"publish_dialog_chip_call_label\": \"電話番号\",\n    \"account_basics_phone_numbers_dialog_description\": \"電話通知機能を使うには、最低ひとつの電話番号を追加して認証する必要があります。認証はSMSまたは電話で実施できます。\",\n    \"account_basics_phone_numbers_description\": \"電話通知\",\n    \"account_basics_phone_numbers_dialog_title\": \"電話番号を追加\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"電話番号はまだありません\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"電話番号がクリップボードにコピーされました\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"電話番号\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"例 +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"SMSを送信\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"自分に電話する\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"確認コード\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"例 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"確認コード\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"電話 1日 {{calls}} 回\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"認証済み電話番号がありません\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"電話する\",\n    \"error_boundary_button_reload_ntfy\": \"ntfyをリロード\",\n    \"prefs_appearance_theme_light\": \"ライトモード\",\n    \"web_push_subscription_expiring_title\": \"通知は一時停止されます\",\n    \"web_push_subscription_expiring_body\": \"ntfyを開いて通知の受信を継続させてください\",\n    \"alert_notification_ios_install_required_description\": \"Shareアイコンをクリック・ホーム画面に追加してiOSでの通知を有効化して下さい\",\n    \"action_bar_mute_notifications\": \"通知をミュート\",\n    \"action_bar_unmute_notifications\": \"通知ミュートを解除\",\n    \"alert_notification_permission_denied_title\": \"通知はブロックされています\",\n    \"alert_notification_permission_denied_description\": \"ブラウザで通知を再度有効化してください\",\n    \"notifications_actions_failed_notification\": \"アクション失敗\",\n    \"alert_notification_ios_install_required_title\": \"iOS用インストールが必要です\",\n    \"publish_dialog_checkbox_markdown\": \"Markdownとして表示\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"ウェブアプリが開かれていない場合は他のサーバーからの通知は受信されません\",\n    \"prefs_notifications_web_push_title\": \"バックグラウンド通知\",\n    \"prefs_notifications_web_push_enabled_description\": \"ウェブアプリが開かれていなくても通知を受信します (Web Push経由)\",\n    \"prefs_notifications_web_push_disabled_description\": \"ウェブアプリが開かれていなくても通知を受信します (WebSocket経由)\",\n    \"prefs_notifications_web_push_enabled\": \"{{server}}で有効\",\n    \"prefs_notifications_web_push_disabled\": \"無効\",\n    \"prefs_appearance_theme_title\": \"テーマ\",\n    \"prefs_appearance_theme_system\": \"システム (既定)\",\n    \"prefs_appearance_theme_dark\": \"ダークモード\",\n    \"web_push_unknown_notification_title\": \"不明な通知を受信しました\",\n    \"web_push_unknown_notification_body\": \"ウェブアプリを開いてntfyをアップデートする必要があります\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"自動作成されたユーザーの編集や削除はできません\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"自動作成されたトークンは編集や削除はできません\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ko.json",
    "content": "{\n    \"action_bar_show_menu\": \"메뉴 표시\",\n    \"action_bar_logo_alt\": \"ntfy 로고\",\n    \"action_bar_settings\": \"설정\",\n    \"action_bar_send_test_notification\": \"시험용 알림 발송\",\n    \"action_bar_clear_notifications\": \"모든 알림 초기화\",\n    \"action_bar_unsubscribe\": \"구독 해제\",\n    \"action_bar_toggle_mute\": \"알림 음소거/해제\",\n    \"action_bar_toggle_action_menu\": \"액션 메뉴 열기/닫기\",\n    \"message_bar_type_message\": \"여기에 메세지를 입력하세요\",\n    \"message_bar_error_publishing\": \"메세지 발송 오류\",\n    \"message_bar_show_dialog\": \"발송 창 표시\",\n    \"message_bar_publish\": \"메세지 발송\",\n    \"nav_topics_title\": \"구독한 주제\",\n    \"nav_button_all_notifications\": \"모든 알림\",\n    \"nav_button_publish_message\": \"알림 보내기\",\n    \"nav_button_subscribe\": \"주제 구독하기\",\n    \"nav_button_muted\": \"알림 음소거됨\",\n    \"nav_button_connecting\": \"연결중\",\n    \"alert_notification_permission_required_title\": \"알림이 비활성화되어 있습니다\",\n    \"alert_notification_permission_required_description\": \"데스크톱 알림을 받기 위해서는 브라우저에서 권한을 부여해야 합니다.\",\n    \"alert_notification_permission_required_button\": \"권한 부여하기\",\n    \"alert_not_supported_title\": \"알림이 지원되지 않습니다\",\n    \"notifications_list_item\": \"알림\",\n    \"notifications_mark_read\": \"읽음으로 표시\",\n    \"notifications_delete\": \"삭제\",\n    \"notifications_copied_to_clipboard\": \"클립보드에 복사됨\",\n    \"notifications_tags\": \"태그\",\n    \"notifications_priority_x\": \"우선순위 {{priority}}\",\n    \"notifications_new_indicator\": \"새 알림\",\n    \"notifications_attachment_image\": \"첨부 이미지\",\n    \"notifications_attachment_copy_url_title\": \"첨부 주소를 클립보드에 복사\",\n    \"notifications_attachment_copy_url_button\": \"URL 복사\",\n    \"notifications_attachment_open_title\": \"{{url}}로 가기\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"첨부파일 크기 제한({{fileSizeLimit}}) 초과 및 할당량 초과({{remainingBytes}} 남음)\",\n    \"publish_dialog_attachment_limits_file_reached\": \"첨부파일 크기 제한({{fileSizeLimit}}) 초과\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"할당량 초과({{remainingBytes}} 남음)\",\n    \"publish_dialog_emoji_picker_show\": \"이모지 선택\",\n    \"publish_dialog_priority_min\": \"우선순위 최소\",\n    \"publish_dialog_priority_low\": \"우선순위 낮음\",\n    \"publish_dialog_priority_default\": \"우선순위 기본\",\n    \"publish_dialog_priority_high\": \"우선순위 높음\",\n    \"publish_dialog_priority_max\": \"우선순위 최상\",\n    \"publish_dialog_base_url_label\": \"서비스 URL\",\n    \"publish_dialog_base_url_placeholder\": \"서비스 URL, 예를 들면 https://example.com\",\n    \"publish_dialog_topic_label\": \"주제 이름\",\n    \"publish_dialog_topic_placeholder\": \"주제 이름, 예를 들면 phil_alerts\",\n    \"publish_dialog_topic_reset\": \"주제 초기화\",\n    \"publish_dialog_title_label\": \"제목\",\n    \"publish_dialog_title_placeholder\": \"알림 제목, 예를 들면 디스크 공간 경고\",\n    \"publish_dialog_message_label\": \"메세지\",\n    \"publish_dialog_message_placeholder\": \"메세지를 여기에 입력하세요\",\n    \"publish_dialog_tags_label\": \"태그\",\n    \"publish_dialog_tags_placeholder\": \"반점으로 구분된 태그 목록, 예를 들면 warning, srv1-backup\",\n    \"publish_dialog_priority_label\": \"우선순위\",\n    \"publish_dialog_click_label\": \"클릭 URL\",\n    \"publish_dialog_click_placeholder\": \"알림이 클릭되었을때 이동할 URL\",\n    \"publish_dialog_click_reset\": \"클릭 URL 제거\",\n    \"publish_dialog_email_label\": \"이메일\",\n    \"publish_dialog_email_placeholder\": \"알림을 전달할 이메일 주소, 예를 들면 phil@example.com\",\n    \"publish_dialog_email_reset\": \"이메일 전달 삭제\",\n    \"publish_dialog_attach_label\": \"첨부 파일 URL\",\n    \"publish_dialog_attach_placeholder\": \"파일을 URL로 첨부하기, 예를 들면 https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"첨부 파일 URL 삭제\",\n    \"publish_dialog_filename_label\": \"파일 이름\",\n    \"publish_dialog_filename_placeholder\": \"첨부 파일 이름\",\n    \"publish_dialog_delay_label\": \"지연\",\n    \"publish_dialog_chip_email_label\": \"이메일로 전달\",\n    \"publish_dialog_chip_attach_url_label\": \"URL로 파일 첨부\",\n    \"publish_dialog_chip_attach_file_label\": \"로컬 파일 첨부\",\n    \"publish_dialog_chip_delay_label\": \"발송 지연\",\n    \"publish_dialog_chip_topic_label\": \"주제 변경\",\n    \"publish_dialog_details_examples_description\": \"예제와 모든 전송 기능의 자세한 설명은 <docsLink>문서</docsLink>를 참고해주세요.\",\n    \"publish_dialog_button_cancel\": \"취소\",\n    \"publish_dialog_button_send\": \"보내기\",\n    \"publish_dialog_button_cancel_sending\": \"보내기 취소\",\n    \"publish_dialog_checkbox_publish_another\": \"다른 메세지 보내기\",\n    \"publish_dialog_attached_file_title\": \"첨부된 파일:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"첨부 파일 이름\",\n    \"publish_dialog_attached_file_remove\": \"첨부 파일 삭제\",\n    \"publish_dialog_drop_file_here\": \"여기에 파일을 끌어다 놓으세요\",\n    \"emoji_picker_search_placeholder\": \"이모지 검색\",\n    \"emoji_picker_search_clear\": \"검색 초기화\",\n    \"subscribe_dialog_subscribe_title\": \"주제 구독하기\",\n    \"subscribe_dialog_subscribe_description\": \"주제는 비밀번호로 보호되지 않을 수 있으니 추측하기 어려운 이름을 사용하십시오. 구독한 뒤 PUT/POST 알림을 보낼 수 있습니다.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"주제 이름, 예를 들면 phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"다른 서버 사용\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"서비스 URL\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"취소\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"구독하기\",\n    \"subscribe_dialog_login_title\": \"로그인 필요함\",\n    \"subscribe_dialog_error_user_anonymous\": \"익명\",\n    \"subscribe_dialog_error_user_not_authorized\": \"사용자 {{username}} 은(는) 인증되지 않았습니다\",\n    \"subscribe_dialog_login_username_label\": \"사용자 이름, 예를 들면 phil\",\n    \"subscribe_dialog_login_password_label\": \"비밀번호\",\n    \"common_back\": \"뒤로가기\",\n    \"subscribe_dialog_login_button_login\": \"로그인\",\n    \"prefs_notifications_title\": \"알림\",\n    \"prefs_notifications_sound_title\": \"알림 효과음\",\n    \"prefs_notifications_sound_description_none\": \"알림 도착시 효과음을 재생하지 않습니다\",\n    \"prefs_notifications_sound_description_some\": \"알림 도착시 {{sound}} 효과음이 재생됩니다\",\n    \"prefs_notifications_sound_no_sound\": \"효과음 없음\",\n    \"prefs_notifications_sound_play\": \"선택한 효과음 재생\",\n    \"prefs_notifications_min_priority_title\": \"우선순위 최소\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"우선순위가 {{number}} ({{name}}) 이상인 알림만 보기\",\n    \"prefs_notifications_min_priority_description_max\": \"우선순위가 5 (최상)인 알림만 보기\",\n    \"prefs_notifications_min_priority_any\": \"아무 우선순위\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"우선순위 기본 이상\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"우선순위 낮음 이상\",\n    \"prefs_notifications_delete_after_three_hours\": \"3시간 뒤\",\n    \"prefs_notifications_delete_after_one_day\": \"1일 뒤\",\n    \"prefs_notifications_delete_after_one_week\": \"1주 뒤\",\n    \"prefs_notifications_delete_after_one_month\": \"1달 뒤\",\n    \"prefs_notifications_delete_after_never_description\": \"알림이 자동으로 삭제되지 않습니다\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"알림이 3시간 뒤 자동으로 삭제됩니다\",\n    \"prefs_notifications_delete_after_one_day_description\": \"알림이 1일 뒤 자동으로 삭제됩니다\",\n    \"prefs_notifications_delete_after_one_week_description\": \"알림이 1주 뒤 자동으로 삭제됩니다\",\n    \"prefs_notifications_delete_after_one_month_description\": \"알림이 1달 뒤 자동으로 삭제됩니다\",\n    \"prefs_users_title\": \"사용자 관리\",\n    \"prefs_users_description\": \"이곳에서 보호된 주제를 위한 사용자를 추가하거나 삭제할 수 있습니다. 사용자 이름과 비밀번호는 브라우저의 로컬 저장소에 보관됩니다.\",\n    \"prefs_users_add_button\": \"사용자 추가\",\n    \"prefs_users_edit_button\": \"사용자 편집\",\n    \"prefs_users_delete_button\": \"사용자 삭제\",\n    \"prefs_users_table_user_header\": \"사용자\",\n    \"prefs_users_table_base_url_header\": \"서비스 URL\",\n    \"prefs_users_dialog_title_add\": \"사용자 추가\",\n    \"prefs_users_dialog_title_edit\": \"사용자 편집\",\n    \"prefs_users_dialog_base_url_label\": \"서비스 URL, 예를 들면 https://ntfy.sh\",\n    \"common_cancel\": \"취소\",\n    \"common_save\": \"저장\",\n    \"prefs_appearance_title\": \"표시 설정\",\n    \"common_add\": \"추가\",\n    \"prefs_appearance_language_title\": \"언어\",\n    \"priority_min\": \"최하\",\n    \"priority_low\": \"낮음\",\n    \"priority_default\": \"기본\",\n    \"priority_high\": \"높음\",\n    \"error_boundary_title\": \"이런, ntfy가 충돌했습니다\",\n    \"error_boundary_button_copy_stack_trace\": \"스택 트레이스 복사\",\n    \"error_boundary_stack_trace\": \"스택 트레이스\",\n    \"error_boundary_gathering_info\": \"더 많은 정보 모으기 …\",\n    \"error_boundary_unsupported_indexeddb_title\": \"시크릿 모드는 지원되지 않습니다\",\n    \"notifications_click_copy_url_button\": \"링크 복사\",\n    \"notifications_click_copy_url_title\": \"링크 URL을 클립보드에 복사\",\n    \"notifications_attachment_file_video\": \"동영상 파일\",\n    \"notifications_attachment_file_app\": \"안드로이드 앱 파일\",\n    \"notifications_attachment_file_document\": \"다른 문서\",\n    \"notifications_click_open_button\": \"링크 열기\",\n    \"notifications_actions_not_supported\": \"웹앱에서 지원되지 않는 동작입니다\",\n    \"publish_dialog_title_topic\": \"{{topic}}에 발송\",\n    \"alert_not_supported_description\": \"사용중인 브라우저에서 알림 기능을 지원하지 않습니다.\",\n    \"notifications_example\": \"예제\",\n    \"notifications_more_details\": \"더 많은 정보가 필요하시다면 <websiteLink>웹사이트</websiteLink>나 <docsLink>문서</docsLink>를 참고하세요.\",\n    \"notifications_list\": \"알림 목록\",\n    \"notifications_attachment_open_button\": \"첨부 파일 열기\",\n    \"notifications_no_subscriptions_title\": \"아직 아무런 구독을 추가하지 않으신 것 같습니다.\",\n    \"nav_button_settings\": \"설정\",\n    \"nav_button_documentation\": \"문서\",\n    \"notifications_attachment_link_expires\": \"링크가 {{date}}에 만료됨\",\n    \"notifications_attachment_link_expired\": \"다운로드 링크 만료됨\",\n    \"notifications_attachment_file_audio\": \"음성 파일\",\n    \"notifications_attachment_file_image\": \"사진 파일\",\n    \"notifications_actions_open_url_title\": \"{{url}]로 가기\",\n    \"notifications_actions_http_request_title\": \"HTTP {{method}}를 {{url}}에 보내기\",\n    \"notifications_none_for_topic_title\": \"아직 이 주제 관련 알림을 받지 않았습니다.\",\n    \"notifications_none_for_any_title\": \"아직 어떤 알림도 받지 않았습니다.\",\n    \"notifications_none_for_any_description\": \"알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요. 구독중이신 주제 중 하나로 예를 들자면 다음과 같습니다.\",\n    \"notifications_loading\": \"알림 불러오는중 …\",\n    \"publish_dialog_message_published\": \"알림 발송됨\",\n    \"notifications_none_for_topic_description\": \"알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요.\",\n    \"notifications_no_subscriptions_description\": \"\\\"{{linktext}}\\\" 링크를 눌러서 주제를 생성하거나 구독하세요. 그 다음, 메세지를 PUT이나 POST로 보내면 여기에서 알림을 받으실 수 있습니다.\",\n    \"publish_dialog_progress_uploading\": \"업로드중 …\",\n    \"publish_dialog_title_no_topic\": \"알림 발송\",\n    \"publish_dialog_progress_uploading_detail\": \"업로드중 {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_delay_placeholder\": \"알림 발송 지연, 예를 들면 {{unixTimestamp}}, {{relativeTime}} 또는 \\\"{{naturalLanguage}}\\\" (영어로 입력)\",\n    \"publish_dialog_delay_reset\": \"발송 지연 삭제\",\n    \"publish_dialog_chip_click_label\": \"클릭 URL\",\n    \"subscribe_dialog_login_description\": \"이 주제는 비밀번호로 보호되어 있습니다. 구독하시려면 사용자 이름과 비밀번호를 입력해주세요.\",\n    \"prefs_notifications_min_priority_max_only\": \"우선순위 최상만\",\n    \"publish_dialog_other_features\": \"다른 기능:\",\n    \"prefs_notifications_min_priority_description_any\": \"우선순위 무관 모든 알림 보기\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"우선순위 높음 이상\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy 웹 앱은 동작하기 위해서 IndexedDB가 필요하지만 사용중이신 브라우저는 IndexedDB를 시크릿 모드에서 지원하지 않습니다.<br/><br/>안타깝지만 모든 정보는 브라우저에만 저장되므로 ntfy 웹앱을 시크릿 모드에서 사용할 이유는 존재하지 않습니다. <githubLink>이 깃허브 이슈</githubLink>를 참고해 보시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>에서 저희와 이야기를 나눌 수 있습니다.\",\n    \"prefs_notifications_delete_after_title\": \"알림 삭제\",\n    \"prefs_notifications_delete_after_never\": \"삭제하지 않음\",\n    \"prefs_users_table\": \"사용자 테이블\",\n    \"prefs_users_dialog_username_label\": \"사용자 이름, 예를 들면 phil\",\n    \"prefs_users_dialog_password_label\": \"비밀번호\",\n    \"priority_max\": \"최상\",\n    \"error_boundary_description\": \"이것은 당연히 발생되어서는 안됩니다. 굉장히 죄송합니다.<br/>가능하시다면 <githubLink>이 문제를 깃허브에 제보</githubLink>해 주시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>를 통해 알려주세요.\",\n    \"common_copy_to_clipboard\": \"클립보드에 복사\"\n}\n"
  },
  {
    "path": "web/public/static/langs/mk.json",
    "content": "{\n    \"common_cancel\": \"Откажи\",\n    \"common_save\": \"Зачувај\",\n    \"common_add\": \"Додади\",\n    \"common_back\": \"Назад\",\n    \"common_copy_to_clipboard\": \"Копирај\",\n    \"action_bar_profile_logout\": \"Одјави се\",\n    \"action_bar_sign_in\": \"Најави се\",\n    \"action_bar_sign_up\": \"Регистрирај се\",\n    \"message_bar_type_message\": \"Пишете порака тука\",\n    \"action_bar_profile_title\": \"Профил\",\n    \"action_bar_profile_settings\": \"Подесувања\",\n    \"signup_form_username\": \"Корисничко име\",\n    \"signup_form_password\": \"Лозинка\",\n    \"signup_form_confirm_password\": \"Повтори лозинка\",\n    \"login_form_button_submit\": \"Најави се\",\n    \"login_link_signup\": \"Регистрирај се\",\n    \"signup_form_button_submit\": \"Регистрирај се\",\n    \"action_bar_settings\": \"Подесувања\",\n    \"signup_title\": \"Создади ntfy профил\",\n    \"signup_form_toggle_password_visibility\": \"Покажи/сокриј лозинка\",\n    \"signup_already_have_account\": \"Имате профил? Најавете се!\",\n    \"signup_disabled\": \"Регистрирање е исклучено\",\n    \"signup_error_username_taken\": \"Корисничкото име {{username}} е веќе земено\",\n    \"signup_error_creation_limit_reached\": \"Лимитот на создадени профили е надминат\",\n    \"login_title\": \"Најавете се на вашиот ntfy профил\",\n    \"login_disabled\": \"Најавувањето е исклучено\",\n    \"action_bar_show_menu\": \"Покажи мени\",\n    \"action_bar_logo_alt\": \"ntfy лого\",\n    \"action_bar_account\": \"Профил\",\n    \"action_bar_change_display_name\": \"Промени покажано име\",\n    \"action_bar_reservation_add\": \"Резервирај тема\",\n    \"action_bar_reservation_edit\": \"Промени резервација\",\n    \"account_basics_title\": \"Профил\",\n    \"account_basics_username_title\": \"Корисничко име\",\n    \"nav_button_account\": \"Профил\",\n    \"nav_button_settings\": \"Подесувања\",\n    \"nav_button_documentation\": \"Документација\",\n    \"notifications_attachment_copy_url_button\": \"Копирај URL\",\n    \"publish_dialog_message_label\": \"Порака\",\n    \"action_bar_reservation_delete\": \"Отстрани резервација\",\n    \"action_bar_reservation_limit_reached\": \"Достигната е границата\",\n    \"action_bar_send_test_notification\": \"Испрати тест нотификација\",\n    \"action_bar_clear_notifications\": \"Исчисти ги сите нотификации\",\n    \"action_bar_mute_notifications\": \"Загуши ги нотификациите\",\n    \"action_bar_unsubscribe\": \"Отпиши се\",\n    \"action_bar_toggle_action_menu\": \"Отвори/затвори мени за акција\",\n    \"message_bar_error_publishing\": \"Грешки при публикација на нотификацијата\",\n    \"message_bar_show_dialog\": \"Покажи дијалог за публикација\",\n    \"nav_topics_title\": \"Претплатени теми\",\n    \"nav_button_all_notifications\": \"Сите нотификации\",\n    \"nav_button_publish_message\": \"Објави нотификација\",\n    \"nav_button_subscribe\": \"Претплати се на тема\",\n    \"action_bar_unmute_notifications\": \"Одглуши ги нотификациите\",\n    \"action_bar_toggle_mute\": \"Заглуши/Загуши ги нотификациите\",\n    \"message_bar_publish\": \"Објави порака\",\n    \"nav_button_connecting\": \"се конектира\",\n    \"nav_upgrade_banner_label\": \"Надградете на ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози\",\n    \"alert_notification_permission_required_title\": \"Известувањата се исклучени\",\n    \"alert_notification_permission_required_description\": \"Дајте му дозвола на вашиот прелистувач да прикажува известувања\",\n    \"nav_button_muted\": \"Известувањата се загушени\",\n    \"alert_not_supported_title\": \"Известувањата не се поддржани\",\n    \"alert_not_supported_description\": \"Известувањата не се поддржани во вашиот прелистувач\",\n    \"alert_not_supported_context_description\": \"Известувањата се поддржани само преку HTTPS. Ова е ограничување на <mdnLink>Notifications API </mdnLink>.\",\n    \"notifications_list\": \"Список на известувања\",\n    \"notifications_list_item\": \"Известување\",\n    \"notifications_mark_read\": \"Означи како прочитано\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Име на фајл за прилог\",\n    \"notifications_attachment_file_app\": \"Фајл со апликација за Android\",\n    \"notifications_attachment_file_document\": \"друг документ\",\n    \"alert_notification_permission_required_button\": \"Дајте дозвола сега\",\n    \"alert_notification_permission_denied_title\": \"Известувањата се блокирани\",\n    \"alert_notification_permission_denied_description\": \"Ве молиме повторно овозможете ги во вашиот пребарувач\",\n    \"alert_notification_ios_install_required_title\": \"Потребна е инсталација на iOS\",\n    \"alert_notification_ios_install_required_description\": \"Кликнете на иконата Сподели и Додај на почетниот екран за да овозможите известувања на iOS\",\n    \"notifications_delete\": \"Избриши\",\n    \"notifications_copied_to_clipboard\": \"Копирано во таблата со исечоци\",\n    \"notifications_tags\": \"Ознаки\",\n    \"notifications_priority_x\": \"Приоритет {{приоритет}}\",\n    \"notifications_new_indicator\": \"Ново известување\",\n    \"notifications_attachment_image\": \"Слика од прилог\",\n    \"notifications_attachment_copy_url_title\": \"Копирај URL-адресата на прилогот во таблата со исечоци\",\n    \"notifications_attachment_open_title\": \"Оди на {{url}}\",\n    \"notifications_attachment_open_button\": \"Отвори го прилогот\",\n    \"notifications_attachment_link_expires\": \"линкот истекува {{date}}\",\n    \"notifications_attachment_link_expired\": \"линкот за преземање е истечен\",\n    \"notifications_attachment_file_image\": \"слика фајл\",\n    \"notifications_attachment_file_video\": \"видео фајл\",\n    \"notifications_attachment_file_audio\": \"аудио фајл\",\n    \"notifications_click_copy_url_button\": \"Копирај линк\",\n    \"notifications_click_open_button\": \"Отвори линк\",\n    \"notifications_actions_open_url_title\": \"Оди на {{url}}\",\n    \"notifications_actions_not_supported\": \"Дејството не е поддржано во веб-апликацијата\",\n    \"notifications_actions_http_request_title\": \"Испрати HTTP {{method}} на {{url}}\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ms.json",
    "content": "{\n    \"signup_disabled\": \"Pendaftaran dilumpuhkan\",\n    \"signup_error_username_taken\": \"Nama pengguna {{username}} telah digunakan\",\n    \"signup_error_creation_limit_reached\": \"Pendaftaran sudah melebihi had\",\n    \"login_form_button_submit\": \"Log masuk\",\n    \"login_disabled\": \"Log masuk dilumpuhkan\",\n    \"action_bar_show_menu\": \"Tunjuk menu\",\n    \"action_bar_logo_alt\": \"logo ntfy\",\n    \"action_bar_settings\": \"Tetapan\",\n    \"action_bar_account\": \"Akaun\",\n    \"action_bar_change_display_name\": \"Tukar nama paparan\",\n    \"action_bar_reservation_add\": \"Tempah topik\",\n    \"action_bar_reservation_edit\": \"Tukar tempahan\",\n    \"action_bar_reservation_delete\": \"Batalkan tempahan\",\n    \"action_bar_reservation_limit_reached\": \"Melebihi had\",\n    \"action_bar_mute_notifications\": \"Senyapkan notifikasi\",\n    \"action_bar_toggle_action_menu\": \"Buka/tutup menu aksi\",\n    \"action_bar_profile_settings\": \"Tetapan\",\n    \"action_bar_profile_logout\": \"Log keluar\",\n    \"action_bar_sign_in\": \"Log masuk\",\n    \"action_bar_sign_up\": \"Daftar\",\n    \"message_bar_type_message\": \"Tulis mesej disini\",\n    \"message_bar_show_dialog\": \"Tunjuk dialog terterbit\",\n    \"message_bar_publish\": \"Hantar mesej\",\n    \"nav_topics_title\": \"Topik terlanggan\",\n    \"nav_button_all_notifications\": \"Semua notifikasi\",\n    \"nav_button_account\": \"Akaun\",\n    \"nav_button_settings\": \"Tetapan\",\n    \"nav_button_documentation\": \"Dokumentasi\",\n    \"nav_button_publish_message\": \"Terbitkan notifikasi\",\n    \"nav_button_subscribe\": \"Melanggan topik\",\n    \"nav_button_muted\": \"Notifikasi disenyapkan\",\n    \"nav_button_connecting\": \"menyambung\",\n    \"nav_upgrade_banner_label\": \"Naik taraf kepada ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Tempah topik, lebih banyak mesej & e-mel dan lampiran yang lebih besar\",\n    \"alert_notification_permission_required_button\": \"Benarkan\",\n    \"alert_notification_permission_denied_description\": \"Sila benarkan semula di pelayar anda\",\n    \"alert_notification_ios_install_required_title\": \"Perlukan muatan iOS\",\n    \"notifications_tags\": \"Tag\",\n    \"notifications_priority_x\": \"Keutamaan {{priority}}\",\n    \"notifications_new_indicator\": \"Notifikasi baharu\",\n    \"notifications_attachment_copy_url_button\": \"Salin URL\",\n    \"notifications_attachment_link_expires\": \"pautan tamat tempoh pada {{date}}\",\n    \"notifications_attachment_link_expired\": \"link muat turun telah tamat tempoh\",\n    \"notifications_attachment_file_image\": \"fail imej\",\n    \"notifications_attachment_file_video\": \"fail video\",\n    \"notifications_attachment_file_audio\": \"fail audio\",\n    \"notifications_attachment_file_app\": \"Fail aplikasi Android\",\n    \"notifications_attachment_file_document\": \"lain-lain dokumen\",\n    \"notifications_click_copy_url_title\": \"Salin pautan URL ke papan klip\",\n    \"notifications_click_copy_url_button\": \"Salin pautan\",\n    \"notifications_click_open_button\": \"Buka pautan\",\n    \"notifications_actions_open_url_title\": \"Pergi ke {{url}}\",\n    \"notifications_actions_not_supported\": \"Tindakan tidak disokong di aplikasi web\",\n    \"notifications_actions_http_request_title\": \"Hantar {{method}} HTTP ke {{url}}\",\n    \"notifications_actions_failed_notification\": \"Tindakan tidak berjaya\",\n    \"notifications_none_for_topic_title\": \"Anda belum menerima sebarang pemberitahuan untuk topik ini lagi.\",\n    \"notifications_example\": \"Contoh\",\n    \"display_name_dialog_title\": \"Tukar nama paparan\",\n    \"display_name_dialog_placeholder\": \"Nama paparan\",\n    \"common_cancel\": \"Batal\",\n    \"common_back\": \"Kembali\",\n    \"common_save\": \"Simpan\",\n    \"common_add\": \"Tambah\",\n    \"signup_form_toggle_password_visibility\": \"Tunjuk/sembunyikan kata laluan\",\n    \"action_bar_send_test_notification\": \"Hantar notifikasi percubaan\",\n    \"action_bar_toggle_mute\": \"Senyap/nyahsenyapkan notifikasi\",\n    \"common_copy_to_clipboard\": \"Salin ke papan klip\",\n    \"signup_title\": \"Cipta akaun baru ntfy\",\n    \"login_link_signup\": \"Daftar\",\n    \"signup_form_username\": \"Nama pengguna\",\n    \"signup_form_confirm_password\": \"Pengesahan kata laluan\",\n    \"signup_already_have_account\": \"Sudah daftar? Log masuk disini!\",\n    \"action_bar_clear_notifications\": \"Padam semua notifikasi\",\n    \"signup_form_password\": \"Kata laluan\",\n    \"signup_form_button_submit\": \"Daftar\",\n    \"login_title\": \"Log masuk ke akaun ntfy\",\n    \"action_bar_unmute_notifications\": \"Nyahsenyapkan notifikasi\",\n    \"action_bar_unsubscribe\": \"Nyahlanggan\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"message_bar_error_publishing\": \"Ralat menerbitkan notifikasi\",\n    \"alert_notification_permission_required_title\": \"Notifikasi telah dinyahkan\",\n    \"notifications_mark_read\": \"Tanda sebagai telah dibaca\",\n    \"alert_notification_permission_required_description\": \"Berikan kebenaran pelayar anda untuk memaparkan pemberitahuan desktop\",\n    \"alert_notification_permission_denied_title\": \"Notifikasi disekat\",\n    \"notifications_delete\": \"Padam\",\n    \"notifications_copied_to_clipboard\": \"Salin ke papan klip\",\n    \"notifications_attachment_image\": \"Imej lampiran\",\n    \"alert_notification_ios_install_required_description\": \"Klik pada ikon Kongsi dan Tambah ke Skrin Utama untuk membenarkan pemberitahuan pada iOS\",\n    \"alert_not_supported_title\": \"Notifikasi tidak disokong\",\n    \"alert_not_supported_description\": \"Notifikasi tidak disokong di pelayar anda\",\n    \"notifications_list\": \"Senarai notifikasi\",\n    \"notifications_list_item\": \"Notifikasi\",\n    \"notifications_attachment_copy_url_title\": \"Salin URL lampiran ke papan klip\",\n    \"notifications_attachment_open_title\": \"Pergi ke {{url}}\",\n    \"notifications_attachment_open_button\": \"Buka lampiran\",\n    \"notifications_none_for_topic_description\": \"Untuk menghantar pemberitahuan kepada topik ini, hanya PUT atau POST ke URL topik.\",\n    \"notifications_no_subscriptions_title\": \"Nampaknya anda belum mempunyai sebarang langganan lagi.\",\n    \"notifications_none_for_any_title\": \"Anda tidak menerima sebarang notifikasi.\",\n    \"notifications_none_for_any_description\": \"Untuk menghantar pemberitahuan kepada topik, hanya PUT atau POST ke URL topik. Berikut ialah contoh menggunakan salah satu topik anda.\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Nombor telefon disalin ke papan klip\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Hantar SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Telefon diri sendiri\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Kod verifikasi\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Panggil\",\n    \"account_usage_title\": \"Penggunaan\",\n    \"account_usage_of_limit\": \": {{limit}}\",\n    \"account_usage_unlimited\": \"Tanpa had\",\n    \"account_usage_limits_reset_daily\": \"Had penggunaan akan di set semula pada tengah malam (UTC)\",\n    \"account_basics_tier_title\": \"Jenis akaun\",\n    \"account_basics_tier_description\": \"Tahap kekuatan akaun anda\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(dengan peringkat {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(tiada peringkat)\",\n    \"account_basics_tier_basic\": \"Asas\",\n    \"account_basics_tier_free\": \"Percuma\",\n    \"account_basics_tier_change_button\": \"Ubah\",\n    \"account_delete_title\": \"Padam akaun\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} paggilan harian\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Tiada panggilan\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"bulan\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Tahap\",\n    \"account_tokens_table_never_expires\": \"Tidak pernah luput\",\n    \"account_tokens_table_expires_header\": \"Luput\",\n    \"account_tokens_table_current_session\": \"Sesi pelayar semasa\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token akses telah disalin\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"TIdak boleh ubah atau padam token sesi semasa\",\n    \"account_basics_phone_numbers_dialog_title\": \"Tambah nombor telefon\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Nombor telefon\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"cth: +1222333444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"cth: 123456\",\n    \"account_basics_tier_admin\": \"Pengurus\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Kod pengesahan\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_tier_interval_monthly\": \"bulanan\",\n    \"account_basics_tier_interval_yearly\": \"tahunan\",\n    \"account_basics_tier_upgrade_button\": \"Naik taraf ke Pro\",\n    \"account_basics_tier_manage_billing_button\": \"Urus cara pembayaran\",\n    \"account_basics_tier_paid_until\": \"Langganan telah dibayar sehingga {{date}}, dan akan diperbaharui secara automatik\",\n    \"account_basics_tier_payment_overdue\": \"Bayaran anda tertunggak. Sila kemas kini kaedah pembayaran anda atau akaun anda akan diturunkan tidak lama lagi.\",\n    \"account_basics_tier_canceled_subscription\": \"Langganan anda telah dibatalkan dan akan diturunkan taraf kepada akaun percuma pada {{date}}.\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} setiap fail, akan dipadam selepas {{expiry}}\",\n    \"account_upgrade_dialog_interval_monthly\": \"Bulanan\",\n    \"account_usage_messages_title\": \"Mesej yang telah diterbitkan\",\n    \"account_usage_emails_title\": \"Email telah dihantar\",\n    \"account_usage_calls_title\": \"Panggilan telefon\",\n    \"account_usage_calls_none\": \"Tiada panggilan telefon boleh dibuat dengan akaun ini\",\n    \"account_usage_reservations_none\": \"Tiada topik simpanan untuk akaun ini\",\n    \"account_usage_reservations_title\": \"Topik simpanan\",\n    \"account_usage_attachment_storage_title\": \"Simpanan lampiran\",\n    \"account_delete_dialog_button_cancel\": \"Batal\",\n    \"account_usage_cannot_create_portal_session\": \"Tidak dapat membuka portal pengebilan\",\n    \"account_delete_description\": \"Padam akaun selamanya\",\n    \"account_delete_dialog_label\": \"Kata laluan\",\n    \"account_delete_dialog_button_submit\": \"Padamkan akaun secara kekal\",\n    \"account_upgrade_dialog_title\": \"Tukar peringkat akaun\",\n    \"account_delete_dialog_description\": \"Ini akan memadamkan akaun anda secara kekal, termasuk semua data yang disimpan pada pelayan. Selepas pemadaman, nama pengguna anda tidak akan tersedia selama 7 hari. Jika anda benar-benar mahu meneruskan, sila sahkan dengan kata laluan anda dalam kotak di bawah.\",\n    \"account_upgrade_dialog_interval_yearly\": \"Tahunan\",\n    \"account_delete_dialog_billing_warning\": \"Memadamkan akaun anda turut membatalkan langganan pengebilan anda serta-merta. Anda tidak akan mempunyai akses kepada papan pemuka pengebilan lagi.\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"jimat {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"jimat sehingga {{discount}}%\",\n    \"account_upgrade_dialog_cancel_warning\": \"Ini akan <strong>membatalkan langganan anda</strong> dan menurunkan taraf akaun anda pada {{date}}. Pada tarikh tersebut, tempahan topik serta mesej yang dicache pada pelayan <strong>akan dipadamkan</strong>.\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} email harian\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Tambahan</strong>: Apabila menaik taraf antara pelan berbayar, perbezaan harga akan <strong>caj serta-merta</strong>. Apabila menurunkan taraf kepada peringkat yang lebih rendah, baki akan digunakan untuk membayar bagi tempoh pengebilan akan datang.\",\n    \"account_tokens_table_create_token_button\": \"Cipta token akses\",\n    \"account_tokens_table_last_origin_tooltip\": \"Daripada alamat IP {{ip}}, klik untuk mencari\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservation}} topik tersimpan\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} mesej harian\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservation}} topik tersimpan\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mesej harian\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Tiada topik tersimpan\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} email harian\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} setiap fail\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} paggilan harian\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} jumlah simpanan\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} setahun. Dibilkan setiap bulan.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Pilih\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} dibilkan setiap tahun. Jimat {{simpan}}.\",\n    \"account_upgrade_dialog_tier_current_label\": \"Semasa\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Untuk pertanyaan pengebilan, sila rujuk <Link>laman web</Link> kami.\",\n    \"account_upgrade_dialog_button_cancel\": \"Batal\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Untuk pertanyaan  pengebilan, sila <Link>hubungi kami</Link> secara terus.\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Daftar sekarang\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Batalkan langganan\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Kemas kini langganan\",\n    \"account_upgrade_dialog_button_pay_now\": \"Bayar sekarang dan langgan\",\n    \"account_tokens_title\": \"Token akses\",\n    \"account_tokens_description\": \"Gunakan token akses semasa menerbitkan dan melanggan melalui API ntfy, jadi anda tidak perlu menghantar bukti kelayakan akaun anda. Lihat <Link>dokumentasi</Link> untuk mengetahui lebih lanjut.\",\n    \"account_tokens_table_last_access_header\": \"Akses terakhir\"\n}\n"
  },
  {
    "path": "web/public/static/langs/nb_NO.json",
    "content": "{\n    \"nav_button_subscribe\": \"Abonner på emne\",\n    \"action_bar_settings\": \"Innstillinger\",\n    \"action_bar_send_test_notification\": \"Send testmerknad\",\n    \"action_bar_clear_notifications\": \"Tøm alle merknader\",\n    \"action_bar_unsubscribe\": \"Meld av\",\n    \"message_bar_type_message\": \"Skriv en melding her\",\n    \"nav_button_all_notifications\": \"Alle merknader\",\n    \"nav_button_settings\": \"Innstillinger\",\n    \"nav_button_documentation\": \"Dokumentasjon\",\n    \"nav_topics_title\": \"Abonnerte emner\",\n    \"alert_notification_permission_required_title\": \"Merknader er avskrudd\",\n    \"alert_not_supported_title\": \"Merknader støttes ikke\",\n    \"notifications_copied_to_clipboard\": \"Kopiert til utklippstavlen\",\n    \"notifications_attachment_copy_url_title\": \"Kopier vedleggsnettadresse til utklippstavlen\",\n    \"notifications_attachment_copy_url_button\": \"Kopier nettadresse\",\n    \"notifications_attachment_open_button\": \"Åpne vedlegg\",\n    \"notifications_attachment_open_title\": \"Gå til {{url}}\",\n    \"notifications_attachment_link_expires\": \"lenken utløper {{date}}\",\n    \"notifications_click_copy_url_title\": \"Kopier lenke-nettadresse til utklippstavlen\",\n    \"notifications_actions_open_url_title\": \"Gå til {{url}}\",\n    \"notifications_tags\": \"Etiketter\",\n    \"notifications_attachment_link_expired\": \"nedlastingslenken har utløpt\",\n    \"notifications_none_for_any_title\": \"Du har ikke mottatt noen merknader.\",\n    \"notifications_click_open_button\": \"Åpne lenke\",\n    \"notifications_none_for_topic_title\": \"Du har ikke mottatt noen merknader for dette emnet enda.\",\n    \"notifications_example\": \"Eksempel\",\n    \"publish_dialog_title_topic\": \"Publiser til {{topic}}\",\n    \"publish_dialog_priority_min\": \"Min. prioritet\",\n    \"publish_dialog_priority_low\": \"Lav prioritet\",\n    \"publish_dialog_priority_default\": \"Forvalgt prioritet\",\n    \"publish_dialog_priority_high\": \"Høy prioritet\",\n    \"publish_dialog_priority_max\": \"Maks. prioritet\",\n    \"publish_dialog_base_url_label\": \"Tjeneste-nettadresse\",\n    \"publish_dialog_message_label\": \"Melding\",\n    \"publish_dialog_priority_label\": \"Prioritet\",\n    \"publish_dialog_tags_label\": \"Etiketter\",\n    \"publish_dialog_click_placeholder\": \"Nettadresse som åpnes når merknaden klikkes\",\n    \"publish_dialog_attach_label\": \"Vedleggs-nettadresse\",\n    \"publish_dialog_attach_placeholder\": \"Legg ved fil per nettadresse, f.eks. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_label\": \"Filnavn\",\n    \"publish_dialog_delay_label\": \"Forsinkelse\",\n    \"publish_dialog_filename_placeholder\": \"Vedleggets filnavn\",\n    \"publish_dialog_other_features\": \"Andre funksjoner:\",\n    \"publish_dialog_chip_email_label\": \"Videresend til e-post\",\n    \"publish_dialog_chip_topic_label\": \"Endre emne\",\n    \"publish_dialog_button_cancel_sending\": \"Avbryt forsendelse\",\n    \"publish_dialog_chip_attach_file_label\": \"Legg ved lokal fil\",\n    \"publish_dialog_attached_file_title\": \"Vedlagt fil:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Vedleggsfilnavn\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Bruk en annen tjener\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Avbryt\",\n    \"publish_dialog_drop_file_here\": \"Slipp filen her\",\n    \"subscribe_dialog_subscribe_title\": \"Abonner på emne\",\n    \"emoji_picker_search_placeholder\": \"Søk etter emoji\",\n    \"subscribe_dialog_login_button_login\": \"Logg inn\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Abonner\",\n    \"subscribe_dialog_login_title\": \"Innlogging kreves\",\n    \"subscribe_dialog_login_username_label\": \"Brukernavn, f.eks. phil\",\n    \"subscribe_dialog_login_password_label\": \"Passord\",\n    \"prefs_notifications_title\": \"Merknader\",\n    \"prefs_notifications_sound_title\": \"Merknadslyd\",\n    \"prefs_notifications_sound_no_sound\": \"Ingen lyd\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonym\",\n    \"error_boundary_stack_trace\": \"Stabelspor\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopier stabelspor\",\n    \"message_bar_error_publishing\": \"Kunne ikke publisere merknader\",\n    \"nav_button_publish_message\": \"Publiser merknad\",\n    \"publish_dialog_title_no_topic\": \"Publiser merknad\",\n    \"publish_dialog_progress_uploading\": \"Laster opp …\",\n    \"publish_dialog_progress_uploading_detail\": \"Laster opp {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"notifications_loading\": \"Laster inn merknader …\",\n    \"publish_dialog_message_published\": \"Merknad publisert\",\n    \"publish_dialog_email_placeholder\": \"Adresse å videresende merknaden til, f.eks. phil@example.com\",\n    \"error_boundary_gathering_info\": \"Hent mer info …\",\n    \"prefs_notifications_sound_description_some\": \"Merknader spiller {{sound}}-lyd når de mottas\",\n    \"prefs_notifications_min_priority_description_any\": \"Viser alle merknader, uavhengig av prioritet\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Vis merknader hvis prioritet er {{number}} ({{name}}) eller høyere\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Høy prioritet og høyere\",\n    \"prefs_notifications_min_priority_max_only\": \"Kun maks. prioritet\",\n    \"prefs_notifications_delete_after_one_day\": \"Etter én dag\",\n    \"prefs_notifications_delete_after_one_week\": \"Etter én uke\",\n    \"prefs_notifications_delete_after_one_month\": \"Etter én måned\",\n    \"prefs_notifications_delete_after_never_description\": \"Merknader blir aldri slettet automatisk\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Merknader slettes automatisk etter tre timer\",\n    \"prefs_users_title\": \"Håndter brukere\",\n    \"prefs_users_add_button\": \"Legg til bruker\",\n    \"prefs_users_table_user_header\": \"Bruker\",\n    \"prefs_users_dialog_title_add\": \"Legg til bruker\",\n    \"prefs_users_dialog_title_edit\": \"Rediger bruker\",\n    \"prefs_users_dialog_base_url_label\": \"Tjeneste-nettadresse, f.eks. https://ntfy.sh\",\n    \"prefs_users_dialog_password_label\": \"Passord\",\n    \"common_save\": \"Lagre\",\n    \"prefs_appearance_title\": \"Utseende\",\n    \"prefs_appearance_language_title\": \"Språk\",\n    \"prefs_users_dialog_username_label\": \"Brukernavn, f.eks. phil\",\n    \"priority_low\": \"lav\",\n    \"priority_default\": \"forvalg\",\n    \"priority_high\": \"høy\",\n    \"priority_max\": \"maks.\",\n    \"alert_notification_permission_required_button\": \"Innvilg nå\",\n    \"publish_dialog_topic_label\": \"Emnenavn\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Merknader slettes automatisk etter én dag\",\n    \"notifications_click_copy_url_button\": \"Kopier lenke\",\n    \"error_boundary_title\": \"Oida, ntfy krasjet\",\n    \"publish_dialog_message_placeholder\": \"Skriv en melding her\",\n    \"publish_dialog_button_cancel\": \"Avbryt\",\n    \"prefs_notifications_min_priority_title\": \"Minimumsprioritet\",\n    \"prefs_notifications_delete_after_title\": \"Slett merknader\",\n    \"prefs_notifications_delete_after_never\": \"Aldri\",\n    \"publish_dialog_email_label\": \"E-post\",\n    \"publish_dialog_button_send\": \"Send\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Merknader slettes automatisk etter én uke\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Merknader slettes automatisk etter én måned\",\n    \"priority_min\": \"min.\",\n    \"common_back\": \"Tilbake\",\n    \"prefs_notifications_delete_after_three_hours\": \"Etter tre timer\",\n    \"prefs_users_table_base_url_header\": \"Tjeneste-nettadresse\",\n    \"common_cancel\": \"Avbryt\",\n    \"common_add\": \"Legg til\",\n    \"publish_dialog_chip_attach_url_label\": \"Legg til fil med nettadresse\",\n    \"publish_dialog_tags_placeholder\": \"Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi\",\n    \"prefs_notifications_sound_description_none\": \"Merknader spiller ikke lyd når de mottas\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Emnenavn, f.eks. phil_varsler\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Forvalgt prioritet og høyere\",\n    \"notifications_no_subscriptions_title\": \"Det ser ut til at du ikke har noen abonnementer ennå.\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"overskrider {{fileSizeLimit}} filgrense og kvote, {{remainingBytes}} gjenstår\",\n    \"publish_dialog_attachment_limits_file_reached\": \"overskrider filgrensen på {{fileSizeLimit}}\",\n    \"publish_dialog_title_label\": \"Tittel\",\n    \"publish_dialog_title_placeholder\": \"Varslingstittel, f.eks. Diskplassvarsel\",\n    \"publish_dialog_topic_placeholder\": \"Emnenavn, f.eks. halgeir_varsler\",\n    \"publish_dialog_chip_click_label\": \"Klikk URL\",\n    \"publish_dialog_chip_delay_label\": \"Forsink leveringen\",\n    \"publish_dialog_details_examples_description\": \"For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se <docsLink>dokumentasjonen</docsLink>.\",\n    \"publish_dialog_base_url_placeholder\": \"Tjeneste-URL, f.eks. https://example.com\",\n    \"alert_notification_permission_required_description\": \"Gi nettleseren din tillatelse til å vise skrivebordsvarsler\",\n    \"alert_not_supported_description\": \"Varsler støttes ikke i nettleseren din\",\n    \"notifications_attachment_file_app\": \"Android-app-fil\",\n    \"notifications_no_subscriptions_description\": \"Klikk på \\\"{{linktext}}\\\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.\",\n    \"notifications_actions_http_request_title\": \"Send HTTP {{metode}} til {{url}}\",\n    \"notifications_none_for_any_description\": \"For å sende varsler til et emne, bare PUT eller POST til emne-URLen. Her er et eksempel som bruker et av emnene dine.\",\n    \"notifications_more_details\": \"For mer informasjon, sjekk ut <websiteLink>nettstedet</websiteLink> eller <docsLink>dokumentasjonen</docsLink>.\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"overskrider kvoten, {{remainingBytes}} gjenstår\",\n    \"publish_dialog_click_reset\": \"Fjern klikk-URL\",\n    \"publish_dialog_delay_placeholder\": \"Forsinket levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \\\"{{naturalLanguage}}\\\" (bare på engelsk)\",\n    \"emoji_picker_search_clear\": \"Tøm søk\",\n    \"subscribe_dialog_subscribe_description\": \"Det kan hende emner ikke er passordsbeskyttet, så velg et navn som ikke er enkelt å gjette. Når du har abonnert kan du utføre PUT/POST av merknader.\",\n    \"publish_dialog_checkbox_publish_another\": \"Publiser enda en\",\n    \"subscribe_dialog_login_description\": \"Dette emnet er passordbeskyttet. Vennligst skriv inn brukernavn og passord for å abonnere.\",\n    \"prefs_notifications_sound_play\": \"Spill av valgt lyd\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Bruker {{brukernavn}} ikke autorisert\",\n    \"prefs_users_delete_button\": \"Slett bruker\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy-nettappen trenger IndexedDB for å fungere, og nettleseren din støtter ikke IndexedDB i privat nettlesingsmodus.<br/><br/>Selv om dette er uheldig, gir det heller ikke så mye mening å bruke ntfy-nettappen i privat surfemodus uansett, fordi alt er lagret i nettleserlagringen. Du kan lese mer om det <githubLink>i denne GitHub-feilmeldingen</githubLink>, eller snakk med oss på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_show_menu\": \"Vis meny\",\n    \"action_bar_toggle_mute\": \"Aktiver/deaktiver notifikasjoner\",\n    \"prefs_notifications_min_priority_description_max\": \"Vis merknader hvis prioritet er 5 (maks.)\",\n    \"prefs_notifications_min_priority_any\": \"Hvilken som helst prioritet\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Lav prioritet og høyere\",\n    \"prefs_users_description\": \"Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.\",\n    \"error_boundary_description\": \"Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_logo_alt\": \"ntfy-logo\",\n    \"message_bar_publish\": \"Publiser melding\",\n    \"action_bar_toggle_action_menu\": \"Åpne/lukk handlingsmeny\",\n    \"message_bar_show_dialog\": \"Vis publiseringsdialog\",\n    \"nav_button_muted\": \"Varsler dempet\",\n    \"nav_button_connecting\": \"kobler til\",\n    \"notifications_list\": \"Varslingsliste\",\n    \"notifications_list_item\": \"Varsling\",\n    \"notifications_mark_read\": \"Merk som lest\",\n    \"notifications_delete\": \"Slett\",\n    \"notifications_priority_x\": \"Prioritet {{prioritet}}\",\n    \"notifications_new_indicator\": \"Nytt varsel\",\n    \"notifications_attachment_image\": \"Vedlagt bilde\",\n    \"notifications_attachment_file_image\": \"bildefil\",\n    \"notifications_attachment_file_video\": \"videofil\",\n    \"notifications_attachment_file_audio\": \"lydfil\",\n    \"notifications_attachment_file_document\": \"annet dokument\",\n    \"notifications_actions_not_supported\": \"Handling støttes ikke i nettappen\",\n    \"notifications_none_for_topic_description\": \"For å sende varsler til dette emnet, bare PUT eller POST til emne-URLen.\",\n    \"publish_dialog_emoji_picker_show\": \"Velg emoji\",\n    \"publish_dialog_topic_reset\": \"Tilbakestill emne\",\n    \"publish_dialog_click_label\": \"Klikk URL\",\n    \"publish_dialog_email_reset\": \"Fjern videresending av e-post\",\n    \"publish_dialog_attach_reset\": \"Fjern URL-vedlegg\",\n    \"publish_dialog_delay_reset\": \"Fjern forsinket levering\",\n    \"publish_dialog_attached_file_remove\": \"Fjern vedlagt fil\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Tjeneste-URL\",\n    \"prefs_users_table\": \"Brukertabell\",\n    \"prefs_users_edit_button\": \"Rediger bruker\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Privat surfing støttes ikke\",\n    \"action_bar_account\": \"Konto\",\n    \"action_bar_profile_settings\": \"Innstillinger\",\n    \"nav_button_account\": \"Konto\",\n    \"signup_title\": \"Opprett en ntfy-konto\",\n    \"signup_form_username\": \"Brukernavn\",\n    \"signup_form_password\": \"Passord\",\n    \"signup_form_button_submit\": \"Meld deg på\",\n    \"signup_form_confirm_password\": \"Bekreft passord\",\n    \"signup_disabled\": \"Registrering er deaktivert\",\n    \"common_copy_to_clipboard\": \"Kopier til utklippstavle\",\n    \"signup_form_toggle_password_visibility\": \"Slå av/på passordvisning\",\n    \"signup_already_have_account\": \"Har du allerede en konto? Logg inn!\",\n    \"signup_error_username_taken\": \"Brukernavnet {{username}} er allerede opptatt\",\n    \"signup_error_creation_limit_reached\": \"Grense for nye kontoer nådd\",\n    \"login_title\": \"Logg inn på ntfy-kontoen din\",\n    \"login_form_button_submit\": \"Logg inn\",\n    \"login_link_signup\": \"Registrer deg\",\n    \"login_disabled\": \"Innlogging deaktivert\",\n    \"action_bar_change_display_name\": \"Endre visningsnavn\",\n    \"account_basics_tier_interval_yearly\": \"årlig\",\n    \"account_basics_tier_change_button\": \"Endre\",\n    \"account_usage_reservations_title\": \"Reserverte emner\",\n    \"account_usage_cannot_create_portal_session\": \"Kunne ikke åpne betalingsportalen\",\n    \"account_delete_dialog_label\": \"Passord\",\n    \"account_tokens_table_copied_to_clipboard\": \"Tilgangstoken kopiert\",\n    \"account_tokens_table_last_origin_tooltip\": \"Fra IP-adresse {{ip}}, klikk for å gjøre oppslag\",\n    \"account_tokens_dialog_title_create\": \"Opprett tilgangstoken\",\n    \"account_tokens_delete_dialog_title\": \"Slett tilgangstoken\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Kan ikke slette eller redigere innlogget bruker\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Bare jeg kan publisere og abonnere\",\n    \"prefs_reservations_dialog_access_label\": \"Tilgang\",\n    \"reservation_delete_dialog_action_keep_title\": \"Behold mellomlagrede meldinger og vedlegg\",\n    \"action_bar_reservation_add\": \"Reserver emne\",\n    \"action_bar_reservation_edit\": \"Endre reservasjon\",\n    \"action_bar_reservation_delete\": \"Fjern reservasjon\",\n    \"action_bar_reservation_limit_reached\": \"Grense nådd\",\n    \"account_basics_phone_numbers_dialog_description\": \"For å bruke ringevarslingsfunksjonen må du legge til og verifisere minst ett telefonnummer. Verifisering kan gjøres vis SMS eller oppringing.\",\n    \"account_basics_tier_interval_monthly\": \"månedlig\",\n    \"account_basics_tier_upgrade_button\": \"Oppgrader til Pro\",\n    \"account_usage_emails_title\": \"E-poster sendt\",\n    \"account_delete_description\": \"Slett kontoen din permanent\",\n    \"account_usage_calls_title\": \"Telefonsamtaler\",\n    \"account_upgrade_dialog_interval_monthly\": \"Månedlig\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reserverte emner\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} daglige meldinger\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} daglige e-poster\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} daglige telefonsamtaler\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Valgt\",\n    \"account_upgrade_dialog_tier_current_label\": \"Nåværende\",\n    \"account_upgrade_dialog_button_cancel\": \"Avbryt\",\n    \"account_upgrade_dialog_billing_contact_email\": \"For faktureringsspørsmål, vennligst <Link>kontakt oss</Link> direkte.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Etikett\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Kan ikke redigere eller slette nåværende økt-token\",\n    \"account_tokens_table_create_token_button\": \"Opprett tilgangstoken\",\n    \"account_tokens_dialog_expires_unchanged\": \"La utløpsdato være uendret\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token utløper om {{hours}} timer\",\n    \"account_tokens_delete_dialog_description\": \"Før du sletter et tilgangstoken, sørg for at ingen applikasjoner eller script bruker det. <strong>Denne handlingen kan ikke angres</strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Slett token permanent\",\n    \"prefs_users_description_no_sync\": \"Brukere og passord synkroniseres ikke til kontoen din.\",\n    \"prefs_reservations_dialog_title_delete\": \"Slett emnereservasjon\",\n    \"prefs_reservations_dialog_topic_label\": \"Emne\",\n    \"display_name_dialog_title\": \"Endre visningsnavn\",\n    \"reserve_dialog_checkbox_label\": \"Rserver emne og sett opp tilgang\",\n    \"publish_dialog_chip_call_label\": \"Telefonsamtale\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_basics_tier_basic\": \"Grunnleggende\",\n    \"account_basics_tier_canceled_subscription\": \"Abonnementet ditt ble avsluttet og blir degradert til en gratiskonto den {{date}}.\",\n    \"account_delete_dialog_description\": \"Dette vil slette kontoen din permanent, inkludert alle data som er lagret på serveren. Etter sletting vil brukernavnet ditt være utilgjengelig i 7 dager. Hvis du virkelig vil fortsette, vennligst bekreft ved å skrive passordet ditt i boksen under.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Pro-rate</strong>: Når du oppgraderer mellom betalte kontotyper, vil prisforskjellen <strong>bli fakturert umiddelbart</strong>. Når du nedgraderer til en billigere kontotype, vil det allerede innbetalte beløpet brukes til å betale for fremtidige regningsperioder.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Det valgte nivået tillater færre reserverte emner enn ditt nåvære nivå. Før du endrer nivå, <strong>vennligst slett minst {{count}} reservasjoner</strong>. Du kan slette reservasjoner i <Link>Innstillingene</Link>.\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} daglig melding\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} pr. år. Fakturert månedlig.\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Registrer deg nå\",\n    \"account_upgrade_dialog_button_pay_now\": \"Betal nå og abonner\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Avslutt abonnement\",\n    \"account_tokens_description\": \"Bruk tilgangstokener når du publiserer og abonnerer via ntfy-APIet, slik at du ikke trenger å sende innloggingsinformasjon for kontoen din. Se <Link>dokumentasjonen</Link> for å lære mer.\",\n    \"account_tokens_table_current_session\": \"Nåværende nettleserøkt\",\n    \"prefs_appearance_theme_system\": \"System (standard)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Varslinger mottas når web-appen kjører (via WebSocket)\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_dark\": \"Mørk modus\",\n    \"prefs_appearance_theme_light\": \"Lys modus\",\n    \"prefs_reservations_title\": \"Reserverte emner\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klikk for å abonnere\",\n    \"prefs_reservations_table_everyone_read_write\": \"Alle kan publisere og abonnere\",\n    \"prefs_reservations_table_not_subscribed\": \"Ikke abonnent\",\n    \"prefs_reservations_table_everyone_write_only\": \"Jeg kan publisere og abonnere, alle andre kan publisere\",\n    \"prefs_reservations_dialog_title_add\": \"Reserver emne\",\n    \"prefs_reservations_dialog_title_edit\": \"Rediger reservert emne\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reservert emne\",\n    \"reservation_delete_dialog_action_delete_title\": \"Slett mellomlagrede meldinger og vedlegg\",\n    \"nav_upgrade_banner_label\": \"Oppgrader til ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Reserver emner, flere meldinger & e-poster, og større vedlegg\",\n    \"account_delete_dialog_button_submit\": \"Slett konto permanent\",\n    \"account_basics_username_description\": \"Hei, det er deg ❤\",\n    \"account_basics_username_admin_tooltip\": \"Du er administrator\",\n    \"account_basics_password_title\": \"Passord\",\n    \"account_basics_password_description\": \"Endre passordet ditt\",\n    \"account_usage_title\": \"Forbruk\",\n    \"account_delete_dialog_button_cancel\": \"Avbryt\",\n    \"account_tokens_dialog_title_delete\": \"Slett tilgangstoken\",\n    \"account_tokens_dialog_label\": \"Etikett, f.eks. Radarr-varslinger\",\n    \"prefs_reservations_table\": \"Tabell over reserverte emner\",\n    \"prefs_reservations_edit_button\": \"Rediger tilgang til emne\",\n    \"prefs_reservations_delete_button\": \"Nullstill tilgang til emne\",\n    \"prefs_reservations_table_topic_header\": \"Emne\",\n    \"account_basics_title\": \"Konto\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Verifiseringskode\",\n    \"alert_notification_permission_denied_title\": \"Varslinger blokkert\",\n    \"alert_notification_permission_denied_description\": \"Vennligst reaktiver dem i nettleseren din\",\n    \"alert_notification_ios_install_required_title\": \"iOS-installasjon kreves\",\n    \"alert_notification_ios_install_required_description\": \"Klikk på Del-ikonet og Legg til hjemmeskjerm for å aktivere varslinger på iOS\",\n    \"action_bar_mute_notifications\": \"Demp varslinger\",\n    \"action_bar_unmute_notifications\": \"Avdemp varslinger\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_logout\": \"Logg ut\",\n    \"action_bar_sign_in\": \"Logg inn\",\n    \"action_bar_sign_up\": \"Registrer deg\",\n    \"alert_not_supported_context_description\": \"Varslinger er kun støttet over HTTPS. Dette er en begrensning i <mdnLink>Varslings-APIet</mdnLink>.\",\n    \"notifications_actions_failed_notification\": \"Handling feilet\",\n    \"display_name_dialog_description\": \"Angi et alternativt navn for et emne som vises i abonneringslisten. Dette hjelper til med å enklere identifisere emner med kompliserte navn.\",\n    \"display_name_dialog_placeholder\": \"Visningnavn\",\n    \"publish_dialog_call_label\": \"Telefonsamtale\",\n    \"publish_dialog_call_item\": \"Ring telefonnummer {{number}}\",\n    \"publish_dialog_call_reset\": \"Fjern telefonsamtale\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Ingen verfiserte telefonnumre\",\n    \"publish_dialog_checkbox_markdown\": \"Formatter som Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Varslinger fra andre servere vil ikke bli tatt imot når webappen ikke er åpen\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generer navn\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Emne allerede reservert\",\n    \"account_basics_username_title\": \"Brukernavn\",\n    \"account_basics_password_dialog_title\": \"Endre passord\",\n    \"account_basics_password_dialog_current_password_label\": \"Nåværende passord\",\n    \"account_basics_password_dialog_new_password_label\": \"Nytt passord\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Bekreft passord\",\n    \"account_basics_password_dialog_button_submit\": \"Endre passord\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Passordet er feil\",\n    \"account_basics_phone_numbers_title\": \"Telefonnumre\",\n    \"account_basics_phone_numbers_description\": \"For telefonvarsling\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Ingen telefonnumre enda\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefonnummer kopiert til utklippstavle\",\n    \"account_basics_phone_numbers_dialog_title\": \"Legg til telefonnummer\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefonnummer\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"f.eks. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Send SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ring meg\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"f.eks. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Bekreft kode\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Ring\",\n    \"account_usage_of_limit\": \"av {{limit}}\",\n    \"account_usage_unlimited\": \"Ubegrenset\",\n    \"account_usage_limits_reset_daily\": \"Forbruksgrenser nullstilles hver dag ved midnatt (UTC)\",\n    \"account_basics_tier_title\": \"Kontotype\",\n    \"account_basics_tier_description\": \"Din kontos styrke\",\n    \"account_basics_tier_admin\": \"Administrator\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(med {{tier}} nivå)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(ingen nivå)\",\n    \"account_basics_tier_paid_until\": \"Abonnement betalt til {{date}}, og vil bli fornyet automatisk\",\n    \"account_basics_tier_payment_overdue\": \"Betalingen din har forfalt. Vennligst oppdater betalingsmetoden din, hvis ikke blir kontoen din snart degradert.\",\n    \"account_basics_tier_manage_billing_button\": \"Behandle betalinger\",\n    \"account_usage_messages_title\": \"Publiserte meldinger\",\n    \"account_usage_calls_none\": \"Ingen telefonsamtaler kan foretas med denne kontoen\",\n    \"account_usage_reservations_none\": \"Ingen reserverte emner for denne kontoen\",\n    \"account_usage_attachment_storage_title\": \"Vedleggslagring\",\n    \"account_usage_basis_ip_description\": \"Forbruksstatistikk og -grenser for denne kontoen er basert på IP-adressen din, så det kan være de er delt med andre brukere. Forbruksgrenser vist over er omtrentlige, basert på eksisterende begrensninger.\",\n    \"account_delete_title\": \"Slett konto\",\n    \"account_delete_dialog_billing_warning\": \"Sletting av kontoen din avslutter også abonnementet og betalingene dine umiddelbart. Du vil ikke ha tilgang til betalingsportalen lenger.\",\n    \"account_upgrade_dialog_title\": \"Endre kontonivå\",\n    \"account_upgrade_dialog_interval_yearly\": \"Årlig\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"spar {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"spar inntil {{discount}}%\",\n    \"account_upgrade_dialog_cancel_warning\": \"Dette vil <strong>avslutte abonnementet ditt</strong>, og nedgradere kontoen din den {{date}}. På den datoen vil alle emnereservasjoner såvel som meldinger lagret på serveren <strong>bli slettet</strong>.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Det valgte nivået tillater færre reserverte emner enn ditt nåvære nivå. Før du endrer nivå, <strong>vennligst slett minst én reservasjon</strong>. Du kan slette reservasjoner i <Link>Innstillingene</Link>.\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Ingen reserverte emner\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} daglig e-post\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} daglige telefonsamtaler\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Ingen telefonsamtaler\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} pr. fil\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} total lagringsplass\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"måned\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} fakturert årlig. Spar {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"For faktureringsspørsmål, vennligst se vår <Link>nettside</Link>.\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Oppdater abonnement\",\n    \"account_tokens_title\": \"Tilgangstokener\",\n    \"account_tokens_table_last_access_header\": \"Sist aksessert\",\n    \"account_tokens_table_expires_header\": \"Utløper\",\n    \"account_tokens_table_never_expires\": \"Utløper aldri\",\n    \"account_tokens_dialog_title_edit\": \"Rediger tilgangstoken\",\n    \"account_tokens_dialog_button_create\": \"Opprett token\",\n    \"account_tokens_dialog_button_update\": \"Oppdater token\",\n    \"account_tokens_dialog_button_cancel\": \"Avbryt\",\n    \"account_tokens_dialog_expires_label\": \"Tilgangstoken utløper om\",\n    \"account_tokens_dialog_expires_x_days\": \"Token utløper om {{days}} dager\",\n    \"account_tokens_dialog_expires_never\": \"Token utløper aldri\",\n    \"prefs_notifications_web_push_title\": \"Bakgrunnsvarslinger\",\n    \"prefs_notifications_web_push_enabled_description\": \"Varslinger mottas send om web-appen ikke kjører (via Web Push)\",\n    \"prefs_notifications_web_push_enabled\": \"Aktivert for {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Deaktivert\",\n    \"prefs_reservations_description\": \"Du kan reservere emnenavn for personlig bruk her. Reservasjon av et emne gir deg eierskap over emnet og lar deg definere tilgangsrettigheter for andre brukere av dette emnet.\",\n    \"prefs_reservations_limit_reached\": \"Du har nådd grensen for antall reserverte emner du kan ha.\",\n    \"prefs_reservations_add_button\": \"Legg til reservert emne\",\n    \"prefs_reservations_table_access_header\": \"Tilgang\",\n    \"prefs_reservations_table_everyone_read_only\": \"Jeg kan publisere og abonnere, alle andre kan abonnere\",\n    \"prefs_reservations_dialog_description\": \"Reservering av et emne gir deg eierskap over emnet, og lar deg definere tilgangsrettigheter for andre brukere av emnet.\",\n    \"reservation_delete_dialog_description\": \"Ved å fjerne en reservasjon gir du fra deg eierskapet over emnet, og gir dermed andre muligheten til å reservere det. Du kan beholde eller slette eksisterende meldinger og vedlegg.\",\n    \"reservation_delete_dialog_action_keep_description\": \"Meldinger og vedlegg som er mellomlagret på serveren vil bli synlige for alle som kjenner til emnenavnet.\",\n    \"reservation_delete_dialog_action_delete_description\": \"Mellomlagrede meldinger og vedlegg vil bli permanent slettet. Denne handlingen kan ikke angres.\",\n    \"reservation_delete_dialog_submit_button\": \"Slett reservasjon\",\n    \"error_boundary_button_reload_ntfy\": \"Last inn ntfy på nytt\",\n    \"web_push_subscription_expiring_title\": \"Varslinger vil bli satt på pause\",\n    \"web_push_subscription_expiring_body\": \"Åpne ntfy for å fortsette å motta varslinger\",\n    \"web_push_unknown_notification_title\": \"Ukjent varsel mottatt fra server\",\n    \"web_push_unknown_notification_body\": \"Du må muligens oppdatere ntfy ved å åpne web-appen\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} pr. fil, slettet etter {{expiry}}\"\n}\n"
  },
  {
    "path": "web/public/static/langs/nl.json",
    "content": "{\n    \"action_bar_settings\": \"Instellingen\",\n    \"action_bar_send_test_notification\": \"Verstuur testnotificatie\",\n    \"action_bar_clear_notifications\": \"Wis alle notificaties\",\n    \"message_bar_type_message\": \"Typ hier een bericht\",\n    \"action_bar_unsubscribe\": \"Afmelden\",\n    \"message_bar_error_publishing\": \"Fout bij publiceren notificatie\",\n    \"nav_topics_title\": \"Geabonneerde topics\",\n    \"nav_button_settings\": \"Instellingen\",\n    \"alert_not_supported_description\": \"Notificaties worden niet ondersteund door je browser\",\n    \"notifications_none_for_any_title\": \"Je hebt nog geen notificaties ontvangen.\",\n    \"publish_dialog_tags_label\": \"Tags\",\n    \"publish_dialog_chip_attach_file_label\": \"Lokaal bestand bijvoegen\",\n    \"prefs_users_dialog_title_edit\": \"Gebruiker bewerken\",\n    \"error_boundary_title\": \"Oh nee, ntfy is vastgelopen\",\n    \"error_boundary_description\": \"Dit hoort natuurlijk niet te gebeuren. Onze excuses.<br/>Wanneer het mogelijk is, <githubLink>meld deze fout op GitHub</githubLink>, of laat het ons weten via <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_button_copy_stack_trace\": \"Stack trace kopiëren\",\n    \"error_boundary_stack_trace\": \"Stacktrace\",\n    \"error_boundary_gathering_info\": \"Meer informatie verzamelen …\",\n    \"prefs_users_delete_button\": \"Gebruiker verwijderen\",\n    \"prefs_notifications_delete_after_one_week\": \"Na één week\",\n    \"prefs_notifications_delete_after_one_month\": \"Na één maand\",\n    \"prefs_users_dialog_title_add\": \"Gebruiker toevoegen\",\n    \"prefs_users_dialog_password_label\": \"Wachtwoord\",\n    \"error_boundary_unsupported_indexeddb_description\": \"De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.<br/><br/>Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen <githubLink>in deze GitHub issue</githubLink>, of praat met ons op <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_show_menu\": \"Toon menu\",\n    \"action_bar_logo_alt\": \"ntfy logo\",\n    \"action_bar_toggle_mute\": \"Notificaties dempen/opheffen\",\n    \"action_bar_toggle_action_menu\": \"Open/Sluit actiemenu\",\n    \"message_bar_show_dialog\": \"Toon publicatie venster\",\n    \"message_bar_publish\": \"Bericht publiceren\",\n    \"nav_button_all_notifications\": \"Alle notificaties\",\n    \"nav_button_documentation\": \"Documentatie\",\n    \"nav_button_publish_message\": \"Notificatie publiceren\",\n    \"nav_button_subscribe\": \"Abonneer op onderwerp\",\n    \"nav_button_muted\": \"Notificaties gedempt\",\n    \"nav_button_connecting\": \"verbinden\",\n    \"alert_notification_permission_required_title\": \"Notificaties zijn uitgeschakeld\",\n    \"alert_notification_permission_required_description\": \"Verleen je browser toestemming voor het weergeven van notificaties op desktop\",\n    \"alert_notification_permission_required_button\": \"Nu toestaan\",\n    \"alert_not_supported_title\": \"Notificaties zijn niet ondersteund\",\n    \"notifications_list\": \"Notificatielijst\",\n    \"notifications_list_item\": \"Notificatie\",\n    \"notifications_mark_read\": \"Markeer als gelezen\",\n    \"notifications_delete\": \"Verwijder\",\n    \"notifications_copied_to_clipboard\": \"Gekopieerd naar klembord\",\n    \"notifications_tags\": \"Labels\",\n    \"notifications_priority_x\": \"Prioriteit {{priority}}\",\n    \"notifications_new_indicator\": \"Nieuwe notificatie\",\n    \"notifications_attachment_image\": \"Afbeelding bijlage\",\n    \"notifications_attachment_copy_url_title\": \"Kopieer URL van bijlage naar klembord\",\n    \"notifications_attachment_copy_url_button\": \"URL kopiëren\",\n    \"notifications_attachment_open_title\": \"Ga naar {{url}}\",\n    \"notifications_attachment_open_button\": \"Bijlage openen\",\n    \"notifications_attachment_link_expires\": \"link vervalt op {{date}}\",\n    \"notifications_attachment_link_expired\": \"download link is verlopen\",\n    \"notifications_attachment_file_image\": \"afbeeldingsbestand\",\n    \"notifications_attachment_file_video\": \"videobestand\",\n    \"notifications_attachment_file_audio\": \"audiobestand\",\n    \"notifications_attachment_file_app\": \"Android app bestand\",\n    \"notifications_attachment_file_document\": \"overig document\",\n    \"notifications_click_copy_url_title\": \"link URL naar klembord kopiëren\",\n    \"notifications_click_copy_url_button\": \"Link kopiëren\",\n    \"notifications_click_open_button\": \"Link openen\",\n    \"notifications_none_for_topic_description\": \"Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.\",\n    \"notifications_none_for_any_description\": \"Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp. Hier is een voorbeeld met één van je onderwerpen.\",\n    \"notifications_no_subscriptions_title\": \"Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.\",\n    \"notifications_no_subscriptions_description\": \"Klik op de \\\"{{linktext}}\\\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.\",\n    \"notifications_example\": \"Voorbeeld\",\n    \"notifications_more_details\": \"Voor meer informatie, bezoek de <websiteLink>website</websiteLink> of <docsLink>documentatie</docsLink>.\",\n    \"notifications_loading\": \"Notificaties laden …\",\n    \"publish_dialog_title_topic\": \"Publiceren naar {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Notificatie publiceren\",\n    \"publish_dialog_progress_uploading\": \"Uploaden …\",\n    \"notifications_actions_open_url_title\": \"Ga naar {{url}}\",\n    \"notifications_actions_not_supported\": \"Actie wordt niet ondersteund in de webapplicatie\",\n    \"notifications_actions_http_request_title\": \"Stuur HTTP {{method}} naar {{url}}\",\n    \"notifications_none_for_topic_title\": \"Je hebt nog geen notificaties ontvangen voor dit onderwerp.\",\n    \"publish_dialog_priority_low\": \"Lage prioriteit\",\n    \"publish_dialog_progress_uploading_detail\": \"Uploaden {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Notificatie gepubliceerd\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend\",\n    \"publish_dialog_attachment_limits_file_reached\": \"overschrijd {{fileSizeLimit}} bestandslimiet\",\n    \"publish_dialog_priority_default\": \"Standaard prioriteit\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"overschrijd quotum, {{remainingBytes}} resterend\",\n    \"publish_dialog_emoji_picker_show\": \"Kies een emoji\",\n    \"publish_dialog_priority_high\": \"Hoge prioriteit\",\n    \"publish_dialog_priority_max\": \"Maximale prioriteit\",\n    \"publish_dialog_priority_min\": \"Minimale prioriteit\",\n    \"publish_dialog_base_url_label\": \"Service URL\",\n    \"publish_dialog_base_url_placeholder\": \"Service URL, bijvoorbeeld: https://voorbeeld.com\",\n    \"publish_dialog_topic_label\": \"Onderwerp\",\n    \"publish_dialog_topic_placeholder\": \"Onderwerp, bijv. phil_alerts\",\n    \"publish_dialog_topic_reset\": \"Onderwerp resetten\",\n    \"publish_dialog_title_label\": \"Titel\",\n    \"publish_dialog_title_placeholder\": \"Notificatie titel , bijv. Schijfruimte alarm\",\n    \"publish_dialog_message_label\": \"Bericht\",\n    \"publish_dialog_message_placeholder\": \"Typ hier een bericht\",\n    \"publish_dialog_tags_placeholder\": \"Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioriteit\",\n    \"publish_dialog_click_label\": \"Klik URL\",\n    \"publish_dialog_click_reset\": \"Verwijder klik URL\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com\",\n    \"publish_dialog_email_reset\": \"Email doorsturen verwijderen\",\n    \"publish_dialog_attach_label\": \"URL van bijlage\",\n    \"publish_dialog_click_placeholder\": \"URL die geopend zal worden wanneer op de notificatie geklikt wordt\",\n    \"publish_dialog_attach_placeholder\": \"Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Bijlage URL verwijderen\",\n    \"publish_dialog_filename_label\": \"Bestandsnaam\",\n    \"publish_dialog_filename_placeholder\": \"Bestandsnaam van bijlage\",\n    \"publish_dialog_delay_label\": \"Uitstellen\",\n    \"publish_dialog_delay_placeholder\": \"Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \\\"{{naturalLanguage}}\\\" (alleen Engels)\",\n    \"publish_dialog_delay_reset\": \"Verwijder uitgestelde bezorging\",\n    \"publish_dialog_other_features\": \"Andere functionaliteiten:\",\n    \"publish_dialog_chip_click_label\": \"Klik URL\",\n    \"publish_dialog_chip_email_label\": \"Doorsturen naar email\",\n    \"publish_dialog_chip_attach_url_label\": \"Bestand bijvoegen via URL\",\n    \"publish_dialog_chip_delay_label\": \"Uitgestelde bezorging\",\n    \"publish_dialog_chip_topic_label\": \"Onderwerp veranderen\",\n    \"publish_dialog_details_examples_description\": \"Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de <docsLink>documentatie</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Versturen annuleren\",\n    \"publish_dialog_button_cancel\": \"Annuleer\",\n    \"publish_dialog_button_send\": \"Verstuur\",\n    \"publish_dialog_checkbox_publish_another\": \"Nog een bericht versturen\",\n    \"publish_dialog_attached_file_title\": \"Bijgevoegd bestand:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Bijlage bestandsnaam\",\n    \"publish_dialog_attached_file_remove\": \"Verwijder bijgevoegd bestand\",\n    \"publish_dialog_drop_file_here\": \"Bestand hier slepen\",\n    \"emoji_picker_search_placeholder\": \"Emoji zoeken\",\n    \"emoji_picker_search_clear\": \"Zoeken leegmaken\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Onderwerp naam, bijv. phils_waarschuwingen\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Gebruik een andere server\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Service URL\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Annuleren\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Abonneren\",\n    \"subscribe_dialog_login_title\": \"Aanmelding vereist\",\n    \"subscribe_dialog_login_description\": \"Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.\",\n    \"subscribe_dialog_login_username_label\": \"Gebruikersnaam, bijv. phil\",\n    \"subscribe_dialog_subscribe_title\": \"Onderwerp abonneren\",\n    \"subscribe_dialog_subscribe_description\": \"Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.\",\n    \"subscribe_dialog_login_password_label\": \"Wachtwoord\",\n    \"common_back\": \"Terug\",\n    \"subscribe_dialog_login_button_login\": \"Aanmelden\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Gebruiker {{username}} heeft geen toegang\",\n    \"subscribe_dialog_error_user_anonymous\": \"anoniem\",\n    \"prefs_notifications_title\": \"Notificaties\",\n    \"prefs_notifications_sound_title\": \"Meldingsgeluid\",\n    \"prefs_notifications_sound_description_none\": \"Notificaties zullen geen geluid geven\",\n    \"prefs_notifications_sound_play\": \"Geselecteerd geluid afspelen\",\n    \"prefs_notifications_sound_description_some\": \"Inkomende notificaties zullen het {{sound}} geluid afspelen\",\n    \"prefs_notifications_sound_no_sound\": \"Geen geluid\",\n    \"prefs_notifications_min_priority_title\": \"Minimale prioriteit\",\n    \"prefs_notifications_min_priority_description_any\": \"Toon alle notificaties, ongeacht prioriteit\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Toon notificaties als prioriteit {{number}} ({{name}}) is of hoger\",\n    \"prefs_notifications_min_priority_description_max\": \"Toon notificaties als prioriteit 5 (maximaal) is\",\n    \"prefs_notifications_min_priority_any\": \"Elke prioriteit\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Lage prioriteit en hoger\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Standaard prioriteit en hoger\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Hoge prioriteit en hoger\",\n    \"prefs_notifications_min_priority_max_only\": \"Alleen maximale prioriteit\",\n    \"prefs_notifications_delete_after_title\": \"Notificaties verwijderen\",\n    \"prefs_notifications_delete_after_never\": \"Nooit\",\n    \"prefs_notifications_delete_after_three_hours\": \"Na drie uur\",\n    \"prefs_notifications_delete_after_one_day\": \"Na één dag\",\n    \"prefs_notifications_delete_after_never_description\": \"Notificaties worden nooit automatisch verwijderd\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Notificaties worden na drie uur automatisch verwijderd\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Notificaties worden na één dag automatisch verwijderd\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Notificaties worden na één week automatisch verwijderd\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Notificaties worden na één maand automatisch verwijderd\",\n    \"prefs_users_title\": \"Gebruikers beheren\",\n    \"prefs_users_description\": \"Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.\",\n    \"prefs_users_table\": \"Gebruikerstabel\",\n    \"prefs_users_add_button\": \"Gebruiker toevoegen\",\n    \"prefs_users_edit_button\": \"Gebruiker bewerken\",\n    \"prefs_users_table_user_header\": \"Gebruiker\",\n    \"prefs_users_table_base_url_header\": \"Service URL\",\n    \"prefs_users_dialog_base_url_label\": \"Service URL, bijv. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Gebruikersnaam, bijv. phil\",\n    \"common_cancel\": \"Annuleren\",\n    \"common_add\": \"Toevoegen\",\n    \"common_save\": \"Bewaren\",\n    \"prefs_appearance_title\": \"Weergave\",\n    \"prefs_appearance_language_title\": \"Taal\",\n    \"priority_min\": \"min\",\n    \"priority_low\": \"laag\",\n    \"priority_default\": \"standaard\",\n    \"priority_high\": \"hoog\",\n    \"priority_max\": \"max\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Privé / incognito browservensters worden niet ondersteund\",\n    \"signup_form_username\": \"Gebruikersnaam\",\n    \"signup_form_toggle_password_visibility\": \"Wachtwoord zichtbaar maken\",\n    \"signup_already_have_account\": \"Heb je al een account? Log in!\",\n    \"signup_form_button_submit\": \"Registreer\",\n    \"signup_disabled\": \"Registreren is uitgeschakeld\",\n    \"signup_error_username_taken\": \"Gebruikersnaam {{username}} is al bezet\",\n    \"signup_error_creation_limit_reached\": \"Limiet voor aanmaken account bereikt\",\n    \"login_title\": \"Inloggen met uw ntfy account\",\n    \"login_form_button_submit\": \"Inloggen\",\n    \"login_link_signup\": \"Registreren\",\n    \"login_disabled\": \"Inloggen is uitgeschakeld\",\n    \"action_bar_account\": \"Account\",\n    \"action_bar_reservation_add\": \"Topic reserveren\",\n    \"action_bar_reservation_edit\": \"Reservering wijzigen\",\n    \"action_bar_reservation_delete\": \"Verwijder reservering\",\n    \"action_bar_reservation_limit_reached\": \"Limiet bereikt\",\n    \"action_bar_profile_title\": \"Profiel\",\n    \"nav_upgrade_banner_label\": \"Upgrade naar ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Onderwerpen reserveren, meer berichten & e-mails, en grotere bijlagen\",\n    \"alert_not_supported_context_description\": \"Notificaties worden alleen ondersteund via HTTPS. Dit is een beperking van de <mdnLink>Notificaties API</mdnLink>.\",\n    \"display_name_dialog_placeholder\": \"Weergavenaam\",\n    \"reserve_dialog_checkbox_label\": \"Onderwerp reserveren en toegang configureren\",\n    \"account_basics_title\": \"Account\",\n    \"account_basics_username_title\": \"Gebruikersnaam\",\n    \"account_basics_username_description\": \"Hé, dat ben jij ❤\",\n    \"account_basics_username_admin_tooltip\": \"Je bent beheerder\",\n    \"account_basics_password_title\": \"Wachtwoord\",\n    \"account_basics_password_description\": \"Wijzig het wachtwoord van je account\",\n    \"account_basics_password_dialog_current_password_label\": \"Huidig wachtwoord\",\n    \"account_basics_password_dialog_new_password_label\": \"Nieuw wachtwoord\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Bevestig wachtwoord\",\n    \"account_basics_password_dialog_button_submit\": \"Wijzig wachtwoord\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Wachtwoord onjuist\",\n    \"account_usage_title\": \"Gebruik\",\n    \"account_usage_of_limit\": \"van {{limit}}\",\n    \"account_usage_unlimited\": \"Onbeperkt\",\n    \"account_basics_tier_title\": \"Account type\",\n    \"account_basics_tier_admin\": \"Beheerder\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(met {{tier}} niveau)\",\n    \"account_basics_tier_basic\": \"Basis\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_basics_tier_change_button\": \"Wijzig\",\n    \"account_basics_tier_paid_until\": \"Abonnement betaald tot {{date}}, en wordt automatisch verlengd\",\n    \"account_basics_tier_payment_overdue\": \"Je betaling is te laat. Update je betalingsmethode, anders wordt je account binnenkort gedowngraded.\",\n    \"account_basics_tier_canceled_subscription\": \"Je abonnement is opgezegd en wordt op {{date}} gedowngraded naar een gratis account.\",\n    \"signup_form_password\": \"Wachtwoord\",\n    \"signup_title\": \"Een ntfy account aanmaken\",\n    \"signup_form_confirm_password\": \"Bevestig wachtwoord\",\n    \"action_bar_change_display_name\": \"Weergavenaam wijzigen\",\n    \"action_bar_profile_logout\": \"Uitloggen\",\n    \"action_bar_profile_settings\": \"Instellingen\",\n    \"action_bar_sign_up\": \"Registreer\",\n    \"nav_button_account\": \"Account\",\n    \"action_bar_sign_in\": \"Inloggen\",\n    \"display_name_dialog_title\": \"Weergavenaam wijzigen\",\n    \"display_name_dialog_description\": \"Stel een alternatieve naam in voor een onderwerp dat wordt weergeven in de abonnementenlijst. Dit helpt onderwerpen met gecompliceerde namen gemakkelijker te identificeren.\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Naam genereren\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Onderwerp al gereserveerd\",\n    \"account_basics_password_dialog_title\": \"Wijzig wachtwoord\",\n    \"account_usage_limits_reset_daily\": \"Gebruikslimieten worden dagelijks om middernacht (UTC) gereset\",\n    \"account_basics_tier_upgrade_button\": \"Upgrade naar Pro\",\n    \"account_upgrade_dialog_title\": \"Accountniveau wijzigen\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"bespaar {{discount}}%\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} jaarlijks gefactureerd. Bespaar {{save}}.\",\n    \"account_upgrade_dialog_cancel_warning\": \"Hiermee wordt <strong>uw abonnement opgezegd</strong> en wordt uw account gedowngraded op {{date}}. Op die datum worden onderwerpreserveringen en berichten in de cache op de server <strong> verwijderd </strong>.\",\n    \"account_tokens_dialog_button_update\": \"Token bijwerken\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Pro rata</strong>: Bij een upgrade tussen betaalde abonnementen wordt het prijsverschil <strong>onmiddellijk in rekening gebracht</strong>. Wanneer u downgradet naar een lager niveau, wordt het saldo gebruikt om toekomstige factureringsperioden te betalen.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, <strong>, moet u ten minste één reservering verwijderen </strong>. U kunt reserveringen verwijderen in de <Link>Instellingen</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, <strong>moet u ten minste {{count}} reserveringen verwijderen</strong>. U kunt reserveringen verwijderen in de <Link>Instellingen</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} gereserveerde onderwerpen\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Neem voor vragen over facturering rechtstreeks <Link>contact met ons op</Link>.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_never_expires\": \"Verloopt nooit\",\n    \"account_tokens_table_current_session\": \"Huidige browsersessie\",\n    \"prefs_reservations_table_everyone_read_only\": \"Ik kan publiceren en abonneren, iedereen kan zich abonneren\",\n    \"prefs_reservations_table_everyone_write_only\": \"Ik kan publiceren en abonneren, iedereen kan publiceren\",\n    \"account_usage_reservations_none\": \"Geen gereserveerde onderwerpen voor dit account\",\n    \"account_usage_attachment_storage_title\": \"Bijlage-opslag\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} per bestand, verwijderd na {{expiry}}\",\n    \"account_delete_dialog_description\": \"Hiermee wordt uw account definitief verwijderd, inclusief alle gegevens die op de server zijn opgeslagen. Na verwijdering is uw gebruikersnaam 7 dagen niet beschikbaar. Als u echt wilt doorgaan, bevestig dan met uw wachtwoord in het onderstaande vak.\",\n    \"account_delete_dialog_billing_warning\": \"Als u uw account verwijdert, wordt ook uw facturering onmiddellijk geannuleerd. U heeft dan geen toegang meer tot het factureringsdashboard.\",\n    \"account_tokens_dialog_button_cancel\": \"Annuleren\",\n    \"reservation_delete_dialog_submit_button\": \"Reservering verwijderen\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Alleen ik kan publiceren en abonneren\",\n    \"reservation_delete_dialog_description\": \"Het verwijderen van een reservering geeft het eigendom van het onderwerp op en stelt anderen in staat het te reserveren. U kunt bestaande berichten en bijlagen behouden of verwijderen.\",\n    \"account_basics_tier_interval_monthly\": \"maandelijks\",\n    \"account_basics_tier_interval_yearly\": \"jaarlijks\",\n    \"account_usage_basis_ip_description\": \"Gebruiksstatistieken en -limieten voor dit account zijn gebaseerd op uw IP-adres en kunnen dus worden gedeeld met andere gebruikers. De hierboven weergegeven limieten zijn bij benadering gebaseerd op de bestaande limieten.\",\n    \"account_usage_cannot_create_portal_session\": \"Kan factureringsportaal niet openen\",\n    \"account_delete_title\": \"Account verwijderen\",\n    \"account_delete_description\": \"Verwijder uw account definitief\",\n    \"account_delete_dialog_label\": \"Wachtwoord\",\n    \"account_delete_dialog_button_cancel\": \"Annuleren\",\n    \"account_delete_dialog_button_submit\": \"Verwijder uw account definitief\",\n    \"account_upgrade_dialog_interval_monthly\": \"Maandelijks\",\n    \"account_upgrade_dialog_interval_yearly\": \"Jaarlijks\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"bespaar tot {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Geen gereserveerde onderwerpen\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} totale opslag\",\n    \"account_upgrade_dialog_tier_current_label\": \"Huidig\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Abonnement bijwerken\",\n    \"account_tokens_title\": \"Toegangstokens\",\n    \"account_tokens_description\": \"Gebruik toegangstokens bij het publiceren en abonneren via de ntfy API, zodat u uw accountgegevens niet hoeft op te sturen. Bekijk de <Link>documentatie</Link> voor meer informatie.\",\n    \"account_tokens_table_label_header\": \"Label\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Kan huidige sessietoken niet bewerken of verwijderen\",\n    \"account_tokens_dialog_expires_label\": \"Toegangstoken verloopt over\",\n    \"account_tokens_dialog_expires_unchanged\": \"Vervaldatum ongewijzigd laten\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token verloopt over {{hours}} uur\",\n    \"account_tokens_dialog_expires_x_days\": \"Token verloopt over {{days}} dagen\",\n    \"account_tokens_dialog_expires_never\": \"Token verloopt nooit\",\n    \"account_tokens_delete_dialog_title\": \"Toegangstoken verwijderen\",\n    \"account_tokens_delete_dialog_description\": \"Voordat u een toegangstoken verwijdert, moet u ervoor zorgen dat er geen toepassingen of scripts actief gebruik van maken. <strong>Deze actie kan niet ongedaan worden gemaakt</strong>.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Kan ingelogde gebruiker niet verwijderen of bewerken\",\n    \"prefs_reservations_title\": \"Gereserveerde onderwerpen\",\n    \"prefs_reservations_description\": \"U kunt hier onderwerpnamen reserveren voor persoonlijk gebruik. Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.\",\n    \"prefs_reservations_limit_reached\": \"Je hebt je limiet voor gereserveerde onderwerpen bereikt.\",\n    \"prefs_reservations_add_button\": \"Gereserveerd onderwerp toevoegen\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klik om je te abonneren\",\n    \"prefs_reservations_dialog_title_add\": \"Onderwerp reserveren\",\n    \"prefs_reservations_dialog_title_edit\": \"Gereserveerd onderwerp bewerken\",\n    \"prefs_reservations_dialog_title_delete\": \"Onderwerpreservering verwijderen\",\n    \"prefs_reservations_dialog_description\": \"Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.\",\n    \"prefs_reservations_dialog_topic_label\": \"Onderwerp\",\n    \"prefs_reservations_dialog_access_label\": \"Toegang\",\n    \"reservation_delete_dialog_action_keep_title\": \"Bewaar in de cache opgeslagen berichten en bijlagen\",\n    \"reservation_delete_dialog_action_keep_description\": \"Berichten en bijlagen die in de cache op de server zijn opgeslagen, worden publiekelijk zichtbaar voor mensen die de onderwerpnaam kennen.\",\n    \"reservation_delete_dialog_action_delete_description\": \"Berichten en bijlagen in de cache worden permanent verwijderd. Deze actie kan niet ongedaan gemaakt worden.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} gereserveerd onderwerp\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} dagelijks bericht\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} dagelijkse berichten\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} dagelijkse e-mail\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} dagelijkse e-mails\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per bestand\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"maand\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} per jaar. Maandelijks gefactureerd.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Geselecteerd\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Raadpleeg voor vragen over facturering onze <Link>website</Link>.\",\n    \"account_upgrade_dialog_button_cancel\": \"Annuleren\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Nu aanmelden\",\n    \"account_upgrade_dialog_button_pay_now\": \"Nu betalen en inschrijven\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Abonnement opzeggen\",\n    \"account_tokens_table_last_access_header\": \"Laatste toegang\",\n    \"account_tokens_table_expires_header\": \"Verloopt op\",\n    \"common_copy_to_clipboard\": \"Kopieer naar klembord\",\n    \"account_tokens_table_copied_to_clipboard\": \"Toegangstoken gekopieerd\",\n    \"account_tokens_delete_dialog_submit_button\": \"Token definitief verwijderen\",\n    \"prefs_users_description_no_sync\": \"Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Verwijder in de cache opgeslagen berichten en bijlagen\",\n    \"account_basics_tier_description\": \"Het niveau van uw account\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(geen niveau)\",\n    \"account_basics_tier_manage_billing_button\": \"Facturering beheren\",\n    \"account_usage_messages_title\": \"Gepubliceerde berichten\",\n    \"account_usage_emails_title\": \"E-mails verzonden\",\n    \"account_usage_reservations_title\": \"Gereserveerde onderwerpen\",\n    \"account_tokens_table_create_token_button\": \"Toegangstoken maken\",\n    \"account_tokens_table_last_origin_tooltip\": \"Vanaf IP-adres {{ip}}, klik om op te zoeken\",\n    \"account_tokens_dialog_title_create\": \"Toegangstoken maken\",\n    \"account_tokens_dialog_title_edit\": \"Toegangstoken bewerken\",\n    \"account_tokens_dialog_title_delete\": \"Toegangstoken verwijderen\",\n    \"account_tokens_dialog_label\": \"Label, bijv. Radarr-meldingen\",\n    \"account_tokens_dialog_button_create\": \"Token maken\",\n    \"prefs_reservations_edit_button\": \"Onderwerptoegang bewerken\",\n    \"prefs_reservations_delete_button\": \"Toegang tot onderwerp resetten\",\n    \"prefs_reservations_table\": \"Tabel met gereserveerde onderwerpen\",\n    \"prefs_reservations_table_topic_header\": \"Onderwerp\",\n    \"prefs_reservations_table_access_header\": \"Toegang\",\n    \"prefs_reservations_table_everyone_read_write\": \"Iedereen kan publiceren en abonneren\",\n    \"prefs_reservations_table_not_subscribed\": \"Niet geabonneerd\",\n    \"publish_dialog_call_label\": \"Telefoongesprek\",\n    \"publish_dialog_call_reset\": \"Telefoongesprek verwijderen\",\n    \"publish_dialog_chip_call_label\": \"Telefoongesprek\",\n    \"account_basics_phone_numbers_title\": \"Telefoonnummers\",\n    \"account_basics_phone_numbers_description\": \"Voor meldingen via telefoongesprekken\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Nog geen telefoonnummers\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Bel me\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} dagelijkse telefoontjes\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefoonnummer gekopieerd naar klembord\",\n    \"publish_dialog_call_item\": \"Bel telefoonnummer {{nummer}}\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Bevestig code\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Geen geverifieerde telefoonnummers\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Telefoongesprek\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefoonnummer\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"bijv. 123456\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} dagelijkse telefoontjes\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Geen telefoontjes\",\n    \"account_basics_phone_numbers_dialog_description\": \"Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.\",\n    \"account_basics_phone_numbers_dialog_title\": \"Telefoonnummer toevoegen\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"bijv. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Stuur SMS\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Verificatiecode\",\n    \"account_usage_calls_title\": \"Aantal telefoontjes\",\n    \"account_usage_calls_none\": \"Met dit account kan niet worden gebeld\",\n    \"action_bar_mute_notifications\": \"Notificaties dempen\",\n    \"prefs_notifications_web_push_disabled_description\": \"Notificaties worden ontvangen als de webapplicatie geopend is (via WebSocket)\",\n    \"web_push_unknown_notification_body\": \"Het is mogelijk dat je ntfy moet updaten door de webapplicatie opnieuw te openen\",\n    \"action_bar_unmute_notifications\": \"Dempen notificaties opheffen\",\n    \"alert_notification_permission_denied_title\": \"Notificaties zijn geblokkeerd\",\n    \"alert_notification_permission_denied_description\": \"Activeer ze in de browser\",\n    \"alert_notification_ios_install_required_title\": \"iOS installatie vereist\",\n    \"alert_notification_ios_install_required_description\": \"Klik op het Deel icoon, daarna op \\\"Add to Home Screen\\\" om notificaties op iOS toe te staan\",\n    \"notifications_actions_failed_notification\": \"Actie onsuccesvol\",\n    \"publish_dialog_checkbox_markdown\": \"Opmaken met Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Notificaties van andere servers worden niet ontvangen als de webapplicatie niet geopend is\",\n    \"prefs_notifications_web_push_title\": \"Achtergrond notificaties\",\n    \"prefs_notifications_web_push_enabled\": \"Aan voor {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Uitgezet\",\n    \"prefs_notifications_web_push_enabled_description\": \"Notificaties worden ontvangen, ook als de webapplicatie niet geopend is (via Web Push)\",\n    \"prefs_appearance_theme_title\": \"Thema\",\n    \"prefs_appearance_theme_system\": \"Systeem (standaard)\",\n    \"prefs_appearance_theme_dark\": \"Donkere modus\",\n    \"prefs_appearance_theme_light\": \"Lichte modus\",\n    \"error_boundary_button_reload_ntfy\": \"Herlaad ntfy\",\n    \"web_push_subscription_expiring_title\": \"Notificaties worden gepauzeerd\",\n    \"web_push_subscription_expiring_body\": \"Open ntfy om weer notificaties te ontvangen\",\n    \"web_push_unknown_notification_title\": \"Onbekende notificatie ontvangen van de server\"\n}\n"
  },
  {
    "path": "web/public/static/langs/pl.json",
    "content": "{\n    \"action_bar_send_test_notification\": \"Wyślij powiadomienie testowe\",\n    \"action_bar_clear_notifications\": \"Wyczyść powiadomienia\",\n    \"action_bar_toggle_mute\": \"Włączanie/wyłączanie wyciszania powiadomień\",\n    \"action_bar_toggle_action_menu\": \"Otwórz/zamknij menu działań\",\n    \"message_bar_type_message\": \"Wpisz wiadomość tutaj\",\n    \"message_bar_error_publishing\": \"Błąd przy wysyłaniu powiadomienia\",\n    \"message_bar_show_dialog\": \"Pokaż okno dialogowe publikacji\",\n    \"nav_button_all_notifications\": \"Wszystkie powiadomienia\",\n    \"nav_button_documentation\": \"Dokumentacja\",\n    \"nav_button_muted\": \"Powiadomienia wyciszone\",\n    \"alert_notification_permission_required_title\": \"Powiadomienia są wyłączone\",\n    \"alert_notification_permission_required_description\": \"Udziel przeglądarce pozwolenia na wyświetlanie powiadomień na pulpicie\",\n    \"alert_notification_permission_required_button\": \"Pozwól teraz\",\n    \"alert_not_supported_title\": \"Powiadomienia nie są obsługiwane\",\n    \"alert_not_supported_description\": \"Powiadomienia nie są obsługiwane przez Twoją przeglądarkę\",\n    \"notifications_list\": \"Lista powiadomień\",\n    \"notifications_list_item\": \"Powiadomienie\",\n    \"notifications_mark_read\": \"Oznacz jako przeczytane\",\n    \"notifications_delete\": \"Usuń\",\n    \"notifications_copied_to_clipboard\": \"Skopiowano do schowka\",\n    \"notifications_tags\": \"Tagi\",\n    \"message_bar_publish\": \"Opublikuj powiadomienie\",\n    \"nav_topics_title\": \"Subskrybowane tematy\",\n    \"nav_button_settings\": \"Ustawienia\",\n    \"nav_button_publish_message\": \"Opublikuj powiadomienie\",\n    \"nav_button_subscribe\": \"Zasubskrybuj temat\",\n    \"nav_button_connecting\": \"łączenie\",\n    \"notifications_attachment_image\": \"Obraz załącznika\",\n    \"notifications_attachment_copy_url_button\": \"Kopiuj Adres URL\",\n    \"notifications_attachment_link_expires\": \"Łącze wygasa w dniu {{date}}\",\n    \"notifications_attachment_link_expired\": \"Łącze do pobrania wygasło\",\n    \"notifications_attachment_file_image\": \"plik graficzny\",\n    \"notifications_attachment_file_video\": \"plik wideo\",\n    \"notifications_attachment_file_audio\": \"plik audio\",\n    \"notifications_attachment_file_app\": \"plik aplikacji Android\",\n    \"notifications_attachment_file_document\": \"inny dokument\",\n    \"notifications_click_copy_url_title\": \"Skopiuj adres URL do schowka\",\n    \"notifications_click_open_button\": \"Otwórz łącze\",\n    \"notifications_actions_open_url_title\": \"Przejdź do {{url}}\",\n    \"notifications_actions_not_supported\": \"Ta akcja nie jest obsługiwana w aplikacji internetowej\",\n    \"notifications_actions_http_request_title\": \"Wyślij HTTP {{method}} do {{url}}\",\n    \"notifications_none_for_topic_title\": \"Nie otrzymałeś jeszcze żadnych powiadomień dla tego tematu.\",\n    \"notifications_none_for_any_description\": \"Aby wysłać powiadomienia do tematu, wyślij PUT/POST do adresu URL tematu. Oto przykład z jednym z twoich tematów.\",\n    \"notifications_no_subscriptions_title\": \"Wygląda na to, że nie masz jeszcze żadnych subskrypcji.\",\n    \"notifications_no_subscriptions_description\": \"Kliknij łącze \\\"{{linktext}}\\\", aby stworzyć lub zasubskrybować temat. Następnie możesz wysyłać wiadomości za pomocą PUT lub POST i otrzymywać powiadomienia tutaj.\",\n    \"notifications_example\": \"Przykład\",\n    \"notifications_loading\": \"Ładowanie powiadomień …\",\n    \"publish_dialog_title_topic\": \"Opublikuj do {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Opublikuj powiadomienie\",\n    \"publish_dialog_progress_uploading\": \"Przesyłanie …\",\n    \"publish_dialog_progress_uploading_detail\": \"Przesyłanie {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Powiadomienie wysłane\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"przekracza limit rozmiaru pliku {{fileSizeLimit}}, pozostaje {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_reached\": \"przekracza limit rozmiaru pliku {{filesizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"przekracza limit, {{remainingBytes}} pozostało\",\n    \"publish_dialog_emoji_picker_show\": \"Wybierz emotkę\",\n    \"publish_dialog_priority_min\": \"Min. priorytet\",\n    \"publish_dialog_priority_low\": \"Niski priorytet\",\n    \"publish_dialog_base_url_label\": \"Adres URL usługi\",\n    \"publish_dialog_base_url_placeholder\": \"Adres URL usługi, np. https://example.com\",\n    \"publish_dialog_topic_label\": \"Nazwa tematu\",\n    \"publish_dialog_topic_placeholder\": \"Nazwa tematu, np. moje_alerty\",\n    \"publish_dialog_topic_reset\": \"Resetuj temat\",\n    \"publish_dialog_title_label\": \"Tytuł\",\n    \"publish_dialog_title_placeholder\": \"Tytuł notyfikacji, np. Niski poziom baterrii\",\n    \"publish_dialog_message_label\": \"Wiadomość\",\n    \"publish_dialog_message_placeholder\": \"Wpisz wiadomość tutaj\",\n    \"publish_dialog_tags_label\": \"Tagi\",\n    \"publish_dialog_tags_placeholder\": \"Lista tagów oddzielona przecinkami, np. ostrzeżenie, srv1-backup\",\n    \"publish_dialog_priority_label\": \"Priorytet\",\n    \"publish_dialog_click_label\": \"Kliknij Adres URL\",\n    \"publish_dialog_click_placeholder\": \"Adres URL, który ma być otwarty po kliknięciu na powiadomienie\",\n    \"publish_dialog_click_reset\": \"Usuń adres URL kliknięcia\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Adres, na który ma być wysłane powiadomienie, np. phil@example.com\",\n    \"publish_dialog_email_reset\": \"Usuń przekazywanie wiadomości email\",\n    \"publish_dialog_attach_label\": \"Adres URL załącznika\",\n    \"publish_dialog_attach_placeholder\": \"Dołączenie pliku z adresu URL, np. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Usuń adres URL załącznika\",\n    \"publish_dialog_filename_label\": \"Nazwa pliku\",\n    \"publish_dialog_filename_placeholder\": \"Nazwa pliku załącznika\",\n    \"publish_dialog_delay_label\": \"Opóźnienie\",\n    \"publish_dialog_delay_reset\": \"Usuń opóźnione dostarczenie\",\n    \"publish_dialog_other_features\": \"Inne funkcje:\",\n    \"publish_dialog_chip_click_label\": \"Adres URL kliknięcia\",\n    \"publish_dialog_chip_email_label\": \"Przekaż na email\",\n    \"publish_dialog_chip_attach_url_label\": \"Dołącz plik z adresu URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Dołącz plik lokalny\",\n    \"publish_dialog_chip_delay_label\": \"Opóźnienie dostawy\",\n    \"publish_dialog_chip_topic_label\": \"Zmień temat\",\n    \"publish_dialog_details_examples_description\": \"Przykłady i szczegółowe informacje na temat wszystkich opcji można znaleźć w <docsLink>dokumentacji</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Anuluj wysyłanie\",\n    \"publish_dialog_button_send\": \"Wyślij\",\n    \"publish_dialog_checkbox_publish_another\": \"Wyślij kolejną wiadomość\",\n    \"publish_dialog_attached_file_title\": \"Załączony plik:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nazwa pliku załącznika\",\n    \"publish_dialog_drop_file_here\": \"Upuść plik tutaj\",\n    \"emoji_picker_search_placeholder\": \"Szukaj emotki\",\n    \"emoji_picker_search_clear\": \"Wyczyść wyszukiwanie\",\n    \"subscribe_dialog_subscribe_title\": \"Zasubskrybuj temat\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nazwa tematu, np. moje_alerty\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Użyj innego serwera\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Adres URL usługi\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Anuluj\",\n    \"subscribe_dialog_login_description\": \"Ten temat jest chroniony hasłem. Proszę podać nazwę użytkownika i hasło, aby zasubskrybować.\",\n    \"subscribe_dialog_login_username_label\": \"Nazwa użytkownika, np. phil\",\n    \"subscribe_dialog_login_password_label\": \"Hasło\",\n    \"publish_dialog_button_cancel\": \"Anuluj\",\n    \"common_back\": \"Powrót\",\n    \"subscribe_dialog_login_button_login\": \"Zaloguj się\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Użytkownik {{username}} nie ma uprawnień\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonim\",\n    \"prefs_notifications_title\": \"Powiadomienia\",\n    \"prefs_notifications_sound_title\": \"Dźwięk powiadomienia\",\n    \"prefs_notifications_sound_description_none\": \"Brak dźwięku po otrzymaniu powiadomienia\",\n    \"prefs_notifications_sound_description_some\": \"Odtwarzaj dźwięk {{sound}}, gdy nadejdzie powiadomienie\",\n    \"prefs_notifications_sound_play\": \"Odtwórz wybrany dźwięk\",\n    \"prefs_notifications_min_priority_title\": \"Minimalny priorytet\",\n    \"prefs_notifications_min_priority_description_any\": \"Pokaż wszystkie powiadomienia, niezależnie od priorytetu\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Pokazuj powiadomienia, gdy ich priorytet to {{number}} ({{name}}) lub wyższy\",\n    \"prefs_notifications_min_priority_description_max\": \"Pokaż powiadomienia, jeśli priorytet wynosi 5 (max)\",\n    \"prefs_notifications_min_priority_any\": \"Dowolny priorytet\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Niski priorytet i wyższy\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Priorytet standardowy i wyższy\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Wysoki priorytet i wyższy\",\n    \"prefs_notifications_delete_after_one_day\": \"Po jednym dniu\",\n    \"prefs_notifications_delete_after_one_week\": \"Po tygodniu\",\n    \"prefs_notifications_delete_after_one_month\": \"Po miesiącu\",\n    \"prefs_notifications_delete_after_never_description\": \"Powiadomienia nigdy nie są automatycznie usuwane\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Powiadomienia są automatycznie usuwane po trzech godzinach\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Powiadomienia są automatycznie usuwane po jednym dniu\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Powiadomienia są automatycznie usuwane po upływie jednego miesiąca\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Powiadomienia są automatycznie usuwane po upływie jedego tygodnia\",\n    \"prefs_users_title\": \"Zarządzaj użytkownikami\",\n    \"prefs_users_description\": \"Dodaj/usuń użytkowników dla tematów chronionych hasłem. Uwaga: Nazwa użytkownika i hasło są przechowywane w lokalnej pamięci przeglądarki.\",\n    \"prefs_users_table\": \"Tabela użytkowników\",\n    \"prefs_users_add_button\": \"Dodaj użytkownika\",\n    \"notifications_attachment_open_button\": \"Otwórz załącznik\",\n    \"prefs_users_edit_button\": \"Edytuj użytkownika\",\n    \"prefs_users_delete_button\": \"Usuń użytkownika\",\n    \"prefs_users_table_base_url_header\": \"Adres URL usługi\",\n    \"prefs_users_dialog_title_add\": \"Dodaj użytkownika\",\n    \"common_cancel\": \"Anuluj\",\n    \"common_add\": \"Dodaj\",\n    \"common_save\": \"Zapisz\",\n    \"prefs_appearance_title\": \"Wygląd\",\n    \"prefs_appearance_language_title\": \"Język\",\n    \"error_boundary_title\": \"Oh nie, ntfy przestało działać\",\n    \"error_boundary_description\": \"Oczywiście, to nie miało się wydarzyć. Bardzo przepraszam za to.<br/>Jeśli masz minutę, proszę <githubLink>zgłoś to na GitHubie</githubLink>, albo daj nam znać przez <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopiuj stack trace\",\n    \"error_boundary_stack_trace\": \"Stack trace\",\n    \"error_boundary_gathering_info\": \"Zbierz więcej informacji …\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Prywatne karty przeglądarki nie są obsługiwane\",\n    \"action_bar_show_menu\": \"Pokaż menu\",\n    \"action_bar_logo_alt\": \"ntfy logo\",\n    \"action_bar_unsubscribe\": \"Zrezygnuj z subskrypcji\",\n    \"notifications_attachment_copy_url_title\": \"Kopiuj adres URL załącznika do schowka\",\n    \"action_bar_settings\": \"Ustawienia\",\n    \"notifications_priority_x\": \"Priorytet {{priority}}\",\n    \"notifications_new_indicator\": \"Nowe powiadomienie\",\n    \"notifications_attachment_open_title\": \"Przejdź do {{url}}\",\n    \"notifications_click_copy_url_button\": \"Skopiuj łącze\",\n    \"notifications_none_for_topic_description\": \"Aby wysłać powiadomienia do tego tematu, wyślij PUT lub POST-Request na adres URL tematu.\",\n    \"notifications_none_for_any_title\": \"Nie otrzymałeś żadnych powiadomień.\",\n    \"notifications_more_details\": \"Bardziej szczegółowe informacje można znaleźć na <websiteLink>stronie internetowej</websiteLink> oraz w <docsLink>dokumentacji</docsLink>.\",\n    \"publish_dialog_priority_default\": \"Domyślny priorytet\",\n    \"publish_dialog_priority_max\": \"Max. priorytet\",\n    \"publish_dialog_priority_high\": \"Wysoki priorytet\",\n    \"publish_dialog_delay_placeholder\": \"Opóźnienie dostarczenie, np.{{unixTimestamp}}, {{relativeTime}}, lub \\\"{{naturalLanguage}}\\\" (tylko w języku angielskim)\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Subskrybuj\",\n    \"prefs_users_table_user_header\": \"Użytkownik\",\n    \"publish_dialog_attached_file_remove\": \"Usuń załączony plik\",\n    \"subscribe_dialog_subscribe_description\": \"Tematy nie mogą być chronione hasłem, więc wybierz trudną do odgadnięcia nazwę. Po zasubskrybowaniu możesz wysyłać powiadomienia poprzez POST/PUT.\",\n    \"subscribe_dialog_login_title\": \"Wymagane jest zalogowanie się\",\n    \"prefs_notifications_delete_after_title\": \"Usuń powiadomienia\",\n    \"prefs_users_dialog_password_label\": \"Hasło\",\n    \"priority_low\": \"niski\",\n    \"priority_default\": \"podstawowy\",\n    \"priority_max\": \"maksymalny\",\n    \"prefs_notifications_delete_after_three_hours\": \"Po trzech godzinach\",\n    \"prefs_users_dialog_base_url_label\": \"Adres URL usługi, np. https://ntfy.sh\",\n    \"prefs_notifications_sound_no_sound\": \"Bez dzwięku\",\n    \"prefs_users_dialog_username_label\": \"Nazwa użytkownika, np. phil\",\n    \"priority_high\": \"wysoki\",\n    \"prefs_notifications_min_priority_max_only\": \"Tylko maksymalny priorytet\",\n    \"prefs_notifications_delete_after_never\": \"Nigdy\",\n    \"prefs_users_dialog_title_edit\": \"Edytuj użytkownika\",\n    \"priority_min\": \"minimum\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.\",\n    \"signup_form_password\": \"Hasło\",\n    \"signup_title\": \"Załóż konto ntfy\",\n    \"signup_error_creation_limit_reached\": \"Przekroczono limit zakładania kont\",\n    \"action_bar_reservation_limit_reached\": \"Limit wyczerpany\",\n    \"display_name_dialog_title\": \"Zmień wyświetlaną nazwę\",\n    \"display_name_dialog_description\": \"Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.\",\n    \"account_basics_title\": \"Konto\",\n    \"account_basics_password_dialog_title\": \"Zmień hasło\",\n    \"signup_form_username\": \"Nawa użytkownika\",\n    \"signup_form_confirm_password\": \"Powtórz hasło\",\n    \"signup_form_button_submit\": \"Załóż konto\",\n    \"signup_form_toggle_password_visibility\": \"Pokaż lub ukryj hasło\",\n    \"signup_already_have_account\": \"Masz już konto? Zaloguj się!\",\n    \"signup_disabled\": \"Zakładanie kont jest wyłączone\",\n    \"signup_error_username_taken\": \"Nazwa użytkownika {{username}} jest już zajęta\",\n    \"login_title\": \"Zaloguj się do swojego konta ntfy\",\n    \"login_form_button_submit\": \"Zaloguj się\",\n    \"login_link_signup\": \"Załóż konto\",\n    \"login_disabled\": \"Logowanie jet wyłączone\",\n    \"action_bar_account\": \"Konto\",\n    \"action_bar_change_display_name\": \"Zmień wyświetlaną nazwę\",\n    \"action_bar_reservation_add\": \"Zarezerwuj temat\",\n    \"action_bar_reservation_edit\": \"Zmień rezerwację\",\n    \"action_bar_reservation_delete\": \"Usuń rezerwację\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Ustawienia\",\n    \"action_bar_profile_logout\": \"Wyloguj\",\n    \"action_bar_sign_in\": \"Zaloguj\",\n    \"action_bar_sign_up\": \"Załóż konto\",\n    \"nav_button_account\": \"Konto\",\n    \"display_name_dialog_placeholder\": \"Nazwa wyświetlana\",\n    \"reserve_dialog_checkbox_label\": \"Zarezerwuj temat i skonfiguruj dostęp\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Wygeneruj nazwę\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Temat już jest zarezerwowany\",\n    \"account_basics_username_title\": \"Nazwa użytkownika\",\n    \"account_basics_username_description\": \"Hej, to Ty ❤\",\n    \"account_basics_username_admin_tooltip\": \"Jesteś Administratorem\",\n    \"account_basics_password_title\": \"Hasło\",\n    \"account_basics_password_description\": \"Zmień hasło do konta\",\n    \"account_basics_password_dialog_current_password_label\": \"Aktualne hasło\",\n    \"account_basics_password_dialog_new_password_label\": \"Nowe hasło\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Powtórz hasło\",\n    \"account_basics_password_dialog_button_submit\": \"Zmień hasło\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Błędne hasło\",\n    \"account_usage_title\": \"Użycie\",\n    \"account_usage_of_limit\": \"z {{limit}}\",\n    \"account_usage_unlimited\": \"Bez limitu\",\n    \"account_usage_limits_reset_daily\": \"Limity są resetowane codziennie o północy (UTC)\",\n    \"account_delete_dialog_button_submit\": \"Nieodwracalnie usuń konto\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Brak rezerwacji tematów\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} na plik\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} pamięci łącznie\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"miesiąc\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} na rok. Płatne miesięcznie.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"W razie pytań dotyczących rozliczeń <Link>skontaktuj się z nami</Link> bezpośrednio.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"W razie pytań dotyczących rozliczeń sprawdź naszą <Link>stronę</Link>.\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Anuluj subskrypcję\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Zmień subskrypcję\",\n    \"account_tokens_title\": \"Tokeny dostępowe\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Etykieta\",\n    \"account_tokens_table_last_access_header\": \"Ostatnie użycie\",\n    \"account_tokens_table_expires_header\": \"Termin ważności\",\n    \"account_tokens_table_never_expires\": \"Bezterminowy\",\n    \"account_tokens_table_current_session\": \"Aktualna sesja przeglądarki\",\n    \"common_copy_to_clipboard\": \"Kopiuj do schowka\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token został skopiowany\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Nie można edytować ani usunąć tokenu aktualnej sesji\",\n    \"account_tokens_table_create_token_button\": \"Utwórz token dostępowy\",\n    \"account_tokens_dialog_label\": \"Etykieta, np. Powiadomienia Radarr\",\n    \"account_tokens_dialog_button_update\": \"Zmień token\",\n    \"account_basics_tier_interval_monthly\": \"miesięcznie\",\n    \"account_basics_tier_interval_yearly\": \"rocznie\",\n    \"account_upgrade_dialog_interval_monthly\": \"Miesięcznie\",\n    \"account_upgrade_dialog_title\": \"Zmień plan konta\",\n    \"account_delete_dialog_description\": \"Konto, wraz ze wszystkimi związanymi z nim danymi przechowywanymi na serwerze, będzie nieodwracalnie usunięte. Po usunięciu Twoja nazwa użytkownika będzie niedostępna jeszcze przez 7 dni. Jeśli chcesz kontynuować, potwierdź wpisując swoje hasło w polu poniżej.\",\n    \"account_delete_dialog_billing_warning\": \"Usunięcie konta powoduje natychmiastowe anulowanie subskrypcji. Nie będziesz już mieć dostępu do strony z rachunkami.\",\n    \"account_upgrade_dialog_interval_yearly\": \"Rocznie\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"taniej o {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"nawet {{discount}}% taniej\",\n    \"account_upgrade_dialog_button_cancel\": \"Anuluj\",\n    \"account_tokens_description\": \"Używaj tokenów do publikowania wiadomości i subskrybowania tematów przez API ntfy, żeby uniknąć konieczności podawania danych do logowania. Szczegóły znajdziesz w <Link>dokumentacji</Link>.\",\n    \"account_tokens_dialog_title_create\": \"Utwórz token dostępowy\",\n    \"account_tokens_table_last_origin_tooltip\": \"Z adresu IP {{ip}}, kliknij żeby sprawdzić\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} płatne jednorazowo. Oszczędzasz {{save}}.\",\n    \"account_tokens_dialog_title_edit\": \"Edytuj token dostępowy\",\n    \"account_tokens_dialog_title_delete\": \"Usuń token dostępowy\",\n    \"account_tokens_dialog_button_create\": \"Utwórz token\",\n    \"nav_upgrade_banner_label\": \"Przejdź na ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Rezerwuj tematy, więcej powiadomień i maili oraz większe załączniki\",\n    \"alert_not_supported_context_description\": \"Powiadomienia działają tylko przez HTTPS. To jest ograniczenie <mdnLink>Notifications API</mdnLink>.\",\n    \"account_basics_tier_canceled_subscription\": \"Twoja subskrypcja została anulowana i konto zostanie ograniczone do wersji darmowej w dniu {{date}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Zarządzaj rachunkami\",\n    \"account_usage_messages_title\": \"Wysłane wiadomości\",\n    \"account_usage_emails_title\": \"Wysłane maile\",\n    \"account_basics_tier_title\": \"Rodzaj konta\",\n    \"account_basics_tier_description\": \"Mocarność Twojego konta\",\n    \"account_basics_tier_admin\": \"Administrator\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(plan {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(brak planu)\",\n    \"account_basics_tier_basic\": \"Podstawowe\",\n    \"account_basics_tier_free\": \"Darmowe\",\n    \"account_basics_tier_upgrade_button\": \"Przejdź na Pro\",\n    \"account_basics_tier_change_button\": \"Zmień\",\n    \"account_basics_tier_paid_until\": \"Subskrypcja opłacona do {{date}} i będzie odnowiona automatycznie\",\n    \"account_basics_tier_payment_overdue\": \"Minął termin płatności. Zaktualizuj metodę płatności, w przeciwnym razie Twoje konto wkrótce zostanie ograniczone.\",\n    \"account_usage_reservations_title\": \"Zarezerwowane tematy\",\n    \"account_usage_reservations_none\": \"Brak zarezerwowanych tematów na tym koncie\",\n    \"account_usage_attachment_storage_title\": \"Miejsce na załączniki\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} na każdy plik, przechowywane przez {{expiry}}\",\n    \"account_usage_basis_ip_description\": \"Statystyki i limity dla tego konta bazują na Twoim adresie IP, więc mogą być współdzielone z innymi użytkownikami. Limity pokazane powyżej to wartości przybliżone bazujące na rzeczywistych limitach.\",\n    \"account_usage_cannot_create_portal_session\": \"Nie można otworzyć portalu z rachunkami\",\n    \"account_delete_title\": \"Usuń konto\",\n    \"account_delete_description\": \"Usuń swoje konto nieodwracalnie\",\n    \"account_delete_dialog_label\": \"Hasło\",\n    \"account_delete_dialog_button_cancel\": \"Anuluj\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Załóż konto\",\n    \"account_upgrade_dialog_button_pay_now\": \"Zapłać i aktywuj subskrypcję\",\n    \"account_tokens_dialog_button_cancel\": \"Anuluj\",\n    \"account_tokens_dialog_expires_label\": \"Token dostępowy wygasa po\",\n    \"account_tokens_dialog_expires_unchanged\": \"Pozostaw termin ważności bez zmian\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} rezerwacja tematu\",\n    \"account_upgrade_dialog_tier_features_reservations_few\": \"{{reservations}} rezerwacje tematów\",\n    \"account_upgrade_dialog_tier_features_reservations_many\": \"{{reservations}} rezerwacji tematów\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} mail dziennie\",\n    \"account_upgrade_dialog_tier_features_emails_few\": \"{{emails}} maile dziennie\",\n    \"account_upgrade_dialog_tier_features_emails_many\": \"{{emails}} maili dziennie\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} wiadomość dziennie\",\n    \"account_upgrade_dialog_tier_features_messages_few\": \"{{messages}} wiadomości dziennie\",\n    \"account_upgrade_dialog_tier_features_messages_many\": \"{{messages}} wiadomości dziennie\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Brak połączeń telefonicznych\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"Dzienne rozmowy telefoniczne: {{calls}}\",\n    \"account_upgrade_dialog_tier_current_label\": \"Bieżące\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Numer telefonu\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Potwierdź kod\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Proporcja</strong>: Przy ulepszaniu pomiędzy płatnymi planami, różnica ceny będzie <strong>pobrana natychmiast</strong>. Przy obniżaniu planu do niższych planów, środki zostaną użyte do rozliczenia przyszłych okresów subskrypcji.\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"Dzienne wiadomości e-mail: {{emails}}\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"Dzienne rozmowy telefoniczne: {{calls}}\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Wyślij SMS\",\n    \"account_tokens_dialog_expires_never\": \"Token nigdy nie wygasa\",\n    \"account_tokens_dialog_expires_x_days\": \"Token wygasa za {{days}} dni\",\n    \"account_basics_phone_numbers_dialog_description\": \"Aby używać funkcji powiadomień telefonicznych, musisz dodać i zweryfikować no najmniej jeden numer telefonu. Weryfikacja może być dokonana przez SMS lub połączenie telefoniczne.\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"Dzienne wiadomości: {{messages}}\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Brak numerów telefonów\",\n    \"account_tokens_delete_dialog_title\": \"Usuń token dostępu\",\n    \"publish_dialog_chip_call_label\": \"Rozmowa telefoniczna\",\n    \"account_basics_phone_numbers_dialog_title\": \"Dodaj numer telefonu\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"Zarezerwowane tematy: {{reservations}}\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Wybrany plan zezwala na mniejszą liczbę zarezerwowanych tematów niż obecny. Przed zmianą planu, <strong>usuń co najmniej jedną rezerwację</strong>. Rezerwacje możesz usunąć w <Link>Ustawieniach</Link>.\",\n    \"account_basics_phone_numbers_description\": \"Dla powiadomień telefonicznych\",\n    \"account_upgrade_dialog_cancel_warning\": \"To <strong>anuluje Twoją subskrypcję</strong> i obniży status Twojego konta {{date}}. Tego dnia rezerwacja tematów oraz wiadomości przechowywane na serwerze <strong>zostaną usunięte</strong>.\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token wygasa za {{hours}} godzin(y)\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Brak zweryfikowanych numerów telefonów\",\n    \"publish_dialog_call_label\": \"Rozmowa telefoniczna\",\n    \"account_usage_calls_title\": \"Wykonane połączenia telefoniczne\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Numer telefonu skopiowany do schowka\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"np. +1222333444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"np. 123456\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Zadzwoń\",\n    \"account_basics_phone_numbers_title\": \"Numery telefonów\",\n    \"account_usage_calls_none\": \"Nie wykonano żadnych połączeń z tego konta\",\n    \"publish_dialog_call_reset\": \"Usuń rozmowę telefoniczną\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Kod weryfikacyjny\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Zadzwoń do mnie\",\n    \"publish_dialog_call_item\": \"Zadzwoń pod numer {{number}}\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Wybrane\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Wybrany plan zezwala na mniejszą liczbę zarezerwowanych tematów niż obecny. Przed zmianą planu, <strong>usuń co najmniej tyle rezerwacji: {{count}}</strong>. Rezerwacje możesz usunąć w <Link>Ustawieniach</Link>.\",\n    \"prefs_reservations_title\": \"Zarezerwowane tematy\",\n    \"prefs_reservations_table_everyone_read_only\": \"Ja mogę publikować i subskrybować, każdy może subskrybować\",\n    \"prefs_reservations_table_not_subscribed\": \"Nie jesteś zasubskrybowany\",\n    \"prefs_reservations_dialog_title_delete\": \"Usuń rezerwacje tematu\",\n    \"prefs_reservations_dialog_topic_label\": \"Temat\",\n    \"reservation_delete_dialog_action_delete_title\": \"Usuń wiadomości i załączniki zapisane w pamięci cache\",\n    \"prefs_reservations_description\": \"Możesz tutaj zarezerwować nazwy tematów do własnego użytku. Rezerwacja tematu daje ci go na własność i pozwala definiować permisje dla innych użytkowników.\",\n    \"prefs_reservations_limit_reached\": \"Zużyłeś swój limit zarezerwowanych tematów.\",\n    \"prefs_reservations_add_button\": \"Dodaj zarezerwowany temat\",\n    \"account_tokens_delete_dialog_description\": \"Przed usuwaniem tokenu dostępu upewnij się, że nie jest on aktywnie używany przez inną aplikację lub skrypt. <strong>Ta akcja nie może być wycofana</strong>.\",\n    \"prefs_reservations_table_everyone_read_write\": \"Każdy może publikować i subskrybować\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Kliknij aby subskrybować\",\n    \"prefs_reservations_dialog_title_edit\": \"Modyfikuj zarezerwowany temat\",\n    \"prefs_reservations_table_everyone_write_only\": \"Ja mogę publikować i subskrybować, każdy może publikować\",\n    \"action_bar_mute_notifications\": \"Wycisz powiadomienia\",\n    \"alert_notification_permission_denied_title\": \"Powiadomienia są blokowane\",\n    \"alert_notification_ios_install_required_description\": \"Wciśnij ikonę Udostępniania i dodaj do Strony Głównej aby zezwolić na otrzymywanie powiadomień na IOS\",\n    \"notifications_actions_failed_notification\": \"Akcja zakończona niepowodzeniem\",\n    \"prefs_notifications_web_push_disabled_description\": \"Powiadomienia są dostarczane kiedy aplikacja jest aktywna (poprzez WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Włączone dla {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Wyłączone\",\n    \"prefs_appearance_theme_system\": \"Systemowy (domyślny)\",\n    \"prefs_appearance_theme_dark\": \"Tryb ciemny\",\n    \"prefs_appearance_theme_light\": \"Tryb jasny\",\n    \"prefs_reservations_edit_button\": \"Modyfikuj ustawienia dostępu dla tematu\",\n    \"prefs_reservations_table\": \"Tabela zarezerwowanych tematów\",\n    \"prefs_reservations_table_topic_header\": \"Temat\",\n    \"prefs_reservations_table_access_header\": \"Dostęp\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Tylko ja mogę publikować i subskrybować\",\n    \"prefs_reservations_dialog_access_label\": \"Dostęp\",\n    \"reservation_delete_dialog_action_delete_description\": \"Wiadomości i załączniki zapisane w pamięci cache zostaną pernamentie usunięte. <strong>Ta akcja nie może być wycofana</strong>.\",\n    \"reservation_delete_dialog_submit_button\": \"Usuń rezerwację\",\n    \"error_boundary_button_reload_ntfy\": \"Przeładuj ntfy\",\n    \"web_push_subscription_expiring_title\": \"Powiadomienia będą wstrzymane\",\n    \"web_push_subscription_expiring_body\": \"Otwórz ntfy aby nadal dostawać powiadomienia\",\n    \"alert_notification_permission_denied_description\": \"Prosze ponownie pozwolić na otrzymywanie powiadomień w twojej przeglądarce\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Powiadomienia z innych serwerów nie zostaną odebrane jeśli aplikacja nie jest otwarta\",\n    \"alert_notification_ios_install_required_title\": \"Instalacja IOS wymagana\",\n    \"publish_dialog_checkbox_markdown\": \"Formatuj jako Markdown\",\n    \"account_tokens_delete_dialog_submit_button\": \"Pernamentnie usuń token dostępu\",\n    \"prefs_notifications_web_push_title\": \"Powiadomienia w tle\",\n    \"prefs_notifications_web_push_enabled_description\": \"Powiadomienia są dostarczane nawet kiedy aplikacja nie jest aktywna (poprzez Web Push)\",\n    \"prefs_users_description_no_sync\": \"Nazwy użytkownika i hasła nie są synchronizowane z kontem.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Nie można usunąć lub modyfikować zalogowanego użytkownika\",\n    \"prefs_reservations_delete_button\": \"Zresetuj ustawienia dostępu dla tematu\",\n    \"prefs_reservations_dialog_title_add\": \"Zarezerwuj temat\",\n    \"reservation_delete_dialog_action_keep_title\": \"Zachowaj wiadomości i załącznik w pamięci cache\",\n    \"reservation_delete_dialog_action_keep_description\": \"Wiadomości i załączniki które są zapisane w pamięci cache będą dostępne publicznie dla każdego znającego nazwę powiązanego z nimi tematu.\",\n    \"web_push_unknown_notification_title\": \"Nieznane powiadomienie otrzymane od serwera\",\n    \"action_bar_unmute_notifications\": \"Włącz ponownie powiadomienia\",\n    \"prefs_appearance_theme_title\": \"Wygląd\",\n    \"prefs_reservations_dialog_description\": \"Zastrzeżenie tematu daje użytkownikowi prawo własności do tego tematu i umożliwia zdefiniowanie uprawnień dostępu do tego tematu dla innych użytkowników.\",\n    \"reservation_delete_dialog_description\": \"Usunięcie rezerwacji powoduje rezygnację z prawa własności do tematu i umożliwia innym jego zarezerwowanie. Istniejące wiadomości i załączniki można zachować lub usunąć.\",\n    \"web_push_unknown_notification_body\": \"Konieczne może być zaktualizowanie ntfy poprzez otwarcie aplikacji internetowej\"\n}\n"
  },
  {
    "path": "web/public/static/langs/pt.json",
    "content": "{\n    \"action_bar_clear_notifications\": \"Limpar todas as notificações\",\n    \"action_bar_send_test_notification\": \"Enviar notificação de teste\",\n    \"action_bar_unsubscribe\": \"Anular subscrição\",\n    \"action_bar_toggle_mute\": \"Ativa/Desativa notificações\",\n    \"action_bar_toggle_action_menu\": \"Abrir/fechar menu de ação\",\n    \"message_bar_type_message\": \"Escreva uma mensagem aqui\",\n    \"message_bar_error_publishing\": \"Erro ao publicar notificação\",\n    \"message_bar_publish\": \"Publicar mensagem\",\n    \"nav_topics_title\": \"Tópicos subscritos\",\n    \"nav_button_all_notifications\": \"Todas notificações\",\n    \"nav_button_settings\": \"Configurações\",\n    \"nav_button_documentation\": \"Documentação\",\n    \"nav_button_publish_message\": \"Publicar notificação\",\n    \"nav_button_subscribe\": \"Subscrever tópico\",\n    \"nav_button_muted\": \"Notificações desativadas\",\n    \"nav_button_connecting\": \"A ligar\",\n    \"alert_notification_permission_required_title\": \"As notificações estão desativadas\",\n    \"alert_notification_permission_required_description\": \"Conceder permissão ao seu navegador para mostrar notificações\",\n    \"alert_not_supported_title\": \"Notificações não suportadas\",\n    \"notifications_list\": \"Lista de notificações\",\n    \"alert_not_supported_description\": \"As notificações não são suportadas pelo seu navegador\",\n    \"notifications_list_item\": \"Notificação\",\n    \"notifications_mark_read\": \"Marcar como lido\",\n    \"notifications_delete\": \"Apagar\",\n    \"notifications_copied_to_clipboard\": \"Copiado para a área de transferência\",\n    \"notifications_tags\": \"Etiquetas\",\n    \"notifications_priority_x\": \"Prioridade {{priority}}\",\n    \"notifications_new_indicator\": \"Nova notificação\",\n    \"notifications_attachment_image\": \"Imagem anexada\",\n    \"notifications_attachment_copy_url_title\": \"Copiar URL do anexo para a área de transferência\",\n    \"notifications_attachment_copy_url_button\": \"Copiar URL\",\n    \"notifications_attachment_open_title\": \"Ir para {{url}}\",\n    \"notifications_attachment_link_expired\": \"a ligação de descarga expirou\",\n    \"notifications_attachment_open_button\": \"Abrir anexo\",\n    \"notifications_attachment_link_expires\": \"a ligação expira em {{date}}\",\n    \"notifications_attachment_file_image\": \"ficheiro de imagem\",\n    \"notifications_attachment_file_video\": \"ficheiro de vídeo\",\n    \"notifications_attachment_file_audio\": \"ficheiro de áudio\",\n    \"notifications_attachment_file_app\": \"ficheiro apk Android\",\n    \"notifications_attachment_file_document\": \"outros documentos\",\n    \"notifications_click_copy_url_title\": \"Copiar URL da ligação para a área de transferência\",\n    \"notifications_click_copy_url_button\": \"Copiar ligação\",\n    \"notifications_click_open_button\": \"Abrir ligação\",\n    \"notifications_actions_open_url_title\": \"Ir para {{url}}\",\n    \"notifications_actions_not_supported\": \"Ação não suportada na app web\",\n    \"notifications_actions_http_request_title\": \"Enviar HTTP {{method}} para {{url}}\",\n    \"notifications_none_for_topic_title\": \"Ainda não recebeu nenhuma notificação deste tópico.\",\n    \"notifications_none_for_topic_description\": \"Para enviar notificações deste tópico, basta usar os métodos PUT ou POST no URL do tópico.\",\n    \"notifications_none_for_any_title\": \"Ainda não recebeu nenhuma notificação.\",\n    \"notifications_none_for_any_description\": \"Para enviar notificações dum tópico, basta usar os métodos PUT ou POST no URL do tópico. Eis um exemplo usando um dos seus tópicos.\",\n    \"notifications_no_subscriptions_title\": \"Parece que ainda não tem nenhuma inscrição.\",\n    \"notifications_no_subscriptions_description\": \"Clique na ligação \\\"{{linktext}}\\\" para criar ou subscrever um tópico. Depois, poderá enviar mensagens via PUT ou POST e receberá notificações aqui.\",\n    \"notifications_example\": \"Exemplo\",\n    \"notifications_more_details\": \"Para mais informações, aceda ao <websiteLink>site</websiteLink> ou à <docsLink>documentação</docsLink>.\",\n    \"notifications_loading\": \"A carregar notificações…\",\n    \"publish_dialog_title_topic\": \"Publicar em {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publicar notificação\",\n    \"publish_dialog_progress_uploading\": \"A enviar …\",\n    \"publish_dialog_progress_uploading_detail\": \"A enviar {{loaded}}/{{total}} ({{percent}}%)…\",\n    \"publish_dialog_message_published\": \"Notificação publicada\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"excede limite de ficheiro de {{fileSizeLimit}} e cota, {{remainingBytes}} restante(s)\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"excede a cota, {{remainingBytes}} restante(s)\",\n    \"publish_dialog_priority_min\": \"Prioridade mínima\",\n    \"publish_dialog_priority_low\": \"Prioridade baixa\",\n    \"publish_dialog_priority_default\": \"Prioridade padrão\",\n    \"publish_dialog_priority_high\": \"Prioridade alta\",\n    \"publish_dialog_base_url_label\": \"URL de serviço\",\n    \"publish_dialog_base_url_placeholder\": \"URL de serviço, por exemplo: https://exemplo.com\",\n    \"publish_dialog_topic_label\": \"Nome do tópico\",\n    \"publish_dialog_topic_placeholder\": \"Nome do tópico, por exemplo: \\\"avisos_do_filipe\\\"\",\n    \"publish_dialog_topic_reset\": \"Limpar tópico\",\n    \"publish_dialog_title_placeholder\": \"Título da notificação, por exemplo: \\\"Alerta de espaço em disco\\\"\",\n    \"publish_dialog_message_label\": \"Mensagem\",\n    \"publish_dialog_message_placeholder\": \"Escreva uma mensagem aqui\",\n    \"publish_dialog_tags_label\": \"Etiquetas\",\n    \"publish_dialog_tags_placeholder\": \"Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioridade\",\n    \"publish_dialog_click_label\": \"URL de clique\",\n    \"publish_dialog_click_placeholder\": \"URL que é aberto quando a notificação é clicada\",\n    \"publish_dialog_click_reset\": \"Remover URL de clique\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_filename_placeholder\": \"Nome do ficheiro anexado\",\n    \"publish_dialog_email_placeholder\": \"Endereça para o qual encaminhar a notificação, por exemplo: filipe@exemplo.com\",\n    \"publish_dialog_email_reset\": \"Remover encaminhamento por email\",\n    \"publish_dialog_attach_label\": \"URL de anexo\",\n    \"publish_dialog_attach_placeholder\": \"Anexar ficheiro por URL, por exemplo: https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Remover URL de anexo\",\n    \"publish_dialog_filename_label\": \"Nome do ficheiro\",\n    \"publish_dialog_delay_label\": \"Atraso\",\n    \"publish_dialog_delay_placeholder\": \"Atraso na entrega, por exemplo \\\"{{{unixTimestamp}}\\\", \\\"{{relativeTime}}\\\", ou \\\"{{naturalLanguage}}\\\" (apenas em Inglês)\",\n    \"publish_dialog_other_features\": \"Outras funcionalidades:\",\n    \"publish_dialog_chip_click_label\": \"URL de clique\",\n    \"publish_dialog_chip_topic_label\": \"Alterar tópico\",\n    \"publish_dialog_details_examples_description\": \"Para obter exemplos e uma descrição detalhada de todas as funcionalidades de envio, consulte a <docsLink>documentação</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Cancelar o envio\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nome do ficheiro anexado\",\n    \"publish_dialog_attached_file_remove\": \"Remover ficheiro anexado\",\n    \"emoji_picker_search_clear\": \"Limpar pesquisa\",\n    \"subscribe_dialog_subscribe_description\": \"Os tópicos podem não ser protegidos por palavra-passe, por isso escolha um nome que não seja fácil de adivinhar. Uma vez subscrito, pode usar os métodos PUT/POST para publicar notificações.\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Usar outro servidor\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Utilizador {{username}} não autorizado\",\n    \"prefs_notifications_min_priority_description_max\": \"Mostrar notificações se prioridade for 5 (máxima)\",\n    \"prefs_notifications_delete_after_one_week\": \"Após uma semana\",\n    \"prefs_notifications_delete_after_one_month\": \"Após um mês\",\n    \"prefs_notifications_delete_after_never_description\": \"As notificações nunca serão eliminadas automaticamente\",\n    \"prefs_notifications_delete_after_one_week_description\": \"As notificações serão eliminadas automaticamente após uma semana\",\n    \"prefs_notifications_delete_after_one_month_description\": \"As notificações serão eliminadas automaticamente após um mês\",\n    \"prefs_users_dialog_username_label\": \"Utilizador, por exemplo: \\\"filipe\\\"\",\n    \"prefs_users_dialog_password_label\": \"Palavra-passe\",\n    \"common_cancel\": \"Cancelar\",\n    \"common_add\": \"Adicionar\",\n    \"error_boundary_description\": \"Obviamente, isto não devia acontecer, lamentamos o sucedido.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_stack_trace\": \"Erro (\\\"stack trace\\\")\",\n    \"error_boundary_gathering_info\": \"A recolher mais informações …\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Navegação anónima não suportada\",\n    \"error_boundary_unsupported_indexeddb_description\": \"A aplicação web ntfy necessita da \\\"IndexedDB\\\" para funcionar e o seu navegador não a suporta no modo de navegação privada.<br/><br/>Embora isso seja inconveniente, também não faz muito sentido usar a aplicação no modo de navegação privada de qualquer maneira, visto que tudo é guardado no armazenamento do navegador. Pode ler mais sobre isso <githubLink>nesta questão no GitHub</githubLink>, ou falar connosco por <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_show_menu\": \"Mostrar menu\",\n    \"action_bar_logo_alt\": \"logótipo do ntfy\",\n    \"action_bar_settings\": \"Configurações\",\n    \"message_bar_show_dialog\": \"Mostrar caixa de publicação\",\n    \"alert_notification_permission_required_button\": \"Conceder agora\",\n    \"publish_dialog_attachment_limits_file_reached\": \"excede o limite de ficheiro de {{fileSizeLimit}}\",\n    \"publish_dialog_emoji_picker_show\": \"Escolher emoji\",\n    \"publish_dialog_priority_max\": \"Prioridade máxima\",\n    \"publish_dialog_title_label\": \"Título\",\n    \"publish_dialog_delay_reset\": \"Remover atraso de entrega\",\n    \"publish_dialog_chip_email_label\": \"Encaminhar para email\",\n    \"publish_dialog_chip_attach_url_label\": \"Anexar ficheiro por URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Anexar ficheiro local\",\n    \"publish_dialog_chip_delay_label\": \"Atraso de entrega\",\n    \"publish_dialog_button_cancel\": \"Cancelar\",\n    \"publish_dialog_button_send\": \"Enviar\",\n    \"publish_dialog_checkbox_publish_another\": \"Publicar outra\",\n    \"publish_dialog_attached_file_title\": \"Ficheiro anexado:\",\n    \"publish_dialog_drop_file_here\": \"Arraste o ficheiro para aqui\",\n    \"emoji_picker_search_placeholder\": \"Pesquisar emoji\",\n    \"subscribe_dialog_subscribe_title\": \"Subscrever tópico\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nome do tópico, por exemplo: \\\"alertas_do_filipe\\\"\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL de serviço\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Cancelar\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Subscrever\",\n    \"subscribe_dialog_login_title\": \"Autenticação necessária\",\n    \"subscribe_dialog_login_description\": \"Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.\",\n    \"subscribe_dialog_login_username_label\": \"Nome, por exemplo: \\\"filipe\\\"\",\n    \"subscribe_dialog_login_password_label\": \"Palavra-passe\",\n    \"common_back\": \"Voltar\",\n    \"subscribe_dialog_login_button_login\": \"Autenticar\",\n    \"subscribe_dialog_error_user_anonymous\": \"anónimo\",\n    \"prefs_notifications_title\": \"Notificações\",\n    \"prefs_notifications_sound_title\": \"Som de notificações\",\n    \"prefs_notifications_sound_description_none\": \"Notificações não reproduzem nenhum som quando chegam\",\n    \"prefs_notifications_sound_description_some\": \"Notificações reproduzem som {{sound}} quando chegam\",\n    \"prefs_notifications_sound_no_sound\": \"Sem som\",\n    \"prefs_notifications_sound_play\": \"Reproduzir som selecionado\",\n    \"prefs_notifications_min_priority_title\": \"Prioridade mínima\",\n    \"prefs_notifications_min_priority_description_any\": \"A mostrar todas as notificações, independentemente da prioridade\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima\",\n    \"prefs_notifications_min_priority_any\": \"Qualquer prioridade\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Prioridade baixa e acima\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioridade padrão e acima\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Prioridade alta e acima\",\n    \"prefs_notifications_min_priority_max_only\": \"Apenas prioridade máxima\",\n    \"prefs_notifications_delete_after_title\": \"Eliminar notificações\",\n    \"prefs_notifications_delete_after_never\": \"Nunca\",\n    \"prefs_notifications_delete_after_three_hours\": \"Após três horas\",\n    \"prefs_notifications_delete_after_one_day\": \"Após um dia\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"As notificações serão eliminadas automaticamente após três horas\",\n    \"prefs_notifications_delete_after_one_day_description\": \"As notificações serão eliminadas automaticamente após um dia\",\n    \"prefs_users_title\": \"Gerir utilizadores\",\n    \"prefs_users_description\": \"Adicionar/remover utilizadores aos seus tópicos protegidos. Note que o utilizador e palavra-passe são guardados no armazenamento local do navegador.\",\n    \"prefs_users_table\": \"Tabela de utilizadores\",\n    \"prefs_users_add_button\": \"Adicionar utilizador\",\n    \"prefs_users_edit_button\": \"Editar utilizador\",\n    \"prefs_users_delete_button\": \"Apagar utilizador\",\n    \"prefs_users_table_user_header\": \"Utilizador\",\n    \"prefs_users_table_base_url_header\": \"URL de serviço\",\n    \"prefs_users_dialog_title_add\": \"Adicionar utilizador\",\n    \"prefs_users_dialog_title_edit\": \"Editar utilizador\",\n    \"prefs_users_dialog_base_url_label\": \"URL de serviço, por exemplo: https://ntfy.sh\",\n    \"common_save\": \"Gravar\",\n    \"prefs_appearance_title\": \"Aparência\",\n    \"prefs_appearance_language_title\": \"Idioma\",\n    \"priority_min\": \"mínima\",\n    \"priority_low\": \"baixa\",\n    \"priority_default\": \"padrão\",\n    \"priority_high\": \"alta\",\n    \"priority_max\": \"máxima\",\n    \"error_boundary_title\": \"Oh não, o ntfy parou de funcionar\",\n    \"error_boundary_button_copy_stack_trace\": \"Copiar erro (\\\"stack trace\\\")\",\n    \"signup_title\": \"Criar uma conta ntfy\",\n    \"signup_form_username\": \"Nome de utilizador\",\n    \"signup_form_confirm_password\": \"Confirmar palavra-passe\",\n    \"signup_form_button_submit\": \"Registar\",\n    \"signup_form_toggle_password_visibility\": \"Alternar visibilidade da palavra-passe\",\n    \"signup_already_have_account\": \"Já tem uma conta? Inicie sessão!\",\n    \"signup_disabled\": \"Novos registos desativados\",\n    \"signup_error_username_taken\": \"O nome \\\"{{username}}\\\" já está em uso\",\n    \"signup_error_creation_limit_reached\": \"Limite de criação de contas atingido\",\n    \"login_title\": \"Inicie sessão na sua conta ntfy\",\n    \"login_form_button_submit\": \"Iniciar sessão\",\n    \"login_disabled\": \"Início de sessão desativado\",\n    \"action_bar_account\": \"Conta\",\n    \"action_bar_change_display_name\": \"Alterar nome de exibição\",\n    \"action_bar_reservation_delete\": \"Remover reserva\",\n    \"action_bar_reservation_limit_reached\": \"Limite alcançado\",\n    \"action_bar_profile_title\": \"Perfil\",\n    \"action_bar_profile_settings\": \"Configurações\",\n    \"action_bar_profile_logout\": \"Terminar sessão\",\n    \"action_bar_sign_in\": \"Iniciar sessão\",\n    \"nav_upgrade_banner_description\": \"Reserve tópicos, envie mais mensagens, emails e anexos maiores\",\n    \"signup_form_password\": \"Palavra-passe\",\n    \"action_bar_reservation_edit\": \"Alterar reserva\",\n    \"login_link_signup\": \"Registar\",\n    \"action_bar_reservation_add\": \"Reservar tópico\",\n    \"action_bar_sign_up\": \"Registar\",\n    \"nav_button_account\": \"Conta\",\n    \"common_copy_to_clipboard\": \"Copiar à área de transferência\",\n    \"nav_upgrade_banner_label\": \"Upgrade para ntfy Pro\",\n    \"alert_not_supported_context_description\": \"As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.\",\n    \"display_name_dialog_title\": \"Alterar o nome público\",\n    \"display_name_dialog_description\": \"Configurar um nome alternativo para um tópico que é mostrado na lista de subscrições. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.\",\n    \"display_name_dialog_placeholder\": \"Nome público\",\n    \"reserve_dialog_checkbox_label\": \"Reservar tópico e configurar acesso\",\n    \"publish_dialog_call_label\": \"Chamada telefónica\",\n    \"publish_dialog_call_placeholder\": \"Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'\",\n    \"publish_dialog_call_reset\": \"Remover chamada telefônica\",\n    \"publish_dialog_chip_call_label\": \"Chamada telefônica\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Gerar nome\",\n    \"action_bar_unmute_notifications\": \"Restaurar notificações\",\n    \"alert_notification_ios_install_required_description\": \"Clique no ícone Compartilhar e Adicionar à Tela Inicial para ativar as notificações no iOS\",\n    \"publish_dialog_checkbox_markdown\": \"Formatar como Markdown\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Números de telefone não verificados\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Tópico já reservado\",\n    \"action_bar_mute_notifications\": \"Silenciar notificações\",\n    \"alert_notification_permission_denied_title\": \"Notificações estão bloqueadas\",\n    \"alert_notification_permission_denied_description\": \"Por favor reative-as em seu navegador\",\n    \"alert_notification_ios_install_required_title\": \"Requer instalação em iOS\",\n    \"notifications_actions_failed_notification\": \"Houve uma falha na ação\",\n    \"publish_dialog_call_item\": \"Ligar para o número de telefone {{number}}\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Notificações de outros servidores não serão recebidas enquanto o aplicativo web não estiver aberto\",\n    \"account_basics_username_description\": \"Olá, és tu ❤\",\n    \"account_basics_password_dialog_new_password_label\": \"Nova palavra-passe\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Palavra-passe inválida\",\n    \"account_basics_phone_numbers_title\": \"Números de telefone\",\n    \"account_basics_phone_numbers_dialog_description\": \"Para utilizar o recurso de notificação por ligação, você precisa adicionar e verificar pelo menos um número de telefone. A verificação poderá ser feita via SMS ou ligação telefônica.\",\n    \"account_basics_phone_numbers_dialog_title\": \"Adicionar número de telefone\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ligue me\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Número de telefone\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"ex.: +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Enviar SMS\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"ex.: 123456\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Código de verificação\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Código de confirmação\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Ligação\",\n    \"account_basics_tier_canceled_subscription\": \"Sua assinatura foi cancelada e será rebaixada para uma conta gratuita em {[data}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Gerenciar cobrança\",\n    \"account_usage_reservations_none\": \"Esta conta não possui tópicos reservados\",\n    \"account_usage_attachment_storage_title\": \"Armazenamento de anexos\",\n    \"account_usage_emails_title\": \"E-mails enviados\",\n    \"account_basics_password_description\": \"Mudar a palavra-passe da conta\",\n    \"account_basics_password_dialog_title\": \"Mudar a palavra-passe\",\n    \"account_basics_phone_numbers_description\": \"Para notificações por ligação\",\n    \"account_basics_tier_paid_until\": \"Assinatura paga até {{date}}, e será renovada automaticamente\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmar palavra-passe\",\n    \"account_basics_password_dialog_button_submit\": \"Mudar palavra-passe\",\n    \"account_basics_title\": \"Conta\",\n    \"account_basics_username_admin_tooltip\": \"És Admin\",\n    \"account_basics_password_title\": \"Palavra-passe\",\n    \"account_basics_password_dialog_current_password_label\": \"Palavra-passe atual\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Nenhum número de telefone\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefones copiados para área de transferência\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_usage_title\": \"Utilização\",\n    \"account_usage_of_limit\": \"de {{limit}}\",\n    \"account_usage_unlimited\": \"Ilimitado\",\n    \"account_usage_limits_reset_daily\": \"Limites de uso são resetados diariamente à meia noite (UTC)\",\n    \"account_basics_tier_title\": \"Tipo de conta\",\n    \"account_basics_tier_description\": \"Nível da sua conta\",\n    \"account_basics_tier_admin\": \"Administrador\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(com {{tier}} classe)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(sem classe)\",\n    \"account_basics_tier_basic\": \"Básico\",\n    \"account_basics_tier_free\": \"Grátis\",\n    \"account_basics_tier_interval_monthly\": \"Mensalmente\",\n    \"account_basics_tier_interval_yearly\": \"anualmente\",\n    \"account_basics_tier_upgrade_button\": \"Atualizar para o Pro\",\n    \"account_basics_tier_change_button\": \"Alterar\",\n    \"account_basics_tier_payment_overdue\": \"Seu pagamento está em atraso. Por favor atualize seu método de pagamento, ou sua conta será rebaixada em breve.\",\n    \"account_usage_messages_title\": \"Mensagens publicadas\",\n    \"account_usage_calls_title\": \"Ligações realizadas\",\n    \"account_usage_calls_none\": \"Esta conta não pode realizar ligações\",\n    \"account_usage_reservations_title\": \"Tópicos reservados\",\n    \"account_basics_username_title\": \"Usuário\",\n    \"account_delete_dialog_description\": \"Isto irá eliminar definitivamente a sua conta, incluindo dados que estejam armazenados no servidor. Apos ser eliminado, o nome de utilizador ficará indisponível durante 7 dias. Se deseja mesmo proceder, por favor confirme com a sua palavra-passe na caixa abaixo.\",\n    \"account_delete_dialog_button_submit\": \"Eliminar conta definitivamente\",\n    \"account_delete_dialog_billing_warning\": \"Eliminar a sua conta também cancela a sua subscrição de faturação imediatamente. Não terá acesso ao portal de faturação de futuro.\",\n    \"account_upgrade_dialog_title\": \"Alterar o nível da sua conta\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensalmente\",\n    \"account_upgrade_dialog_interval_yearly\": \"Anualmente\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"poupe {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"poupe até {{discount}}%\",\n    \"account_delete_dialog_label\": \"Palavra-passe\",\n    \"account_usage_cannot_create_portal_session\": \"Impossível abrir o portal de faturação\",\n    \"account_usage_basis_ip_description\": \"Estatísticas de utilização e limites para esta conta são baseadas no seu endereço IP, pelo que podem ser partilhados com outros utilizadores. Os limites mostrados acima são aproximados com base nos limites existentes.\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} por ficheiro, eliminado após {{expiry}}\",\n    \"account_delete_title\": \"Eliminar conta\",\n    \"account_delete_description\": \"Eliminar definitivamente a sua conta\",\n    \"account_delete_dialog_button_cancel\": \"Cancelar\",\n    \"account_upgrade_dialog_cancel_warning\": \"Isto irá <strong>cancelar a sua assinatura</strong>, e fazer downgrade da sua conta em {{date}}. Nessa data, tópicos reservados bem como mensagens guardadas no servidor <strong>serão eliminados</strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Proporção</strong>: Quando atualizar entre planos pagos, a diferença de preço será <strong>debitada imediatamente</strong>. Quando efetuar um downgrade para um escalão inferior, o saldo disponível será usado para futuros períodos de faturação.\",\n    \"prefs_users_description_no_sync\": \"Utilizadores e palavras-passe não estão sincronizados com a sua conta.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"O nível selecionado permite menos tópicos reservados do que o nível atual. Antes de alterar o seu nível, <strong>apague pelo menos uma reserva</strong>. Pode remover reservas nas <Link>Configurações</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"O nível selecionado permite menos tópicos reservados do que o seu nível atual. Antes de mudar o seu nível, <strong>por favor apague ao menos {{count}} reservas</strong>. Pode remover reservas nas <Link>Configurações</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} tópico reservado\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} tópicos reservados\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Sem tópicos reservados\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} mensagen diária\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mensagens diárias\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} email diário\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} emails diários\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} chamadas diárias\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} chamadas telefônicas diárias\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Nenhuma chamada\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} por ficheiro\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} armazenamento total\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mês\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} por ano. Cobrado mensalmente.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} cobrado anualmente. Gravar {{save}}.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Selecionado\",\n    \"account_upgrade_dialog_tier_current_label\": \"Atual\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Para questões de cobrança, <Link>entre em contacto conosco</Link> diretamente.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Para perguntas sobre o faturamento, consulte o nosso <Link>website</Link>.\",\n    \"account_upgrade_dialog_button_cancel\": \"Cancelar\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Cadastre-se agora\",\n    \"account_upgrade_dialog_button_pay_now\": \"Pague agora para assinar\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Cancelar assinatura\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Atualizar assinatura\",\n    \"account_tokens_title\": \"Tokens de Acesso\",\n    \"account_tokens_description\": \"Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Rótulo\",\n    \"account_tokens_table_last_access_header\": \"Último acesso\",\n    \"account_tokens_table_expires_header\": \"Expira\",\n    \"account_tokens_table_never_expires\": \"Nunca expira\",\n    \"account_tokens_table_current_session\": \"Sessão atual do navegador\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token de acesso copiado\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Não é possível editar ou apagar o token da sessão atual\",\n    \"account_tokens_table_create_token_button\": \"Criar token de acesso\",\n    \"account_tokens_table_last_origin_tooltip\": \"Do endereço IP {{ip}}, clique para pesquisar\",\n    \"account_tokens_dialog_title_create\": \"Criar token de acesso\",\n    \"account_tokens_dialog_title_edit\": \"Editar token de acesso\",\n    \"account_tokens_dialog_title_delete\": \"Apagar token de acesso\",\n    \"account_tokens_dialog_label\": \"Rótulo, por exemplo, notificações de Radarr\",\n    \"account_tokens_dialog_button_create\": \"Criar token\",\n    \"account_tokens_dialog_button_update\": \"Atualizar token\",\n    \"account_tokens_dialog_button_cancel\": \"Cancelar\",\n    \"account_tokens_dialog_expires_label\": \"O token de acesso expira em\",\n    \"account_tokens_dialog_expires_unchanged\": \"Deixar a data de validade inalterada\",\n    \"account_tokens_dialog_expires_x_hours\": \"O token expira em {{hours}} horas\",\n    \"account_tokens_dialog_expires_x_days\": \"O token expira em {{days}} dias\",\n    \"account_tokens_dialog_expires_never\": \"O token nunca expira\",\n    \"account_tokens_delete_dialog_title\": \"Apagar token de acesso\",\n    \"account_tokens_delete_dialog_description\": \"Antes de apagar um token de acesso, certifique-se de que nenhuma aplicação ou script esteja usando-lo ativamente. <strong>Esta ação não pode ser desfeita</strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Apagar token permanentemente\",\n    \"prefs_notifications_web_push_title\": \"Notificações em segundo plano\",\n    \"prefs_notifications_web_push_enabled_description\": \"As notificações são recebidas mesmo quando a aplicação Web não está em execução (via Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"As notificações são recebidas quando a aplicação Web está em execução (via WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Ativado para {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Desativado\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Não é possível apagar ou editar o utilizador conectado\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_system\": \"Sistema (padrão)\",\n    \"prefs_appearance_theme_dark\": \"Modo escuro\",\n    \"prefs_appearance_theme_light\": \"Modo claro\",\n    \"prefs_reservations_title\": \"Tópicos reservados\",\n    \"prefs_reservations_description\": \"Pode reservar nomes de tópicos para uso pessoal aqui. A reserva de um tópico lhe dá propriedade sobre ele e permite que defina permissões de acesso para outros utilizadores sobre o tópico.\",\n    \"prefs_reservations_limit_reached\": \"Atingiu o seu limite de tópicos reservados.\",\n    \"prefs_reservations_add_button\": \"Adicionar tópico reservado\",\n    \"prefs_reservations_edit_button\": \"Editar o acesso ao tópico\",\n    \"prefs_reservations_delete_button\": \"Redefinir o acesso ao tópico\",\n    \"prefs_reservations_table\": \"Tabela de tópicos reservados\",\n    \"prefs_reservations_table_topic_header\": \"Tópico\",\n    \"prefs_reservations_table_access_header\": \"Acesso\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Somente eu posso publicar e me inscrever\",\n    \"prefs_reservations_table_everyone_read_only\": \"Posso publicar e me inscrever, todos podem se inscrever\",\n    \"prefs_reservations_table_everyone_write_only\": \"Posso publicar e me inscrever, todos podem publicar\",\n    \"prefs_reservations_table_everyone_read_write\": \"Todos podem publicar e se inscreverem\",\n    \"prefs_reservations_table_not_subscribed\": \"Não inscrito\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Clique para se inscrever\",\n    \"prefs_reservations_dialog_title_add\": \"Reservar tópico\",\n    \"prefs_reservations_dialog_title_edit\": \"Editar tópico reservado\",\n    \"prefs_reservations_dialog_title_delete\": \"Apagar reserva de tópico\",\n    \"prefs_reservations_dialog_description\": \"A reserva de um tópico lhe dá propriedade sobre ele e permite definir permissões de acesso para outros utilizadores sobre o tópico.\",\n    \"prefs_reservations_dialog_topic_label\": \"Tópico\",\n    \"prefs_reservations_dialog_access_label\": \"Acesso\",\n    \"reservation_delete_dialog_description\": \"A remoção de uma reserva abre mão da propriedade sobre o tópico e permite que outros o reservem. Pode manter ou apagar as mensagens e os anexos existentes.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Manter mensagens e anexos em cache\",\n    \"reservation_delete_dialog_action_keep_description\": \"As mensagens e os anexos armazenados em cache no servidor ficarão visíveis publicamente para as pessoas que souberem o nome do tópico.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Apagar mensagens e anexos armazenados em cache\",\n    \"reservation_delete_dialog_action_delete_description\": \"As mensagens e os anexos armazenados em cache serão apagados permanentemente. Esta ação não pode ser desfeita.\",\n    \"reservation_delete_dialog_submit_button\": \"Apagar reserva\",\n    \"error_boundary_button_reload_ntfy\": \"Recarregar ntfy\",\n    \"web_push_subscription_expiring_title\": \"As notificações serão pausadas\",\n    \"web_push_subscription_expiring_body\": \"Abra o ntfy para continuar recebendo notificações\",\n    \"web_push_unknown_notification_title\": \"Notificação desconhecida recebida do servidor\",\n    \"web_push_unknown_notification_body\": \"Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web\"\n}\n"
  },
  {
    "path": "web/public/static/langs/pt_BR.json",
    "content": "{\n    \"notifications_attachment_open_button\": \"Abrir anexo\",\n    \"action_bar_clear_notifications\": \"Limpar todas as notificações\",\n    \"action_bar_unsubscribe\": \"Desinscrever\",\n    \"message_bar_type_message\": \"Escreva uma mensagem aqui\",\n    \"message_bar_error_publishing\": \"Erro ao publicar notificação\",\n    \"nav_button_all_notifications\": \"Todas notificações\",\n    \"nav_button_settings\": \"Configurações\",\n    \"nav_button_subscribe\": \"Inscrever no tópico\",\n    \"alert_notification_permission_required_title\": \"Notificações estão desativadas\",\n    \"alert_notification_permission_required_description\": \"Conceder permissão ao seu navegador para mostrar notificações\",\n    \"alert_notification_permission_required_button\": \"Conceder agora\",\n    \"alert_not_supported_title\": \"Notificações não são suportadas\",\n    \"alert_not_supported_description\": \"Notificações não são suportadas pelo seu navegador\",\n    \"notifications_copied_to_clipboard\": \"Copiado para a área de transferência\",\n    \"notifications_tags\": \"Etiquetas\",\n    \"notifications_attachment_copy_url_title\": \"Copiar URL do anexo para a área de transferência\",\n    \"notifications_click_copy_url_title\": \"Copiar URL do link para a área de transferência\",\n    \"notifications_click_copy_url_button\": \"Copiar link\",\n    \"notifications_click_open_title\": \"Ir para {{url}}\",\n    \"notifications_click_open_button\": \"Abrir link\",\n    \"notifications_none_for_topic_title\": \"Você ainda não recebeu nenhuma notificação para esse tópico.\",\n    \"notifications_none_for_topic_description\": \"Para enviar notificações para esse tópico, basta usar os métodos PUT ou POST na URL do tópico.\",\n    \"notifications_none_for_any_title\": \"Você ainda não recebeu nenhuma notificação.\",\n    \"notifications_none_for_any_description\": \"Para enviar notificações a um tópico, basta usar os métodos PUT ou POST para o URL do tópico. Aqui um exemplo usando um dos seus tópicos.\",\n    \"notifications_no_subscriptions_title\": \"Parece que você não tem nenhuma inscrição ainda.\",\n    \"notifications_no_subscriptions_description\": \"Clique no link \\\"{{linktext}}\\\" para criar ou inscrever em um tópico. Depois disso, poderá enviar mensagens via PUT ou POST e você receberá notificações aqui.\",\n    \"action_bar_settings\": \"Configurações\",\n    \"action_bar_send_test_notification\": \"Enviar notificação de teste\",\n    \"nav_button_documentation\": \"Documentação\",\n    \"nav_button_publish_message\": \"Publicar notificação\",\n    \"nav_topics_title\": \"Tópicos inscritos\",\n    \"notifications_attachment_open_title\": \"Ir para {{url}}\",\n    \"notifications_attachment_link_expires\": \"link expira em {{date}}\",\n    \"notifications_attachment_copy_url_button\": \"Copiar URL\",\n    \"notifications_attachment_link_expired\": \"link para transferência expirado\",\n    \"notifications_example\": \"Exemplo\",\n    \"notifications_more_details\": \"Para mais informações, confira <websiteLink>site</websiteLink> ou <docsLink>documentação</docsLink>.\",\n    \"notifications_loading\": \"Carregando notificações…\",\n    \"subscribe_dialog_error_user_anonymous\": \"anônimo\",\n    \"prefs_notifications_delete_after_three_hours\": \"Após três horas\",\n    \"prefs_notifications_delete_after_one_day\": \"Após um dia\",\n    \"prefs_notifications_delete_after_one_week\": \"Após uma semana\",\n    \"prefs_notifications_delete_after_one_month\": \"Após um mês\",\n    \"notifications_actions_not_supported\": \"Ação não suportada no aplicativo web\",\n    \"notifications_actions_http_request_title\": \"Enviar HTTP {{method}} para {{url}}\",\n    \"notifications_actions_open_url_title\": \"Ir para {{url}}\",\n    \"publish_dialog_title_topic\": \"Publicar em {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publicar notificação\",\n    \"publish_dialog_progress_uploading\": \"Enviando …\",\n    \"publish_dialog_progress_uploading_detail\": \"Fazendo upload de {{loaded}}/{{total}} ({{percent}}%)…\",\n    \"publish_dialog_message_published\": \"Notificação publicada\",\n    \"publish_dialog_attachment_limits_file_reached\": \"excede o limite de arquivo {{fileSizeLimit}}\",\n    \"publish_dialog_priority_min\": \"Prioridade mínima\",\n    \"publish_dialog_priority_low\": \"Baixa prioridade\",\n    \"publish_dialog_priority_default\": \"Prioridade padrão\",\n    \"publish_dialog_base_url_label\": \"URL de serviço\",\n    \"publish_dialog_base_url_placeholder\": \"URL de serviço, por exemplo https://example.com\",\n    \"publish_dialog_topic_label\": \"Nome do tópico\",\n    \"publish_dialog_topic_placeholder\": \"Nome do tópico, por exemplo, phil_alerts\",\n    \"publish_dialog_title_label\": \"Título\",\n    \"publish_dialog_title_placeholder\": \"Título da notificação, por exemplo Alerta de espaço em disco\",\n    \"publish_dialog_message_label\": \"Mensagem\",\n    \"publish_dialog_message_placeholder\": \"Digite uma mensagem aqui\",\n    \"publish_dialog_tags_label\": \"Etiquetas\",\n    \"publish_dialog_tags_placeholder\": \"Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioridade\",\n    \"publish_dialog_click_label\": \"Clique em URL\",\n    \"publish_dialog_click_placeholder\": \"URL que é aberto quando a notificação é clicada\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Email para encaminhar a notificação, por exemplo phil@example.com\",\n    \"publish_dialog_filename_label\": \"Nome do arquivo\",\n    \"publish_dialog_filename_placeholder\": \"Nome do arquivo anexado\",\n    \"publish_dialog_delay_label\": \"Atraso\",\n    \"publish_dialog_delay_placeholder\": \"Atraso na entrega, por exemplo {{{unixTimestamp}}, {{relativeTime}}, ou \\\"{{naturalLanguage}}\\\" (apenas em inglês)\",\n    \"publish_dialog_other_features\": \"Outros recursos:\",\n    \"publish_dialog_chip_click_label\": \"Clique em URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Anexar arquivo local\",\n    \"publish_dialog_chip_delay_label\": \"Atraso na entrega\",\n    \"publish_dialog_chip_topic_label\": \"Alterar tópico\",\n    \"publish_dialog_button_cancel_sending\": \"Cancelar o envio\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nome do arquivo anexado\",\n    \"publish_dialog_drop_file_here\": \"Solte o arquivo aqui\",\n    \"emoji_picker_search_placeholder\": \"Pesquisar emoji\",\n    \"subscribe_dialog_subscribe_title\": \"Inscrever no tópico\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Usar outro servidor\",\n    \"subscribe_dialog_subscribe_description\": \"Os tópicos podem não ser protegidos por senha, então escolha um nome que não seja fácil de adivinhar. Uma vez inscrito, você pode PUT/POST notificações.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nome do tópico, por exemplo phil_alerts\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Cancelar\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Inscrever\",\n    \"prefs_notifications_min_priority_description_max\": \"Mostrar notificações se prioridade for 5 (máxima)\",\n    \"prefs_notifications_min_priority_any\": \"Qualquer prioridade\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Baixa prioridade e acima\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioridade padrão e acima\",\n    \"subscribe_dialog_login_password_label\": \"Senha\",\n    \"common_back\": \"Voltar\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Alta prioridade e acima\",\n    \"prefs_notifications_min_priority_max_only\": \"Apenas prioridade máxima\",\n    \"prefs_notifications_delete_after_title\": \"Apagar notificações\",\n    \"prefs_notifications_delete_after_never\": \"Nunca\",\n    \"prefs_notifications_delete_after_never_description\": \"Notificações nunca serão auto excluídas\",\n    \"prefs_users_description\": \"Adicionar/remover usuários em seus tópicos protegidos. Note que o usuário e senha são salvos no armazenamento local do navegador.\",\n    \"prefs_users_add_button\": \"Adicionar usuário\",\n    \"prefs_users_table_user_header\": \"Usuário\",\n    \"prefs_users_table_base_url_header\": \"URL de serviço\",\n    \"prefs_users_dialog_title_add\": \"Adicionar usuário\",\n    \"prefs_users_dialog_title_edit\": \"Editar usuário\",\n    \"prefs_users_dialog_base_url_label\": \"URL de serviço, exemplo https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Usuário, por exemplo phil\",\n    \"prefs_users_dialog_password_label\": \"Senha\",\n    \"common_cancel\": \"Cancelar\",\n    \"common_add\": \"Adicionar\",\n    \"common_save\": \"Salvar\",\n    \"prefs_appearance_title\": \"Aparência\",\n    \"prefs_appearance_language_title\": \"LInguagem\",\n    \"priority_min\": \"minima\",\n    \"priority_low\": \"baixa\",\n    \"priority_default\": \"padrão\",\n    \"priority_high\": \"alta\",\n    \"priority_max\": \"máxima\",\n    \"error_boundary_title\": \"Ah não, ntfy parou de funcionar\",\n    \"error_boundary_gathering_info\": \"Coletar mais informações …\",\n    \"error_boundary_description\": \"Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"error_boundary_button_copy_stack_trace\": \"Copiar rastreamento de pilha\",\n    \"error_boundary_stack_trace\": \"Rastreamento de pilha\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"excede a cota, {{remainingBytes}} restantes\",\n    \"publish_dialog_priority_high\": \"Alta prioridade\",\n    \"publish_dialog_priority_max\": \"Prioridade máxima\",\n    \"publish_dialog_button_send\": \"Enviar\",\n    \"publish_dialog_attached_file_title\": \"Arquivo anexado:\",\n    \"publish_dialog_attach_label\": \"URL de anexo\",\n    \"publish_dialog_chip_attach_url_label\": \"Anexar arquivo por URL\",\n    \"publish_dialog_attach_placeholder\": \"Anexar arquivo por URL, por exemplo, https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_chip_email_label\": \"Encaminhar para email\",\n    \"publish_dialog_checkbox_publish_another\": \"Publicar outro\",\n    \"publish_dialog_details_examples_description\": \"Para obter exemplos e uma descrição detalhada de todos os recursos de envio, consulte a <docsLink>documentação</docsLink>.\",\n    \"publish_dialog_button_cancel\": \"Cancelar\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Notificações são automaticamente excluídas após um dia\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Notificações são automaticamente excluídas após um mês\",\n    \"prefs_users_title\": \"Gerenciar usuários\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Usuário {{username}} não autorizado\",\n    \"prefs_notifications_title\": \"Notificações\",\n    \"prefs_notifications_sound_no_sound\": \"Sem som\",\n    \"subscribe_dialog_login_title\": \"Login necessário\",\n    \"prefs_notifications_sound_title\": \"Som de notificações\",\n    \"prefs_notifications_min_priority_title\": \"Mínima prioridade\",\n    \"prefs_notifications_min_priority_description_any\": \"Mostrando todas as notificações, independente da prioridade\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Notificações são automaticamente excluídas após uma semana\",\n    \"subscribe_dialog_login_description\": \"Esse tópico é protegido por senha. Por favor digite o nome de usuário e senha para inscrever.\",\n    \"subscribe_dialog_login_username_label\": \"Nome, por exemplo phil\",\n    \"subscribe_dialog_login_button_login\": \"Login\",\n    \"prefs_notifications_sound_description_none\": \"Notificações não reproduzem nenhum som quando chegam\",\n    \"prefs_notifications_sound_description_some\": \"Notificações reproduzem som {{sound}} quando chegam\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Notificações são automaticamente excluídas após três horas\",\n    \"publish_dialog_attach_reset\": \"Remover URL do anexo\",\n    \"publish_dialog_emoji_picker_show\": \"Escolher emoji\",\n    \"publish_dialog_attached_file_remove\": \"Remover arquivo anexado\",\n    \"emoji_picker_search_clear\": \"Limpar\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL de subscrição\",\n    \"notifications_list\": \"Lista de notificações\",\n    \"message_bar_show_dialog\": \"Mostrar caixa de publicação\",\n    \"publish_dialog_topic_reset\": \"Resetar tópico\",\n    \"publish_dialog_delay_reset\": \"Remover entrega adiada da notificação\",\n    \"nav_button_connecting\": \"Conectando\",\n    \"publish_dialog_email_reset\": \"Remover encaminhar email\",\n    \"prefs_notifications_sound_play\": \"Reproduzir som selecionado\",\n    \"action_bar_show_menu\": \"Mostrar menu\",\n    \"action_bar_toggle_mute\": \"Habilita/Desabilita notificações\",\n    \"action_bar_toggle_action_menu\": \"Abrir/fechar menu de ação\",\n    \"action_bar_logo_alt\": \"nfty logo\",\n    \"message_bar_publish\": \"Publicar mensagem\",\n    \"nav_button_muted\": \"Notificações desabilitadas\",\n    \"notifications_list_item\": \"Notificação\",\n    \"notifications_mark_read\": \"Marcar como lido\",\n    \"notifications_delete\": \"Excluir\",\n    \"notifications_priority_x\": \"Prioridade {{priority}}\",\n    \"notifications_new_indicator\": \"Nova notificação\",\n    \"notifications_attachment_image\": \"Imagem anexada\",\n    \"notifications_attachment_file_image\": \"Arquivo de imagem\",\n    \"notifications_attachment_file_video\": \"Arquivo de vídeo\",\n    \"notifications_attachment_file_audio\": \"Arquivo de áudio\",\n    \"notifications_attachment_file_app\": \"Arquivo apk android\",\n    \"notifications_attachment_file_document\": \"Outros documentos\",\n    \"publish_dialog_click_reset\": \"Remover URL clicável\",\n    \"prefs_users_table\": \"Tabela de usuários\",\n    \"prefs_users_edit_button\": \"Editar usuário\",\n    \"prefs_users_delete_button\": \"Excluir usuário\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Navegação anônima não suportada\",\n    \"error_boundary_unsupported_indexeddb_description\": \"O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.\",\n    \"action_bar_reservation_add\": \"Reservar tópico\",\n    \"action_bar_reservation_edit\": \"Mudar reserva\",\n    \"signup_disabled\": \"O registro está desativado\",\n    \"signup_error_username_taken\": \"O nome de usuário {{username}} já está em uso\",\n    \"signup_error_creation_limit_reached\": \"Limite de criação de contas atingido\",\n    \"action_bar_reservation_delete\": \"Remover reserva\",\n    \"action_bar_account\": \"Conta\",\n    \"action_bar_change_display_name\": \"Mudar nome de exibição\",\n    \"common_copy_to_clipboard\": \"Copiar para a Área de Transferência\",\n    \"login_link_signup\": \"Registrar\",\n    \"login_title\": \"Entrar na sua conta ntfy\",\n    \"login_form_button_submit\": \"Entrar\",\n    \"login_disabled\": \"Login está desabilitado\",\n    \"action_bar_reservation_limit_reached\": \"Limite atingido\",\n    \"action_bar_profile_title\": \"Perfil\",\n    \"action_bar_profile_settings\": \"Configurações\",\n    \"action_bar_profile_logout\": \"Sair\",\n    \"action_bar_sign_in\": \"Entrar\",\n    \"action_bar_sign_up\": \"Registrar\",\n    \"nav_button_account\": \"Conta\",\n    \"signup_title\": \"Criar uma conta ntfy\",\n    \"signup_form_username\": \"Nome de usuário\",\n    \"signup_form_password\": \"Senha\",\n    \"signup_form_confirm_password\": \"Confirmar senha\",\n    \"signup_form_button_submit\": \"Criar conta\",\n    \"account_basics_phone_numbers_title\": \"Telefones\",\n    \"signup_form_toggle_password_visibility\": \"Alterar visibilidade da senha\",\n    \"signup_already_have_account\": \"Já tem uma conta? Entre!\",\n    \"nav_upgrade_banner_label\": \"Atualizar para ntfy Pro\",\n    \"account_basics_phone_numbers_dialog_description\": \"Para usar o recurso de notificação de chamada, é necessários adicionar e verificar pelo menos um número de telefone. A verificação pode ser feita por SMS ou chamada telefônica.\",\n    \"account_basics_phone_numbers_description\": \"Para notificações de chamada telefônica\",\n    \"account_basics_tier_interval_monthly\": \"mensal\",\n    \"account_basics_tier_canceled_subscription\": \"Sua assinatura foi cancelada e será rebaixada para uma conta gratuita em {{date}}.\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Senha incorreta\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Número de telefone\",\n    \"account_basics_password_dialog_button_submit\": \"Mudar senha\",\n    \"reserve_dialog_checkbox_label\": \"Guardar tópico e configurar acesso\",\n    \"account_basics_username_title\": \"Nome de usuário\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Confirmar código\",\n    \"account_usage_attachment_storage_title\": \"Armazenamento de anexos\",\n    \"account_usage_messages_title\": \"Mensagens publicadas\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Enviar SMS\",\n    \"account_basics_tier_change_button\": \"Mudar\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(com nível {{tier}})\",\n    \"account_basics_title\": \"Conta\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Ainda não há números de telefone\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Gerar nome\",\n    \"display_name_dialog_description\": \"Defina um nome alternativo para o tópico exibido na lista de inscrições. Isso pode ajudar a identificar mais facilmente tópicos com nomes complicados.\",\n    \"publish_dialog_chip_call_label\": \"Chamada telefônica\",\n    \"account_basics_phone_numbers_dialog_title\": \"Adicionar número de telefone\",\n    \"account_basics_username_admin_tooltip\": \"Você é Administrador\",\n    \"account_usage_reservations_none\": \"Nenhum tópico reservado para esta conta\",\n    \"account_usage_title\": \"Uso\",\n    \"account_basics_tier_upgrade_button\": \"Atualizar para Pro\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Tópico já reservado\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(sem nível)\",\n    \"account_basics_tier_payment_overdue\": \"O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.\",\n    \"account_basics_tier_description\": \"Nível de poder da sua conta\",\n    \"account_basics_tier_free\": \"Grátis\",\n    \"account_basics_tier_admin\": \"Administrador\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Nenhum número de telefone verificado\",\n    \"account_basics_password_description\": \"Mudar a senha da sua conta\",\n    \"publish_dialog_call_label\": \"Chamada telefônica\",\n    \"account_usage_calls_title\": \"Chamadas de telefone feitas\",\n    \"account_basics_tier_basic\": \"Básico\",\n    \"alert_not_supported_context_description\": \"Notificações são suportadas somente por HTTPS. Essa é uma limitação da <mdnLink>Notifications API</mdnLink>.\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Número de telefone copiado para a área de transferência\",\n    \"account_basics_tier_title\": \"Tipo de conta\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"ex. +1222333444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"ex. 123456\",\n    \"account_basics_tier_manage_billing_button\": \"Gerenciar faturamento\",\n    \"account_basics_username_description\": \"Ei, é você ❤\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmar senha\",\n    \"account_basics_tier_interval_yearly\": \"anual\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Ligar\",\n    \"account_basics_password_title\": \"Senha\",\n    \"account_basics_password_dialog_new_password_label\": \"Nova senha\",\n    \"display_name_dialog_placeholder\": \"Nome de exibição\",\n    \"account_usage_of_limit\": \"de {{limit}}\",\n    \"account_basics_password_dialog_title\": \"Mudar senha\",\n    \"account_usage_limits_reset_daily\": \"Os limites de uso são redefinidos diariamente à meia-noite (UTC)\",\n    \"account_usage_unlimited\": \"Ilimitado\",\n    \"account_basics_password_dialog_current_password_label\": \"Senha atual\",\n    \"account_usage_reservations_title\": \"Tópicos reservados\",\n    \"account_usage_calls_none\": \"Nenhum telefonema pode ser feito com esta conta\",\n    \"display_name_dialog_title\": \"Alterar nome de exibição\",\n    \"nav_upgrade_banner_description\": \"Reserve tópicos, mais mensagens e e-mails, e anexos maiores\",\n    \"publish_dialog_call_reset\": \"Remover chamada telefônica\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Código de verificação\",\n    \"account_basics_tier_paid_until\": \"Assinatura paga até {{date}}, será renovada automaticamente\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} por arquivo, excluído após {{expiry}}\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ligar pra mim\",\n    \"publish_dialog_call_item\": \"Ligue para o número de telefone {{number}}\",\n    \"account_usage_emails_title\": \"Emails enviados\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_delete_title\": \"Deletar conta\",\n    \"account_delete_dialog_label\": \"Senha\",\n    \"account_upgrade_dialog_interval_yearly\": \"Anual\",\n    \"account_upgrade_dialog_title\": \"Alterar nível da conta\",\n    \"alert_notification_ios_install_required_description\": \"Clique no ícone Compartilhar e adicione a tela inicial para ativar notificações no iOS\",\n    \"account_delete_dialog_billing_warning\": \"Excluir sua conta também cancela imediatamente sua assinatura de cobrança. Você não terá mais acesso ao painel de faturamento.\",\n    \"account_delete_dialog_description\": \"Isso excluirá permanentemente sua conta, incluindo todos os dados armazenados no servidor. Após a exclusão, seu nome de usuário ficará indisponível por 7 dias. Se você realmente deseja prosseguir, confirme sua senha na caixa abaixo.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Prorrogação</strong>: Ao atualizar entre planos pagos, a diferença de preço será <strong>cobrada imediatamente</strong>. Ao fazer downgrade para um nível inferior, o saldo será usado para pagar futuras cobranças.\",\n    \"action_bar_mute_notifications\": \"Mutar notificações\",\n    \"action_bar_unmute_notifications\": \"Desmutar notificações\",\n    \"alert_notification_permission_denied_title\": \"Notificações estão bloqueadas\",\n    \"alert_notification_permission_denied_description\": \"Por favor, reative elas no seu navegador\",\n    \"alert_notification_ios_install_required_title\": \"Requer instalação no iOS\",\n    \"notifications_actions_failed_notification\": \"Ação mal sucedida\",\n    \"publish_dialog_checkbox_markdown\": \"Formatar como Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Notificações de outros servidores não serão recebidas quando o web app não estiver aberto\",\n    \"account_usage_basis_ip_description\": \"As estatísticas e limites de uso desta conta são baseados no seu endereço IP, portanto, podem ser compartilhados com outros usuários. Os limites mostrados acima são aproximados com base nos limites de taxa existentes.\",\n    \"account_usage_cannot_create_portal_session\": \"Não é possível abrir o portal de cobrança\",\n    \"account_delete_description\": \"Deletar sua conta permanentemente\",\n    \"account_delete_dialog_button_cancel\": \"Cancelar\",\n    \"account_delete_dialog_button_submit\": \"Deletar conta permanentemente\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mensal\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"desconto de {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"desconto de até {{discount}}%\",\n    \"account_upgrade_dialog_cancel_warning\": \"Isso <strong>cancelará sua assinatura</strong> e fará downgrade de sua conta em {{date}}. Nessa data, as reservas de tópicos, bem como as mensagens armazenadas em cache no servidor <strong>serão excluídas</strong>.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"O nível selecionado permite menos tópicos reservados do que o nível atual. Antes de alterar seu nível, <strong>exclua pelo menos uma reserva</strong>. Você pode remover reservas nas <Link>Configurações</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"O nível selecionado permite menos tópicos reservados do que o seu nível atual. Antes de mudar seu nível, <strong>por favor exclua ao menos {{count}} reservas</strong>. Você pode remover reservas nas <Link>Configurações</Link>.\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Sem tópicos reservados\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} mensagen diária\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} email diário\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} tópico reservado\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} tópicos reservados\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} emails diários\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mensagens diárias\",\n    \"account_upgrade_dialog_tier_current_label\": \"Atual\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mês\",\n    \"account_upgrade_dialog_button_cancel\": \"Cancelar\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Selecionado\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} por arquivo\",\n    \"account_tokens_table_last_access_header\": \"Último acesso\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Cancelar assinatura\",\n    \"account_tokens_table_never_expires\": \"Nunca expira\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} cobrado anualmente. Salvar {{save}}.\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Nenhuma chamada\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Atualizar assinatura\",\n    \"account_tokens_table_current_session\": \"Sessão atual do navegador\",\n    \"account_tokens_table_copied_to_clipboard\": \"Token de acesso copiado\",\n    \"account_tokens_title\": \"Tokens de Acesso\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Cadastre-se agora\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} chamadas diárias\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} chamadas telefônicas diárias\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} armazenamento total\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} por ano. Cobrado mensalmente.\",\n    \"account_upgrade_dialog_button_pay_now\": \"Pague agora para assinar\",\n    \"account_tokens_table_expires_header\": \"Expira\",\n    \"prefs_users_description_no_sync\": \"Usuários e senhas não estão sincronizados com a sua conta.\",\n    \"account_tokens_description\": \"Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que você não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Não é possível editar ou apagar o token da sessão atual\",\n    \"account_tokens_dialog_title_edit\": \"Editar token de acesso\",\n    \"account_tokens_dialog_title_delete\": \"Excluir token de acesso\",\n    \"prefs_reservations_table_everyone_read_write\": \"Todos podem publicar e se inscrever\",\n    \"prefs_reservations_table_everyone_read_only\": \"Posso publicar e me inscrever, todos podem se inscrever\",\n    \"prefs_reservations_limit_reached\": \"Você atingiu seu limite de tópicos reservados.\",\n    \"prefs_reservations_delete_button\": \"Redefinir o acesso ao tópico\",\n    \"prefs_reservations_edit_button\": \"Editar acesso ao tópico\",\n    \"prefs_reservations_table_everyone_write_only\": \"Eu posso publicar e me inscrever, todos podem publicar\",\n    \"prefs_reservations_table_not_subscribed\": \"Não inscrito\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Clique para se inscrever\",\n    \"reservation_delete_dialog_action_keep_title\": \"Manter mensagens e anexos em cache\",\n    \"account_tokens_table_label_header\": \"Rótulo\",\n    \"account_tokens_table_last_origin_tooltip\": \"Do endereço IP {{ip}}, clique para pesquisar\",\n    \"account_tokens_dialog_title_create\": \"Criar token de acesso\",\n    \"account_tokens_delete_dialog_title\": \"Excluir token de acesso\",\n    \"account_tokens_dialog_label\": \"Rótulo, por exemplo, notificações de Radarr\",\n    \"account_tokens_dialog_expires_never\": \"O token nunca expira\",\n    \"prefs_reservations_dialog_title_edit\": \"Editar tópico reservado\",\n    \"prefs_notifications_web_push_enabled_description\": \"As notificações são recebidas mesmo quando o aplicativo Web não está em execução (via Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"As notificações são recebidas quando o aplicativo Web está em execução (via WebSocket)\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Para perguntas sobre faturamento, consulte nosso <Link>website</Link>.\",\n    \"account_tokens_table_create_token_button\": \"Criar token de acesso\",\n    \"account_tokens_dialog_button_cancel\": \"Cancelar\",\n    \"account_tokens_dialog_button_update\": \"Atualizar token\",\n    \"prefs_reservations_table\": \"Tabela de tópicos reservados\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Somente eu posso publicar e me inscrever\",\n    \"account_tokens_delete_dialog_description\": \"Antes de apagar um token de acesso, certifique-se de que nenhum aplicativo ou script o esteja usando ativamente. <strong>Esta ação não pode ser desfeita</strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Excluir token permanentemente\",\n    \"account_tokens_dialog_expires_x_hours\": \"O token expira em {{hours}} horas\",\n    \"account_tokens_dialog_expires_x_days\": \"O token expira em {{days}} dias\",\n    \"prefs_reservations_description\": \"Você pode reservar nomes de tópicos para uso pessoal aqui. A reserva de um tópico lhe dá propriedade sobre ele e permite que você defina permissões de acesso para outros usuários sobre o tópico.\",\n    \"prefs_reservations_dialog_access_label\": \"Acesso\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Para questões de cobrança, <Link>entre em contato conosco</Link> diretamente.\",\n    \"account_tokens_dialog_button_create\": \"Criar token\",\n    \"account_tokens_dialog_expires_label\": \"O token de acesso expira em\",\n    \"account_tokens_dialog_expires_unchanged\": \"Deixar a data de validade inalterada\",\n    \"prefs_notifications_web_push_title\": \"Notificações em segundo plano\",\n    \"prefs_notifications_web_push_enabled\": \"Ativado para {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Desativado\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Não é possível apagar ou editar o usuário conectado\",\n    \"prefs_appearance_theme_system\": \"Sistema (padrão)\",\n    \"prefs_appearance_theme_dark\": \"Modo escuro\",\n    \"prefs_appearance_theme_light\": \"Modo claro\",\n    \"prefs_reservations_title\": \"Tópicos reservados\",\n    \"prefs_reservations_add_button\": \"Adicionar tópico reservado\",\n    \"prefs_reservations_table_topic_header\": \"Tópico\",\n    \"prefs_reservations_table_access_header\": \"Acesso\",\n    \"prefs_reservations_dialog_title_add\": \"Reservar tópico\",\n    \"prefs_reservations_dialog_title_delete\": \"Excluir reserva de tópico\",\n    \"prefs_reservations_dialog_description\": \"A reserva de um tópico lhe dá propriedade sobre ele e permite definir permissões de acesso para outros usuários sobre o tópico.\",\n    \"prefs_reservations_dialog_topic_label\": \"Tópico\",\n    \"reservation_delete_dialog_description\": \"A remoção de uma reserva abre mão da propriedade sobre o tópico e permite que outros o reservem. Você pode manter ou excluir as mensagens e os anexos existentes.\",\n    \"reservation_delete_dialog_action_keep_description\": \"As mensagens e os anexos armazenados em cache no servidor ficarão visíveis publicamente para as pessoas que souberem o nome do tópico.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Excluir mensagens e anexos armazenados em cache\",\n    \"reservation_delete_dialog_action_delete_description\": \"As mensagens e os anexos armazenados em cache serão excluídos permanentemente. Essa ação não pode ser desfeita.\",\n    \"reservation_delete_dialog_submit_button\": \"Excluir reserva\",\n    \"error_boundary_button_reload_ntfy\": \"Recarregar ntfy\",\n    \"web_push_subscription_expiring_title\": \"As notificações serão pausadas\",\n    \"web_push_subscription_expiring_body\": \"Abra o ntfy para continuar recebendo notificações\",\n    \"web_push_unknown_notification_title\": \"Notificação desconhecida recebida do servidor\",\n    \"web_push_unknown_notification_body\": \"Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ro.json",
    "content": "{\n    \"action_bar_show_menu\": \"Afișează meniu\",\n    \"action_bar_send_test_notification\": \"Trimite notificare de probă\",\n    \"action_bar_clear_notifications\": \"Șterge toate notificările\",\n    \"action_bar_settings\": \"Setări\",\n    \"action_bar_unsubscribe\": \"Dezabonare\",\n    \"action_bar_logo_alt\": \"logo-ul ntfy\",\n    \"action_bar_toggle_mute\": \"Oprire/activare notificări\",\n    \"message_bar_type_message\": \"Scrie un mesaj aici\",\n    \"message_bar_error_publishing\": \"Eroare la publicarea notificării\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Setări\",\n    \"nav_button_settings\": \"Setări\",\n    \"nav_button_connecting\": \"conectare\",\n    \"notifications_attachment_file_video\": \"fișier video\",\n    \"publish_dialog_priority_default\": \"Prioritate default\",\n    \"publish_dialog_priority_high\": \"Prioritate înaltă\",\n    \"publish_dialog_priority_max\": \"Max. prioritate\",\n    \"publish_dialog_message_placeholder\": \"Introdu un mesaj aici\",\n    \"nav_button_subscribe\": \"Abonează-te la topic\",\n    \"nav_upgrade_banner_label\": \"Upgrade la ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Rezervă topic-uri, mai multe mesaje și email-uri, și atașamente mai mari\",\n    \"common_back\": \"Înapoi\",\n    \"nav_button_account\": \"Cont\",\n    \"nav_button_documentation\": \"Documentație\",\n    \"nav_button_publish_message\": \"Publică notificarea\",\n    \"alert_notification_permission_required_title\": \"Notificările sunt dezactivate\",\n    \"alert_notification_permission_required_button\": \"Permite acum\",\n    \"alert_not_supported_title\": \"Notificările nu sunt acceptate\",\n    \"alert_not_supported_description\": \"Notificările nu sunt acceptate în browserul tău\",\n    \"alert_notification_permission_required_description\": \"Permite browser-ului să afișeze notificări\",\n    \"notifications_list\": \"Lista de notificări\",\n    \"notifications_list_item\": \"Notificare\",\n    \"notifications_mark_read\": \"Marchează ca citit\",\n    \"notifications_delete\": \"Șterge\",\n    \"notifications_copied_to_clipboard\": \"Copiat în clipboard\",\n    \"notifications_tags\": \"Tag-uri\",\n    \"notifications_new_indicator\": \"Notificare nouă\",\n    \"notifications_attachment_image\": \"Imagine atașament\",\n    \"notifications_attachment_copy_url_title\": \"Copiază URL-ul atașamentului în clipboard\",\n    \"notifications_attachment_copy_url_button\": \"Copiază URL\",\n    \"notifications_attachment_open_title\": \"Mergi la {{url}}\",\n    \"notifications_attachment_link_expires\": \"link-ul expiră {{date}}\",\n    \"notifications_actions_not_supported\": \"Acțiune neacceptată în aplicația web\",\n    \"notifications_actions_http_request_title\": \"Trimite {{method}} HTTP la {{url}}\",\n    \"notifications_none_for_topic_title\": \"N-ați primit încă notificări pe acest subiect.\",\n    \"notifications_none_for_topic_description\": \"Pentru a trimite notificări pe acest subiect, setați PUT sau POST pe URL-ul subiectului.\",\n    \"notifications_none_for_any_title\": \"N-ați primit nici o notificare.\",\n    \"notifications_none_for_any_description\": \"Pentru a trimite notificări pe acest subiect, setează PUT sau POST pe URL-ul subiectului. Uite un exemplu cu unul dintre subiectele tale.\",\n    \"notifications_no_subscriptions_title\": \"Se pare că nu ai nici o înscriere.\",\n    \"notifications_no_subscriptions_description\": \"Click pe link-ul \\\"{{linktext}}\\\" ca sa creezi o înscriere la un subiect. După aceea, poți trimite mesaje via PUT sau POST și vei primi notificări aici.\",\n    \"notifications_example\": \"Exemplu\",\n    \"notifications_more_details\": \"Pentru mai multe informații, vezi <websiteLink>site-ul web</websiteLink> sau <docsLink>documentația</docsLink>.\",\n    \"display_name_dialog_title\": \"Schimbă numele afișat\",\n    \"display_name_dialog_description\": \"Setează un nume alternativ pentru subiect care este afișat în lista de înscrieri. Va ajuta la ușurarea identificării subiectelor cu nume complexe.\",\n    \"display_name_dialog_placeholder\": \"Nume afișat\",\n    \"reserve_dialog_checkbox_label\": \"Rezervă subiectul și configurează accesul\",\n    \"publish_dialog_progress_uploading\": \"Încărcare…\",\n    \"publish_dialog_progress_uploading_detail\": \"Încărcare {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Notificare publicată\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"depășește {{fileSizeLimit}} limita fișierului și cota, {{remainingBytes}} mai rămân\",\n    \"publish_dialog_attachment_limits_file_reached\": \"depășește {{fileSizeLimit}} limita fișierului\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"depășește cota, {{remainingBytes}} mai rămân\",\n    \"publish_dialog_priority_min\": \"Min. prioritate\",\n    \"publish_dialog_base_url_label\": \"URL serviciu\",\n    \"publish_dialog_base_url_placeholder\": \"URL serviciu, ex: https://example.com\",\n    \"publish_dialog_topic_label\": \"Nume subiect\",\n    \"publish_dialog_topic_placeholder\": \"Nume subiect, ex: alerte_phil\",\n    \"publish_dialog_topic_reset\": \"Resetare subiect\",\n    \"publish_dialog_title_label\": \"Titlu\",\n    \"publish_dialog_title_placeholder\": \"Titlu notificare, ex: Alerta spațiu disc\",\n    \"publish_dialog_message_label\": \"Mesaj\",\n    \"publish_dialog_tags_label\": \"Tag-uri\",\n    \"publish_dialog_tags_placeholder\": \"Lista de tag-uri separate prin virgula, ex: avertizare,srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioritate\",\n    \"publish_dialog_click_label\": \"Click URL\",\n    \"publish_dialog_click_placeholder\": \"URL deschis când notificarea este selectată\",\n    \"publish_dialog_click_reset\": \"Șterge URL selecție\",\n    \"publish_dialog_email_label\": \"E-mail\",\n    \"signup_form_confirm_password\": \"Confirmă parola\",\n    \"action_bar_account\": \"Cont\",\n    \"action_bar_change_display_name\": \"Schimbă numele afișat\",\n    \"action_bar_reservation_limit_reached\": \"Limita atinsă\",\n    \"common_cancel\": \"Anulează\",\n    \"common_save\": \"Salvează\",\n    \"common_add\": \"Adaugă\",\n    \"signup_form_password\": \"Parolă\",\n    \"publish_dialog_title_topic\": \"Publică în {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Publică notificare\",\n    \"nav_button_all_notifications\": \"Toate notificările\",\n    \"notifications_priority_x\": \"Prioritate {{priority}}\",\n    \"notifications_attachment_file_image\": \"fișier imagine\",\n    \"notifications_attachment_open_button\": \"Deschide atașament\",\n    \"notifications_attachment_file_audio\": \"fișier audio\",\n    \"notifications_actions_open_url_title\": \"Mergi la {{url}}\",\n    \"notifications_attachment_file_document\": \"alt document\",\n    \"notifications_attachment_link_expired\": \"link-ul de descărcare expirat\",\n    \"notifications_attachment_file_app\": \"fișier aplicație Android\",\n    \"notifications_click_copy_url_title\": \"Copiază URL-ul în clipboard\",\n    \"notifications_click_copy_url_button\": \"Copiază link\",\n    \"notifications_click_open_button\": \"Deschide link\",\n    \"publish_dialog_emoji_picker_show\": \"Alege un emoji\",\n    \"notifications_loading\": \"Încărcare notificări…\",\n    \"publish_dialog_priority_low\": \"Prioritate joasă\",\n    \"signup_form_username\": \"Utilizator\",\n    \"signup_form_button_submit\": \"Înregistrare\",\n    \"common_copy_to_clipboard\": \"Copiază\",\n    \"signup_form_toggle_password_visibility\": \"Schimbă vizibilitatea parolei\",\n    \"signup_title\": \"Crează un cont ntfy\",\n    \"signup_already_have_account\": \"Deja ai un cont? Autentifică-te!\",\n    \"login_disabled\": \"Autentificarea este dezactivată\",\n    \"signup_error_creation_limit_reached\": \"S-a atins limita de conturi\",\n    \"action_bar_toggle_action_menu\": \"Deschide/Închide meniul de acțiuni\",\n    \"action_bar_sign_up\": \"Înscriere\",\n    \"message_bar_publish\": \"Publică mesajul\",\n    \"login_link_signup\": \"Înscrie-te\",\n    \"action_bar_sign_in\": \"Autentificare\",\n    \"action_bar_reservation_edit\": \"Schimbă rezervarea\",\n    \"action_bar_reservation_delete\": \"Șterge rezervarea\",\n    \"login_form_button_submit\": \"Autentifică-te\",\n    \"signup_disabled\": \"Înscrierea este dezactivată\",\n    \"action_bar_profile_logout\": \"Ieșire\",\n    \"message_bar_show_dialog\": \"Arată dialogul de publicare\",\n    \"signup_error_username_taken\": \"Numele de utilizator {{username}} este deja folosit\",\n    \"login_title\": \"Autentifică-te în contul ntfy\",\n    \"action_bar_reservation_add\": \"Rezervă topicul\",\n    \"action_bar_mute_notifications\": \"Oprește notificările\",\n    \"action_bar_unmute_notifications\": \"Pornește notificările\",\n    \"nav_topics_title\": \"Subiecte abonate\",\n    \"publish_dialog_chip_attach_url_label\": \"Atașează fișier prin URL\",\n    \"publish_dialog_call_label\": \"Apel telefonic\",\n    \"publish_dialog_button_cancel_sending\": \"Anulează trimiterea\",\n    \"subscribe_dialog_subscribe_title\": \"Abonează-te la subiect\",\n    \"subscribe_dialog_login_password_label\": \"Parolă\",\n    \"subscribe_dialog_login_button_login\": \"Autentificare\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Utilizatorul {{username}} nu este autorizat\",\n    \"account_basics_title\": \"Cont\",\n    \"account_basics_username_title\": \"Nume de utilizator\",\n    \"account_basics_username_description\": \"Hei, ești tu ❤\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Subiectul este deja rezervat\",\n    \"publish_dialog_attached_file_title\": \"Fișier atașat:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Nume fișier atașat\",\n    \"publish_dialog_attached_file_remove\": \"Elimină fișierul atașat\",\n    \"emoji_picker_search_placeholder\": \"Caută emoji\",\n    \"nav_button_muted\": \"Notificări dezactivate\",\n    \"alert_notification_permission_denied_title\": \"Notificările sunt blocate\",\n    \"alert_notification_ios_install_required_description\": \"Apasă pe butonul Partajare și Adăugați la ecranul principal pentru a porni notificările pe iOS\",\n    \"alert_notification_ios_install_required_title\": \"Instalare iOS necesară\",\n    \"alert_notification_permission_denied_description\": \"Repornește-le în browserul tău\",\n    \"alert_not_supported_context_description\": \"Notificările sunt acceptate doar prin HTTPS. Aceasta este o limitare a <mdnLink>API-ului de notificări</mdnLink>.\",\n    \"notifications_actions_failed_notification\": \"Acțiune nereușită\",\n    \"publish_dialog_email_placeholder\": \"Adresă către care se va redirecționa notificarea, ex. phil@example.com\",\n    \"publish_dialog_email_reset\": \"Șterge redirecționare email\",\n    \"publish_dialog_call_item\": \"Apelează numărul de telefon {{number}}\",\n    \"publish_dialog_attach_label\": \"URL atașament\",\n    \"publish_dialog_attach_placeholder\": \"Atașează fișier prin URL, ex. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Șterge atașament URL\",\n    \"publish_dialog_filename_label\": \"Nume fișier\",\n    \"publish_dialog_filename_placeholder\": \"Nume fișier atașament\",\n    \"publish_dialog_delay_label\": \"Întârziere\",\n    \"publish_dialog_call_reset\": \"Șterge apel telefonic\",\n    \"publish_dialog_delay_placeholder\": \"Întârzie livrarea, ex. {{unixTimestamp}}, {{relativeTime}}, sau \\\"{{naturalLanguage}}\\\" (doar engleză)\",\n    \"publish_dialog_delay_reset\": \"Șterge livrare întârziată\",\n    \"publish_dialog_other_features\": \"Alte funcționalități:\",\n    \"publish_dialog_chip_click_label\": \"Accesează URL-ul\",\n    \"publish_dialog_chip_email_label\": \"Redirecționează către email\",\n    \"publish_dialog_chip_call_label\": \"Apel telefonic\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Nu există numere de telefon verificate\",\n    \"publish_dialog_chip_attach_file_label\": \"Atașează fișier local\",\n    \"publish_dialog_chip_delay_label\": \"Întârziere livrare\",\n    \"publish_dialog_chip_topic_label\": \"Schimbă subiectul\",\n    \"publish_dialog_details_examples_description\": \"Pentru exemple și o descriere detaliată a tuturor funcțiilor de trimitere, vă rugăm să consultați <docsLink>documentația</docsLink>.\",\n    \"publish_dialog_button_cancel\": \"Anulează\",\n    \"publish_dialog_button_send\": \"Trimite\",\n    \"publish_dialog_checkbox_markdown\": \"Formatează ca Markdown\",\n    \"publish_dialog_checkbox_publish_another\": \"Publică altul\",\n    \"publish_dialog_drop_file_here\": \"Trage fișierul aici\",\n    \"emoji_picker_search_clear\": \"Șterge căutarea\",\n    \"subscribe_dialog_subscribe_description\": \"Subiectele nu pot fi protejate prin parolă, așa că alege un nume care să nu fie ușor de ghicit. Odată abonat, poți utiliza metodele PUT/POST pentru a trimite notificări.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Nume subiect, de exemplu, phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Foloseșste alt server\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Notificările de la alte servere nu vor fi primite atunci când aplicația web nu este deschisă\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL serviciu\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generează nume\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Anulează\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Abonează-te\",\n    \"subscribe_dialog_login_title\": \"Autentificare necesară\",\n    \"subscribe_dialog_login_description\": \"Acest subiect este protejat prin parolă. Vă rugăm să introduceți numele de utilizator și parola pentru a vă abona.\",\n    \"subscribe_dialog_login_username_label\": \"Nume de utilizator, de exemplu, phil\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonim\",\n    \"account_basics_tier_interval_monthly\": \"lunar\",\n    \"account_basics_password_dialog_title\": \"Schimbă parola\",\n    \"account_basics_password_dialog_current_password_label\": \"Parola actuală\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Numărul de telefon a fost copiat\",\n    \"account_basics_username_admin_tooltip\": \"Sunteți administrator\",\n    \"account_basics_tier_paid_until\": \"Abonamentul este plătit până la {{date}}, și se va reînnoi automat\",\n    \"account_basics_tier_payment_overdue\": \"Plata dvs. este restantă. Actualizați metoda de plată sau contul dvs. va fi retrogradat în curând.\",\n    \"account_basics_tier_interval_yearly\": \"anual\",\n    \"account_basics_tier_upgrade_button\": \"Upgrade la Pro\",\n    \"account_basics_phone_numbers_title\": \"Numere de telefon\",\n    \"account_basics_password_description\": \"Schimbă parola contului\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Confirmă parola\",\n    \"account_basics_password_dialog_button_submit\": \"Schimbă parola\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Parola este incorectă\",\n    \"account_basics_phone_numbers_dialog_description\": \"Pentru a folosi funcția de notificare prin apel, trebuie să adăugați și să verificați cel puțin un număr de telefon. Verificare poate fi făcută prin SMS sau apel vocal.\",\n    \"account_basics_phone_numbers_description\": \"Pentru notificări prin apel\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Trimite SMS\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Încă nu există numere de telefon\",\n    \"account_basics_phone_numbers_dialog_title\": \"Adaugă număr de telefon\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Număr de telefon\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"e.x. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Sună-mă\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Cod de verificare\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"e.x. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Confirmă codul\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Apel\",\n    \"account_usage_title\": \"Utilizare\",\n    \"account_usage_unlimited\": \"Nelimitat\",\n    \"account_usage_limits_reset_daily\": \"Limitele de utilizare sunt resetate zilnic la miezul nopții (UTC)\",\n    \"account_basics_tier_title\": \"Tip de cont\",\n    \"account_usage_of_limit\": \"din {{limit}}\",\n    \"account_basics_tier_admin\": \"Administrator\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(cu nivelul {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(niciun nivel)\",\n    \"account_basics_tier_basic\": \"De bază\",\n    \"account_basics_tier_change_button\": \"Schimbă\",\n    \"account_basics_password_dialog_new_password_label\": \"Parola nouă\",\n    \"account_basics_password_title\": \"Parolă\",\n    \"account_basics_tier_description\": \"Nivelul de putere al contului\",\n    \"account_basics_tier_free\": \"Gratuit\",\n    \"account_delete_description\": \"Șterge definitiv contul tău\",\n    \"account_usage_messages_title\": \"Mesaje publicate\",\n    \"account_basics_tier_manage_billing_button\": \"Gestionare facturare\",\n    \"account_usage_emails_title\": \"Emailuri trimise\",\n    \"account_usage_calls_title\": \"Apeluri telefonice efectuate\",\n    \"account_usage_calls_none\": \"Nu se pot efectua apeluri telefonice cu acest cont\",\n    \"account_usage_reservations_title\": \"Subiecte rezervate\",\n    \"account_usage_cannot_create_portal_session\": \"Nu s-a putut deschide portalul de facturare\",\n    \"account_delete_title\": \"Șterge contul\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} per fișier, șters după {{expiry}}\",\n    \"account_usage_attachment_storage_title\": \"Stocare atașamente\",\n    \"account_usage_basis_ip_description\": \"Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.\",\n    \"account_usage_reservations_none\": \"Nu există subiecte rezervate pentru acest cont\",\n    \"account_basics_tier_canceled_subscription\": \"Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}.\",\n    \"account_delete_dialog_label\": \"Parolă\",\n    \"account_delete_dialog_button_cancel\": \"Anulează\",\n    \"account_delete_dialog_button_submit\": \"Șterge permanent contul\",\n    \"account_delete_dialog_billing_warning\": \"Ștergerea contului tău anulează imediat și abonamentul de facturare. Nu vei mai avea acces la tabloul de bord pentru facturare.\",\n    \"account_upgrade_dialog_title\": \"Schimbă nivelul contului\",\n    \"account_upgrade_dialog_interval_monthly\": \"Lunar\",\n    \"account_upgrade_dialog_interval_yearly\": \"Anual\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"economisești {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"economisești până la {{discount}}%\",\n    \"prefs_notifications_title\": \"Notificări\",\n    \"prefs_notifications_sound_description_none\": \"Notificările nu redau niciun sunet atunci când sosesc\",\n    \"prefs_notifications_sound_description_some\": \"Notificările redau sunetul {{sound}} atunci când sosesc\",\n    \"prefs_notifications_min_priority_description_any\": \"Se afișează toate notificările, indiferent de prioritate\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Afișează notificările dacă prioritatea este {{number}} ({{name}}) sau mai mare\",\n    \"prefs_notifications_min_priority_description_max\": \"Afișează notificări dacă prioritatea este 5 (maxim)\",\n    \"prefs_notifications_delete_after_title\": \"Șterge notificările\",\n    \"prefs_notifications_delete_after_never_description\": \"Notificările nu sunt niciodată șterse automat\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Notificările sunt șterse automat după trei ore\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Notificările sunt șterse automat după o zi\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Notificările sunt șterse automat după o săptămână\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Notificările sunt șterse automat după o lună\",\n    \"prefs_notifications_web_push_title\": \"Notificări în fundal\",\n    \"prefs_notifications_web_push_enabled_description\": \"Notificările sunt primite chiar și atunci când aplicația web nu rulează (prin Web Push)\",\n    \"web_push_subscription_expiring_title\": \"Notificările vor fi suspendate\",\n    \"web_push_subscription_expiring_body\": \"Deschide ntfy pentru a continua să primești notificări\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"subiect rezervat {{reservations}}\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} subiecte rezervate\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Nu există subiecte rezervate\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} mesaj zilnic\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} mesaje zilnice\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} e-mail zilnic\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} e-mailuri zilnice\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} apeluri telefonice zilnice\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} apeluri telefonice zilnice\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Fără apeluri telefonice\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per fișier\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} stocare totală\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"lună\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} pe an. Facturat lunar.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Selectat\",\n    \"account_upgrade_dialog_tier_current_label\": \"Actual\",\n    \"account_upgrade_dialog_button_cancel\": \"Anulează\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Înscrie-te acum\",\n    \"account_upgrade_dialog_button_pay_now\": \"Plătește acum și abonează-te\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Anulează abonamentul\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_table_label_header\": \"Etichetă\",\n    \"account_tokens_table_last_access_header\": \"Ultimul acces\",\n    \"account_tokens_table_expires_header\": \"Expiră\",\n    \"account_tokens_table_never_expires\": \"Nu expiră niciodată\",\n    \"account_tokens_table_current_session\": \"Sesiunea curentă a browserului\",\n    \"account_tokens_table_copied_to_clipboard\": \"Tokenul de acces a fost copiat\",\n    \"account_tokens_table_last_origin_tooltip\": \"De la adresa IP {{ip}}, faceți clic pentru a căuta\",\n    \"account_tokens_dialog_title_create\": \"Crează un token de acces\",\n    \"account_tokens_dialog_title_edit\": \"Modifică tokenul de acces\",\n    \"account_tokens_dialog_title_delete\": \"Șterge tokenul de acces\",\n    \"account_tokens_dialog_label\": \"Etichetă, de exemplu, notificări Radarr\",\n    \"account_tokens_dialog_button_create\": \"Crează un token\",\n    \"account_tokens_dialog_button_update\": \"Actualizare token\",\n    \"account_tokens_dialog_button_cancel\": \"Anulează\",\n    \"account_tokens_dialog_expires_label\": \"Tokenul de acces expiră în\",\n    \"account_tokens_dialog_expires_never\": \"Tokenul nu expiră niciodată\",\n    \"account_tokens_delete_dialog_title\": \"Șterge tokenul de acces\",\n    \"account_tokens_delete_dialog_submit_button\": \"Șterge definitiv tokenul\",\n    \"prefs_notifications_sound_title\": \"Sunet de notificare\",\n    \"prefs_notifications_sound_no_sound\": \"Niciun sunet\",\n    \"prefs_notifications_sound_play\": \"Redă sunetul selectat\",\n    \"prefs_notifications_min_priority_title\": \"Prioritate minimă\",\n    \"prefs_notifications_min_priority_any\": \"Orice prioritate\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Prioritate scăzută și mai mare\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Prioritate implicită și mai mare\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Prioritate ridicată și mai mare\",\n    \"prefs_notifications_min_priority_max_only\": \"Numai prioritate maximă\",\n    \"prefs_notifications_delete_after_never\": \"Niciodată\",\n    \"prefs_notifications_delete_after_three_hours\": \"După trei ore\",\n    \"prefs_notifications_delete_after_one_day\": \"După o zi\",\n    \"prefs_notifications_delete_after_one_week\": \"După o săptămână\",\n    \"prefs_notifications_delete_after_one_month\": \"După o lună\",\n    \"prefs_notifications_web_push_disabled_description\": \"Notificările sunt primite atunci când aplicația web rulează (prin WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Activat pentru {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Dezactivat\",\n    \"prefs_users_title\": \"Gestionează utilizatorii\",\n    \"prefs_users_description_no_sync\": \"Utilizatorii și parolele nu sunt sincronizate cu contul tău.\",\n    \"prefs_users_table\": \"Tabel utilizatori\",\n    \"prefs_users_add_button\": \"Adăugă utilizator\",\n    \"prefs_users_edit_button\": \"Modifică utilizatorul\",\n    \"prefs_users_delete_button\": \"Șterge utilizatorul\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Nu se poate șterge sau modifica utilizatorul conectat\",\n    \"prefs_users_table_user_header\": \"Utilizator\",\n    \"prefs_users_table_base_url_header\": \"URL-ul serviciului\",\n    \"prefs_users_dialog_title_add\": \"Adaugă utilizator\",\n    \"prefs_users_dialog_title_edit\": \"Modifică utilizatorul\",\n    \"prefs_users_dialog_base_url_label\": \"URL-ul serviciului, de exemplu https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Nume de utilizator, de ex. ionel\",\n    \"prefs_users_dialog_password_label\": \"Parolă\",\n    \"prefs_appearance_title\": \"Aspect\",\n    \"prefs_appearance_language_title\": \"Limbă\",\n    \"prefs_appearance_theme_title\": \"Temă\",\n    \"prefs_appearance_theme_system\": \"Sistem (implicit)\",\n    \"prefs_appearance_theme_dark\": \"Mod întunecat\",\n    \"prefs_appearance_theme_light\": \"Mod luminos\",\n    \"prefs_reservations_title\": \"Subiecte rezervate\",\n    \"prefs_reservations_limit_reached\": \"Ai atins limita de subiecte rezervate.\",\n    \"prefs_reservations_add_button\": \"Adaugă un subiect rezervat\",\n    \"prefs_reservations_delete_button\": \"Resetează accesul la topic\",\n    \"prefs_reservations_table_access_header\": \"Acces\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Numai eu pot publica și mă pot abona\",\n    \"prefs_reservations_table_not_subscribed\": \"Neabonat\",\n    \"prefs_reservations_dialog_access_label\": \"Acces\",\n    \"reservation_delete_dialog_action_keep_title\": \"Păstrează mesajele și atașamentele în cache\",\n    \"prefs_users_description\": \"Adaugă/elimină utilizatori pentru subiectele protejate aici. Reține că numele de utilizator și parola sunt stocate în memoria locală a browserului.\",\n    \"reservation_delete_dialog_submit_button\": \"Șterge rezervarea\",\n    \"priority_min\": \"minim\",\n    \"priority_low\": \"scăzut\",\n    \"priority_default\": \"implicit\",\n    \"priority_high\": \"ridicat\",\n    \"priority_max\": \"maxim\",\n    \"error_boundary_button_reload_ntfy\": \"Reîncarcă ntfy\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ru.json",
    "content": "{\n    \"publish_dialog_priority_min\": \"Минимальный приоритет\",\n    \"action_bar_settings\": \"Настройки\",\n    \"action_bar_send_test_notification\": \"Отправить тестовое уведомление\",\n    \"action_bar_clear_notifications\": \"Удалить все уведомления\",\n    \"action_bar_unsubscribe\": \"Отписаться\",\n    \"message_bar_type_message\": \"Введите сообщение здесь\",\n    \"notifications_none_for_topic_description\": \"Чтобы отправить уведомление на данную тему, просто сделаете PUT или POST-запрос на URL-адрес этой темы.\",\n    \"notifications_none_for_any_description\": \"Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.\",\n    \"notifications_no_subscriptions_title\": \"Похоже, что у вас ещё нет подписок.\",\n    \"alert_notification_permission_required_description\": \"Предоставьте браузеру разрешение на отображение уведомлений на рабочем столе\",\n    \"notifications_no_subscriptions_description\": \"Нажмите на ссылку \\\"{{linktext}}\\\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.\",\n    \"notifications_example\": \"Пример\",\n    \"notifications_more_details\": \"Для более подробной информации, посетите <websiteLink>наш сайт</websiteLink> или <docsLink>документацию</docsLink>.\",\n    \"notifications_loading\": \"Идет загрузка уведомлений …\",\n    \"publish_dialog_title_topic\": \"Опубликовать в {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Опубликовать уведомление\",\n    \"publish_dialog_progress_uploading\": \"Идет загрузка …\",\n    \"publish_dialog_progress_uploading_detail\": \"Загружается {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Уведомление опубликовано\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"превышает максимальный размер файла {{fileSizeLimit}} и квоту, осталось {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_reached\": \"превышает максимальный размер файла {{fileSizeLimit}}\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"превышает квоту, осталось {{remainingBytes}}\",\n    \"publish_dialog_priority_low\": \"Низкий приоритет\",\n    \"publish_dialog_priority_default\": \"Стандартный приоритет\",\n    \"publish_dialog_priority_high\": \"Высокий приоритет\",\n    \"publish_dialog_priority_max\": \"Максимальный приоритет\",\n    \"publish_dialog_base_url_label\": \"URL-адрес сервиса\",\n    \"publish_dialog_base_url_placeholder\": \"URL-адрес сервиса, например https://example.com\",\n    \"publish_dialog_topic_label\": \"Название темы\",\n    \"publish_dialog_topic_placeholder\": \"Название темы, например phil_alerts\",\n    \"publish_dialog_title_label\": \"Заголовок\",\n    \"publish_dialog_title_placeholder\": \"Заголовок уведомления, например, Предупреждение о занятости диска\",\n    \"publish_dialog_message_label\": \"Сообщение\",\n    \"publish_dialog_message_placeholder\": \"Введите сообщение здесь\",\n    \"publish_dialog_tags_label\": \"Тэги\",\n    \"publish_dialog_tags_placeholder\": \"Ярлыки, разделенные запятыми, например: warning, srv1-backup\",\n    \"publish_dialog_priority_label\": \"Приоритет\",\n    \"publish_dialog_click_label\": \"Ссылка при открытии\",\n    \"publish_dialog_click_placeholder\": \"URL-адрес, который откроется при нажатии на уведомление\",\n    \"publish_dialog_email_label\": \"Электронная почта\",\n    \"message_bar_error_publishing\": \"Ошибка публикации уведомления\",\n    \"alert_not_supported_title\": \"Уведомления не поддерживаются\",\n    \"alert_not_supported_description\": \"Уведомления не поддерживаются в вашем браузере\",\n    \"notifications_copied_to_clipboard\": \"Скопировано в буфер обмена\",\n    \"notifications_attachment_open_button\": \"Открыть вложение\",\n    \"notifications_none_for_topic_title\": \"Вы ещё не получали уведомления для этой темы.\",\n    \"nav_topics_title\": \"Подписки на темы\",\n    \"nav_button_all_notifications\": \"Все уведомления\",\n    \"nav_button_settings\": \"Настройки\",\n    \"nav_button_documentation\": \"Документация\",\n    \"nav_button_publish_message\": \"Опубликовать уведомление\",\n    \"nav_button_subscribe\": \"Подписаться на тему\",\n    \"alert_notification_permission_required_button\": \"Разрешить\",\n    \"notifications_attachment_copy_url_button\": \"Скопировать URL-адрес\",\n    \"notifications_attachment_open_title\": \"Перейти на {{url}}\",\n    \"notifications_attachment_link_expired\": \"срок действия ссылки для скачивания истёк\",\n    \"notifications_click_copy_url_button\": \"Скопировать ссылку\",\n    \"notifications_none_for_any_title\": \"Вы ещё не получали никаких уведомлений.\",\n    \"alert_notification_permission_required_title\": \"Уведомления отключены\",\n    \"notifications_attachment_copy_url_title\": \"Скопировать URL-адрес вложения\",\n    \"notifications_actions_open_url_title\": \"Перейти на {{url}}\",\n    \"notifications_tags\": \"Тэги\",\n    \"notifications_attachment_link_expires\": \"срок действия ссылки истекает {{date}}\",\n    \"notifications_click_copy_url_title\": \"Скопировать URL-адрес ссылки\",\n    \"notifications_click_open_button\": \"Открыть ссылку\",\n    \"subscribe_dialog_subscribe_title\": \"Подписаться на тему\",\n    \"publish_dialog_button_cancel\": \"Отмена\",\n    \"subscribe_dialog_subscribe_description\": \"Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.\",\n    \"prefs_users_description\": \"Вы можете управлять пользователями для защищённых тем. Учтите, что имя учётные данные хранятся в локальном хранилище браузера.\",\n    \"error_boundary_description\": \"Это не должно было случиться. Нам очень жаль. <br/>Если Вы можете уделить минуту своего времени, пожалуйста <githubLink>сообщите об этом на GitHub</githubLink>, или дайте нам знать через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.\",\n    \"publish_dialog_email_placeholder\": \"Адрес для пересылки уведомления. Например, phil@example.com\",\n    \"publish_dialog_attach_placeholder\": \"Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_label\": \"Имя файла\",\n    \"publish_dialog_delay_label\": \"Задержка\",\n    \"publish_dialog_delay_placeholder\": \"Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, или \\\"{{naturalLanguage}}\\\" (только по-английски)\",\n    \"publish_dialog_chip_click_label\": \"URL-адрес при нажатии\",\n    \"publish_dialog_chip_email_label\": \"Переслать на электронную почту\",\n    \"publish_dialog_chip_attach_url_label\": \"Прикрепить файл по URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Прикрепить локальный файл\",\n    \"publish_dialog_chip_delay_label\": \"Задержать доставку\",\n    \"publish_dialog_chip_topic_label\": \"Изменить тему\",\n    \"publish_dialog_details_examples_description\": \"Примеры и подробное описание всех функций смотрите в <docsLink>документации</docsLink>.\",\n    \"publish_dialog_attach_label\": \"URL-адрес вложения\",\n    \"publish_dialog_filename_placeholder\": \"Имя файла вложения\",\n    \"publish_dialog_other_features\": \"Другие возможности:\",\n    \"publish_dialog_button_cancel_sending\": \"Отменить отправку\",\n    \"publish_dialog_button_send\": \"Отправить\",\n    \"publish_dialog_checkbox_publish_another\": \"Опубликовать еще\",\n    \"publish_dialog_attached_file_title\": \"Прикреплённый файл:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Имя прикреплённого файла\",\n    \"emoji_picker_search_placeholder\": \"Поиск смайликов\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Название темы. Например, phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Использовать другой сервер\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Отмена\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Подписаться\",\n    \"subscribe_dialog_login_title\": \"Требуется авторизация\",\n    \"subscribe_dialog_login_description\": \"Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.\",\n    \"subscribe_dialog_login_username_label\": \"Имя пользователя. Например, oleg\",\n    \"subscribe_dialog_login_password_label\": \"Пароль\",\n    \"common_back\": \"Назад\",\n    \"subscribe_dialog_login_button_login\": \"Войти\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Пользователь {{username}} не авторизован\",\n    \"subscribe_dialog_error_user_anonymous\": \"анонимный пользователь\",\n    \"prefs_notifications_title\": \"Уведомления\",\n    \"prefs_notifications_sound_title\": \"Звук уведомлений\",\n    \"prefs_notifications_sound_description_none\": \"При получении уведомлений не звуки не проигрываются\",\n    \"prefs_notifications_sound_no_sound\": \"Без звука\",\n    \"prefs_notifications_min_priority_title\": \"Минимальный приоритет\",\n    \"prefs_notifications_min_priority_description_any\": \"Показывать все уведомления, независимо от их приоритета\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Показывать уведомления, если приоритет {{number}} ({{name}}) или выше\",\n    \"prefs_notifications_min_priority_description_max\": \"Показывать уведомления, если приоритет равен 5 (максимальный)\",\n    \"prefs_notifications_min_priority_any\": \"Любой приоритет\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Низкий приоритет и выше\",\n    \"prefs_notifications_min_priority_max_only\": \"Только максимальный приоритет\",\n    \"prefs_notifications_delete_after_title\": \"Удаление уведомлений\",\n    \"prefs_notifications_delete_after_never\": \"Никогда\",\n    \"prefs_notifications_delete_after_three_hours\": \"Через три часа\",\n    \"prefs_notifications_sound_description_some\": \"При уведомлениях проигрывается звук {{sound}}\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Стандартный приоритет и выше\",\n    \"prefs_notifications_delete_after_one_day\": \"Через день\",\n    \"prefs_notifications_delete_after_one_week\": \"Через неделю\",\n    \"prefs_notifications_delete_after_one_month\": \"Через месяц\",\n    \"prefs_notifications_delete_after_never_description\": \"Уведомления никогда не удаляются автоматически\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Уведомления удаляются автоматически через три часа\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Уведомления удаляются автоматически через один день\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Уведомления удаляются автоматически через неделю\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Уведомления удаляются автоматически через месяц\",\n    \"prefs_users_title\": \"Управление пользователями\",\n    \"prefs_users_add_button\": \"Добавить пользователя\",\n    \"prefs_users_table_user_header\": \"Пользователь\",\n    \"prefs_users_table_base_url_header\": \"URL сервера\",\n    \"prefs_users_dialog_title_add\": \"Добавить пользователя\",\n    \"prefs_users_dialog_title_edit\": \"Редактировать пользователя\",\n    \"prefs_users_dialog_base_url_label\": \"URL-адрес сервера. Например, https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"Имя пользователя. Например, oleg\",\n    \"prefs_users_dialog_password_label\": \"Пароль\",\n    \"common_cancel\": \"Отмена\",\n    \"common_add\": \"Добавить\",\n    \"common_save\": \"Сохранить\",\n    \"prefs_appearance_title\": \"Внешний вид\",\n    \"prefs_appearance_language_title\": \"Язык\",\n    \"priority_min\": \"минимальный\",\n    \"priority_low\": \"низкий\",\n    \"priority_default\": \"стандартный\",\n    \"priority_high\": \"высокий\",\n    \"priority_max\": \"максимальный\",\n    \"error_boundary_title\": \"О нет, ntfy сломался\",\n    \"error_boundary_button_copy_stack_trace\": \"Скопировать трассировку стека\",\n    \"error_boundary_stack_trace\": \"Трассировка стека\",\n    \"error_boundary_gathering_info\": \"Идет сбор дополнительной информации …\",\n    \"publish_dialog_drop_file_here\": \"Перетащите файл сюда\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Высокий приоритет и выше\",\n    \"action_bar_toggle_action_menu\": \"Открыть/закрыть меню\",\n    \"action_bar_show_menu\": \"Показать меню\",\n    \"action_bar_logo_alt\": \"Логотип ntfy\",\n    \"emoji_picker_search_clear\": \"Сбросить поиск\",\n    \"account_upgrade_dialog_cancel_warning\": \"Это действие <strong>отменит Вашу подписку</strong> и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.\",\n    \"account_tokens_table_create_token_button\": \"Создать токен доступа\",\n    \"account_tokens_table_last_origin_tooltip\": \"С IP-адреса {{ip}}, нажмите для подробностей\",\n    \"account_tokens_dialog_title_edit\": \"Изменить токен доступа\",\n    \"account_delete_dialog_button_cancel\": \"Отмена\",\n    \"account_delete_dialog_billing_warning\": \"Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.\",\n    \"account_delete_dialog_description\": \"Это действие безвозвратно удалит вашу учётную запись, включая все данные, хранящиеся на сервере. После удаления имя пользователя вашей учётной записи не будет доступно для регистрации в течение 7 дней. Если вы точно хотите продолжить, пожалуйста, введите свой пароль ниже.\",\n    \"account_delete_dialog_label\": \"Пароль\",\n    \"reservation_delete_dialog_action_keep_description\": \"Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.\",\n    \"prefs_reservations_table\": \"Список зарезервированных тем\",\n    \"prefs_reservations_table_access_header\": \"Доступ\",\n    \"prefs_reservations_table_everyone_write_only\": \"Я могу публиковать и подписываться, все остальные могут публиковать\",\n    \"prefs_reservations_dialog_description\": \"Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Удалить сообщения в кэше и вложения\",\n    \"reservation_delete_dialog_action_delete_description\": \"Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.\",\n    \"prefs_reservations_table_not_subscribed\": \"Не подписан\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Только я могу публиковать и подписываться\",\n    \"prefs_reservations_table_everyone_read_write\": \"Все могут публиковать и подписываться\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Нажмите, чтобы подписаться\",\n    \"prefs_reservations_dialog_title_add\": \"Зарезервировать тему\",\n    \"prefs_reservations_dialog_title_delete\": \"Удалить резервирование\",\n    \"prefs_reservations_dialog_title_edit\": \"Изменение резервированной темы\",\n    \"prefs_reservations_table_topic_header\": \"Тема\",\n    \"prefs_users_description_no_sync\": \"Пользователи и пароли не синхронизируются с Вашей учетной записью.\",\n    \"prefs_users_delete_button\": \"Удалить пользователя\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Невозможно удалить или редактировать залогиненного пользователя\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Пересчёт оплаты</strong>: при расширении подписки, разница в цене от текущей <strong>спишется сразу</strong>. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} на файл\",\n    \"account_tokens_table_never_expires\": \"Никогда\",\n    \"account_tokens_table_copied_to_clipboard\": \"Токен доступа скопирован\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Невозможно изменить или удалить токен текущего сеанса\",\n    \"account_tokens_delete_dialog_description\": \"Перед удалением токена доступа, убедитесь что он не используется приложениями и скриптами. <strong>Это действие невозможно отменить</strong>.\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Работа в приватном режиме не поддерживается\",\n    \"account_tokens_dialog_button_create\": \"Создать токен\",\n    \"account_tokens_delete_dialog_submit_button\": \"Безвозвратно удалить токен\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} сообщений в день\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} суммарный объем\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Выбранная\",\n    \"account_tokens_table_current_session\": \"Текущий сеанс браузера\",\n    \"account_tokens_dialog_button_update\": \"Изменить токен\",\n    \"account_tokens_dialog_expires_label\": \"Токен доступа истекает\",\n    \"account_tokens_dialog_expires_x_hours\": \"Токен истекает через {{hours}} часов\",\n    \"account_tokens_dialog_expires_never\": \"Токен никогда не истекает\",\n    \"prefs_notifications_sound_play\": \"Воспроизводить выбранный звук\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} зарезервированных тем\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} эл. писем в день\",\n    \"account_basics_tier_free\": \"Бесплатный\",\n    \"account_tokens_dialog_title_create\": \"Создать токен доступа\",\n    \"account_tokens_dialog_title_delete\": \"Удалить токен доступа\",\n    \"common_copy_to_clipboard\": \"Скопировать в буфер обмена\",\n    \"account_tokens_dialog_button_cancel\": \"Отмена\",\n    \"account_tokens_dialog_expires_unchanged\": \"Оставить срок истечения без изменений\",\n    \"account_tokens_dialog_expires_x_days\": \"Токен истекает через {{days}} дней\",\n    \"account_tokens_delete_dialog_title\": \"Удалить токен доступа\",\n    \"prefs_users_table\": \"Список пользоваетелй\",\n    \"account_upgrade_dialog_tier_current_label\": \"Текущая\",\n    \"account_upgrade_dialog_button_cancel\": \"Отмена\",\n    \"prefs_users_edit_button\": \"Редактировать пользователя\",\n    \"account_basics_tier_upgrade_button\": \"Обновить до Pro\",\n    \"account_basics_tier_paid_until\": \"Подписка оплачена до {{date}} и будет продляться автоматически\",\n    \"account_basics_tier_change_button\": \"Изменить\",\n    \"account_delete_dialog_button_submit\": \"Безвозвратно удалить эту учётную запись\",\n    \"account_upgrade_dialog_title\": \"Изменить уровень учётной записи\",\n    \"account_usage_basis_ip_description\": \"Статистика и ограничения на использование учитываются по IP-адресу, поэтому они могут совмещаться с другими пользователями. Уровни, указанные выше, примерно соответствуют текущим ограничениям.\",\n    \"publish_dialog_topic_reset\": \"Сбросить тему\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(без подписки)\",\n    \"prefs_reservations_dialog_topic_label\": \"Тема\",\n    \"signup_form_username\": \"Имя пользователя\",\n    \"signup_form_password\": \"Пароль\",\n    \"signup_form_confirm_password\": \"Подтвердите пароль\",\n    \"signup_form_button_submit\": \"Зарегистрироваться\",\n    \"signup_form_toggle_password_visibility\": \"Показать/скрыть пароль\",\n    \"signup_disabled\": \"Регистрация недоступна\",\n    \"signup_error_username_taken\": \"Имя пользователя {{username}} уже занято\",\n    \"signup_title\": \"Создать учётную запись ntfy\",\n    \"signup_already_have_account\": \"Уже есть учётная запись? Войдите!\",\n    \"signup_error_creation_limit_reached\": \"Исчерпано ограничение создания учётных записей\",\n    \"login_form_button_submit\": \"Вход\",\n    \"login_link_signup\": \"Регистрация\",\n    \"login_disabled\": \"Вход недоступен\",\n    \"action_bar_reservation_add\": \"Зарезервировать тему\",\n    \"action_bar_reservation_edit\": \"Изменить резервирование\",\n    \"action_bar_reservation_delete\": \"Удалить резервирование\",\n    \"action_bar_profile_title\": \"Профиль\",\n    \"action_bar_profile_settings\": \"Настройки\",\n    \"action_bar_profile_logout\": \"Выйти\",\n    \"action_bar_sign_in\": \"Войти\",\n    \"action_bar_sign_up\": \"Регистрация\",\n    \"action_bar_change_display_name\": \"Изменить псевдоним\",\n    \"message_bar_publish\": \"Опубликовать сообщение\",\n    \"nav_button_muted\": \"Уведомления заглушены\",\n    \"nav_button_connecting\": \"установка соединения\",\n    \"action_bar_account\": \"Учётная запись\",\n    \"login_title\": \"Войдите в учётную запись ntfy\",\n    \"action_bar_reservation_limit_reached\": \"Лимит исчерпан\",\n    \"action_bar_toggle_mute\": \"Заглушить/разрешить уведомления\",\n    \"nav_button_account\": \"Учётная запись\",\n    \"nav_upgrade_banner_label\": \"Подписка ntfy Pro\",\n    \"message_bar_show_dialog\": \"Открыть диалог публикации\",\n    \"notifications_list\": \"Список уведомлений\",\n    \"notifications_list_item\": \"Уведомление\",\n    \"notifications_mark_read\": \"Пометить как прочитанное\",\n    \"notifications_priority_x\": \"Приоритет {{priority}}\",\n    \"notifications_attachment_image\": \"Приложенное изображение\",\n    \"notifications_attachment_file_audio\": \"звуковой файл\",\n    \"notifications_attachment_file_video\": \"видео файл\",\n    \"notifications_attachment_file_image\": \"графический файл\",\n    \"notifications_attachment_file_app\": \"Исполняемый файл Android\",\n    \"notifications_attachment_file_document\": \"другой тип файла\",\n    \"notifications_actions_not_supported\": \"Действие не поддерживается в веб-приложении\",\n    \"display_name_dialog_title\": \"Изменить псевдоним\",\n    \"display_name_dialog_description\": \"Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.\",\n    \"reserve_dialog_checkbox_label\": \"Зарезервировать тему и настроить доступ\",\n    \"publish_dialog_emoji_picker_show\": \"Выбрать смайлик\",\n    \"publish_dialog_click_reset\": \"Удалить ссылку\",\n    \"publish_dialog_email_reset\": \"Удалить адрес для пересылки\",\n    \"publish_dialog_attach_reset\": \"Удалить URL-адрес вложения\",\n    \"publish_dialog_delay_reset\": \"Удалить задержку доставки\",\n    \"publish_dialog_attached_file_remove\": \"Удалить прикреплённый файл\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL-адрес сервера\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Сгенерировать случайное имя\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Тема уже зарезервирована\",\n    \"account_basics_title\": \"Учётная запись\",\n    \"account_basics_username_title\": \"Имя пользователя\",\n    \"account_basics_username_admin_tooltip\": \"Вы администратор\",\n    \"account_basics_password_title\": \"Пароль\",\n    \"account_basics_username_description\": \"Это вы! :)\",\n    \"account_basics_password_description\": \"Смена пароля учётной записи\",\n    \"account_basics_password_dialog_title\": \"Смена пароля\",\n    \"account_basics_password_dialog_current_password_label\": \"Текущий пароль\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Введен неверный пароль\",\n    \"account_usage_title\": \"Использование\",\n    \"account_usage_of_limit\": \"из {{limit}}\",\n    \"account_usage_unlimited\": \"Неограниченно\",\n    \"account_usage_limits_reset_daily\": \"Ограничения сбрасываются ежедневно в полночь (UTC)\",\n    \"account_basics_tier_description\": \"Уровень вашей учётной записи\",\n    \"account_basics_tier_admin\": \"Администратор\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(с подпиской {{tier}})\",\n    \"account_basics_tier_payment_overdue\": \"У вас имеется задолженность по оплате. Пожалуйста, проверьте метод оплаты, иначе скоро вы утратите преимущества подписки.\",\n    \"account_basics_tier_canceled_subscription\": \"Ваша подписка была отменена. Учётная запись перейдет на бесплатное обслуживание {{date}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Управление оплатой\",\n    \"account_usage_messages_title\": \"Опубликованные сообщения\",\n    \"account_usage_emails_title\": \"Отправленные электронные сообщения\",\n    \"account_usage_reservations_title\": \"Зарезервированные темы\",\n    \"account_usage_reservations_none\": \"Нет зарезервированных тем\",\n    \"account_usage_attachment_storage_title\": \"Хранение вложений\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} за файл, удаляются спустя {{expiry}}\",\n    \"account_usage_cannot_create_portal_session\": \"Невозможно открыть портал оплаты\",\n    \"account_delete_title\": \"Удаление учётной записи\",\n    \"account_delete_description\": \"Безвозвратное удаление этой учётной записи\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Зарегистрироваться\",\n    \"account_upgrade_dialog_button_pay_now\": \"Оплатить и подписаться\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Отменить подписку\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Изменить подписку\",\n    \"account_tokens_title\": \"Токены доступа\",\n    \"account_tokens_description\": \"Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.\",\n    \"account_tokens_table_token_header\": \"Токен\",\n    \"account_tokens_table_label_header\": \"Название\",\n    \"account_tokens_table_last_access_header\": \"Последний доступ\",\n    \"account_tokens_table_expires_header\": \"Истекает\",\n    \"account_tokens_dialog_label\": \"Название, например Radarr notifications\",\n    \"prefs_reservations_title\": \"Зарезервированные темы\",\n    \"prefs_reservations_description\": \"Здесь вы можете резервировать темы для личного пользования. Резервирование дает возможность управления темой и настройки правил доступа к ней для других пользователей.\",\n    \"prefs_reservations_limit_reached\": \"Лимит количества зарезервированных тем исчерпан.\",\n    \"prefs_reservations_add_button\": \"Добавить тему\",\n    \"prefs_reservations_edit_button\": \"Настройка доступа\",\n    \"prefs_reservations_delete_button\": \"Сбросить правила доступа\",\n    \"prefs_reservations_table_everyone_read_only\": \"Я могу публиковать и подписываться, все остальные могут подписываться\",\n    \"prefs_reservations_dialog_access_label\": \"Доступ\",\n    \"reservation_delete_dialog_description\": \"Удаление резервирования дает возможность зарезервировать эту тему другим. Вы можете оставить или удалить существующие сообщения и вложения.\",\n    \"reservation_delete_dialog_action_keep_title\": \"Сохранить сообщения в кэше и вложения\",\n    \"reservation_delete_dialog_submit_button\": \"Удалить резервирование\",\n    \"account_basics_tier_basic\": \"Базовый\",\n    \"nav_upgrade_banner_description\": \"Зарезервированные темы, больше сообщений и электронных писем, а также вложения большего размера\",\n    \"alert_not_supported_context_description\": \"Уведомления поддерживаются только по протоколу HTTPS. Это ограничение <mdnLink>Notifications API</mdnLink>.\",\n    \"notifications_delete\": \"Удалить\",\n    \"notifications_new_indicator\": \"Новое уведомление\",\n    \"notifications_actions_http_request_title\": \"Отправить HTTP {{method}}-запрос на {{url}}\",\n    \"display_name_dialog_placeholder\": \"Псевдоним\",\n    \"account_basics_password_dialog_new_password_label\": \"Новый пароль\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Подтвердите пароль\",\n    \"account_basics_password_dialog_button_submit\": \"Сменить пароль\",\n    \"account_basics_tier_title\": \"Тип учётной записи\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.\",\n    \"account_basics_tier_interval_monthly\": \"ежемесячно\",\n    \"account_basics_tier_interval_yearly\": \"ежегодно\",\n    \"account_upgrade_dialog_interval_yearly\": \"Ежегодно\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"скидка {{discount}}%\",\n    \"account_upgrade_dialog_interval_monthly\": \"Ежемесячно\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"скидка до {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Нет зарезервированных тем\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"в месяц\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} в год. Оплата помесячно.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} ежегодно. Сэкономьте {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"По вопросам оплаты, пожалуйста <Link>свяжитесь с нами</Link>.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"По вопросам оплаты, пожалуйста обратитесь к нашему <Link>сайту</Link>.\",\n    \"publish_dialog_call_reset\": \"Удалить вызов\",\n    \"account_basics_phone_numbers_dialog_description\": \"Для того что бы использовать возможность уведомлений о вызовах, нужно добавить и проверить хотя бы один номер телефона. Проверить можно используя SMS или звонок.\",\n    \"account_basics_phone_numbers_dialog_title\": \"Добавить номер телефона\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"например, +72223334444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"например, 123456\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Отправить SMS\",\n    \"account_usage_calls_title\": \"Совершённые вызовы\",\n    \"account_usage_calls_none\": \"Невозможно совершать вызовы с этой учётной записью\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Нет проверенных номеров\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Номер телефона скопирован в буфер обмена\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Нет вызовов\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} ежедневный звонок\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Номер телефона\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Подтвердить код\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} ежедневных звонков\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} зарезервированная тема\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Телефонных номеров пока нет\",\n    \"publish_dialog_chip_call_label\": \"Звонок\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} эл. письмо в день\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} сообщение в день\",\n    \"account_basics_phone_numbers_description\": \"Для уведомлений о телефонных звонках\",\n    \"publish_dialog_call_label\": \"Звонок\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Позвонить\",\n    \"account_basics_phone_numbers_title\": \"Номера телефонов\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Проверочный код\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Позвонить мне\",\n    \"publish_dialog_call_item\": \"Вызов телефонного номера {{number}}\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"action_bar_mute_notifications\": \"Заглушить уведомления\",\n    \"action_bar_unmute_notifications\": \"Разрешить уведомления\",\n    \"alert_notification_permission_denied_title\": \"Уведомления не разрешены\",\n    \"alert_notification_permission_denied_description\": \"Пожалуйста, разрешите отправку уведомлений браузере\",\n    \"alert_notification_ios_install_required_title\": \"iOS требует установку\",\n    \"alert_notification_ios_install_required_description\": \"Нажмите на значок \\\"Поделиться\\\" и \\\"Добавить на главный экран\\\", чтобы включить уведомления на iOS\",\n    \"error_boundary_button_reload_ntfy\": \"Перезагрузить ntfy\",\n    \"web_push_subscription_expiring_title\": \"Уведомления будут приостановлены\",\n    \"web_push_subscription_expiring_body\": \"Откройте ntfy, чтобы продолжать получать уведомления\",\n    \"web_push_unknown_notification_title\": \"Получено неизвестное уведомление от сервера\",\n    \"web_push_unknown_notification_body\": \"Вам может потребоваться обновить ntfy, для этого откройте веб-приложение\",\n    \"prefs_notifications_web_push_title\": \"Фоновые уведомления\",\n    \"prefs_notifications_web_push_enabled_description\": \"Уведомления приходят даже когда веб-приложение не запущено (через Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Уведомления приходят, когда веб-приложение запущено (через WebSocket)\",\n    \"prefs_appearance_theme_title\": \"Тема оформления\",\n    \"prefs_notifications_web_push_enabled\": \"Включено для {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Выключено\",\n    \"notifications_actions_failed_notification\": \"Неудачное действие\",\n    \"publish_dialog_checkbox_markdown\": \"Форматировать как Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Уведомления с других серверов не будут получены, когда веб-приложение не открыто\",\n    \"prefs_appearance_theme_system\": \"Как в системе (по умолчанию)\",\n    \"prefs_appearance_theme_dark\": \"Тёмная\",\n    \"prefs_appearance_theme_light\": \"Светлая\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Пользователя, созданного автоматически, нельзя изменить или удалить\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Автоматически созданный токен нельзя изменить или удалить\"\n}\n"
  },
  {
    "path": "web/public/static/langs/sk.json",
    "content": "{\n    \"common_save\": \"Uložiť\",\n    \"common_back\": \"Späť\",\n    \"common_copy_to_clipboard\": \"Kopírovať do schránky\",\n    \"signup_title\": \"Vytvoriť ntfy účet\",\n    \"signup_form_username\": \"Používateľské meno\",\n    \"signup_form_confirm_password\": \"Potvrdenie hesla\",\n    \"signup_form_button_submit\": \"Zaregistrovať sa\",\n    \"signup_form_toggle_password_visibility\": \"Prepnúť viditeľnosť hesla\",\n    \"signup_error_username_taken\": \"Používateľské meno {{username}} je už obsadené\",\n    \"login_form_button_submit\": \"Prihlásiť sa\",\n    \"login_disabled\": \"Prihlásenie je zakázané\",\n    \"action_bar_logo_alt\": \"ntfy logo\",\n    \"action_bar_settings\": \"Nastavenia\",\n    \"action_bar_account\": \"Účet\",\n    \"action_bar_sign_in\": \"Prihlásiť sa\",\n    \"action_bar_profile_settings\": \"Nastavenia\",\n    \"action_bar_reservation_edit\": \"Zmeniť rezerváciu\",\n    \"action_bar_unsubscribe\": \"Odhlásiť odber\",\n    \"action_bar_toggle_mute\": \"Stlmiť/zrušiť stlmenie upozornení\",\n    \"action_bar_toggle_action_menu\": \"Otvoriť/zavrieť akčné menu\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"nav_button_settings\": \"Nastavenia\",\n    \"nav_button_account\": \"Účet\",\n    \"message_bar_show_dialog\": \"Zobraziť okno pre odosielanie oznámení\",\n    \"message_bar_publish\": \"Zverejniť správu\",\n    \"nav_topics_title\": \"Odoberané témy\",\n    \"nav_button_all_notifications\": \"Všetky oznámenia\",\n    \"alert_grant_description\": \"Udeliť prehliadaču povolenie na zobrazovanie oznámení na ploche.\",\n    \"alert_not_supported_context_description\": \"Oznámenia sú podporované len cez HTTPS. Ide o obmedzenie rozhrania <mdnLink>Notifications API</mdnLink>.\",\n    \"notifications_list\": \"Zoznam oznámení\",\n    \"notifications_list_item\": \"Oznámenie\",\n    \"notifications_mark_read\": \"Označiť ako prečítané\",\n    \"notifications_delete\": \"Zmazať\",\n    \"notifications_copied_to_clipboard\": \"Skopírované do schránky\",\n    \"notifications_tags\": \"Štítky\",\n    \"notifications_priority_x\": \"Priorita {{priority}}\",\n    \"notifications_new_indicator\": \"Nové oznámenie\",\n    \"notifications_attachment_image\": \"Obrázok prílohy\",\n    \"notifications_attachment_link_expired\": \"odkaz na stiahnutie vypršal\",\n    \"notifications_attachment_file_image\": \"súbor s obrázkom\",\n    \"notifications_attachment_file_video\": \"video súbor\",\n    \"notifications_attachment_file_audio\": \"zvukový súbor\",\n    \"notifications_attachment_file_app\": \"Súbor aplikácie pre Android\",\n    \"notifications_attachment_file_document\": \"iný dokument\",\n    \"notifications_click_copy_url_title\": \"Skopírovať URL adresu odkazu do schránky\",\n    \"notifications_click_copy_url_button\": \"Kopírovať odkaz\",\n    \"notifications_click_open_button\": \"Otvoriť odkaz\",\n    \"notifications_actions_not_supported\": \"Akcia nie je podporovaná vo webovej aplikácii\",\n    \"notifications_none_for_topic_title\": \"K tejto téme ste zatiaľ nedostali žiadne upozornenia.\",\n    \"notifications_none_for_any_title\": \"Nedostali ste žiadne upozornenia.\",\n    \"notifications_none_for_any_description\": \"Ak chcete posielať oznámenia do témy, jednoducho zadajte adresu PUT alebo POST na adresu URL témy. Tu je príklad s použitím jednej z vašich tém.\",\n    \"notifications_no_subscriptions_title\": \"Zdá sa, že zatiaľ nemáte žiadne prihlásenia na odber.\",\n    \"display_name_dialog_title\": \"Zmeniť zobrazovaný názov\",\n    \"notifications_no_subscriptions_description\": \"Kliknutím na odkaz \\\"{{text odkazu}}\\\" vytvoríte tému alebo sa na ňu prihlásite. Potom môžete posielať správy prostredníctvom PUT alebo POST a budete tu dostávať oznámenia.\",\n    \"notifications_example\": \"Príklad\",\n    \"notifications_more_details\": \"Ďalšie informácie nájdete na <websiteLink>webovej stránke</websiteLink> alebo v <docsLink>dokumentácií</docsLink>.\",\n    \"display_name_dialog_placeholder\": \"Zobrazený názov\",\n    \"reserve_dialog_checkbox_label\": \"Rezervovať tému a nakonfigurovať prístup\",\n    \"notifications_loading\": \"Načítavanie oznámení …\",\n    \"publish_dialog_title_no_topic\": \"Zverejniť oznámenie\",\n    \"publish_dialog_title_topic\": \"Zverejniť v {{topic}}\",\n    \"publish_dialog_progress_uploading\": \"Nahrávanie…\",\n    \"publish_dialog_progress_uploading_detail\": \"Nahrávanie {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"publish_dialog_message_published\": \"Oznámenie zverejnené\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"prekročí {{fileSizeLimit}} limit súboru a kvótu, {{remainingBytes}} zostáva\",\n    \"publish_dialog_attachment_limits_file_reached\": \"prekračuje {{fileSizeLimit}} limit súboru\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"prekračuje kvótu, {{remainingBytes}} zostáva\",\n    \"publish_dialog_emoji_picker_show\": \"Vyberte emoji\",\n    \"publish_dialog_priority_min\": \"Min. priorita\",\n    \"publish_dialog_priority_low\": \"Nízka priorita\",\n    \"publish_dialog_priority_default\": \"Predvolená priorita\",\n    \"publish_dialog_priority_high\": \"Vysoká priorita\",\n    \"publish_dialog_priority_max\": \"Max. priorita\",\n    \"publish_dialog_base_url_label\": \"URL Adresa služby\",\n    \"publish_dialog_base_url_placeholder\": \"URL adresa služby, napr. https://example.com\",\n    \"publish_dialog_topic_label\": \"Názov témy\",\n    \"publish_dialog_topic_placeholder\": \"Názov témy, napr. phil_alerts\",\n    \"publish_dialog_topic_reset\": \"Resetovať tému\",\n    \"publish_dialog_title_label\": \"Názov\",\n    \"publish_dialog_title_placeholder\": \"Názov oznámenia, napr. Upozornenie na miesto na disku\",\n    \"publish_dialog_tags_label\": \"Štítky\",\n    \"publish_dialog_message_label\": \"Správa\",\n    \"publish_dialog_priority_label\": \"Priorita\",\n    \"publish_dialog_click_label\": \"Kliknite na URL\",\n    \"publish_dialog_click_placeholder\": \"URL adresa sa otvorí po kliknutí na oznámenie\",\n    \"publish_dialog_email_label\": \"Email\",\n    \"publish_dialog_email_placeholder\": \"Emailová adresa, na ktorú sa má oznámenie zaslať, napr. phil@example.com\",\n    \"publish_dialog_call_label\": \"Telefonovať\",\n    \"publish_dialog_call_item\": \"Zavolať na telefónne číslo {{number}}\",\n    \"publish_dialog_call_reset\": \"Odstrániť telefón\",\n    \"publish_dialog_attach_label\": \"URL prílohy\",\n    \"publish_dialog_attach_reset\": \"Odstrániť URL prílohy\",\n    \"publish_dialog_filename_label\": \"Názov súboru\",\n    \"publish_dialog_filename_placeholder\": \"Názov súboru prílohy\",\n    \"publish_dialog_delay_label\": \"Oneskorenie\",\n    \"publish_dialog_delay_placeholder\": \"Oneskorenie doručenia, napr. {{unixTimestamp}}, {{relativeTime}} alebo \\\"{{naturalLanguage}}\\\" (len v angličtine)\",\n    \"publish_dialog_delay_reset\": \"Odstrániť oneskorené doručenie\",\n    \"publish_dialog_chip_call_label\": \"Telefonovať\",\n    \"publish_dialog_other_features\": \"Ďalšie funkcie:\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Žiadne overené telefónne čísla\",\n    \"publish_dialog_chip_attach_url_label\": \"Pripojiť súbor pomocou adresy URL\",\n    \"publish_dialog_chip_delay_label\": \"Oneskoriť doručenie\",\n    \"publish_dialog_chip_topic_label\": \"Zmeniť tému\",\n    \"publish_dialog_button_cancel_sending\": \"Zrušiť odosielanie\",\n    \"publish_dialog_button_send\": \"Odoslať\",\n    \"publish_dialog_checkbox_publish_another\": \"Zverejniť ďalšie\",\n    \"publish_dialog_attached_file_title\": \"Priložený súbor:\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Zrušiť\",\n    \"subscribe_dialog_subscribe_title\": \"Odoberať tému\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL Adresa služby\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Názov témy, napr. phil_alerts\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Názov súboru prílohy\",\n    \"publish_dialog_attached_file_remove\": \"Odstrániť priložený súbor\",\n    \"publish_dialog_drop_file_here\": \"Vložiť súbor\",\n    \"subscribe_dialog_login_password_label\": \"Heslo\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Potvrdenie hesla\",\n    \"account_basics_title\": \"Účet\",\n    \"account_delete_dialog_button_cancel\": \"Zrušiť\",\n    \"account_delete_dialog_label\": \"Heslo\",\n    \"prefs_reservations_dialog_title_add\": \"Rezervovať tému\",\n    \"publish_dialog_button_cancel\": \"Zrušiť\",\n    \"account_upgrade_dialog_button_cancel\": \"Zrušiť\",\n    \"account_tokens_dialog_button_cancel\": \"Zrušiť\",\n    \"common_cancel\": \"Zrušiť\",\n    \"common_add\": \"Pridať\",\n    \"account_basics_username_title\": \"Používateľské meno\",\n    \"signup_form_password\": \"Heslo\",\n    \"signup_error_creation_limit_reached\": \"Dosiahnutý limit na vytvorenie konta\",\n    \"account_basics_password_title\": \"Heslo\",\n    \"action_bar_change_display_name\": \"Zmeniť zobrazovaný názov\",\n    \"prefs_users_dialog_password_label\": \"Heslo\",\n    \"action_bar_sign_up\": \"Zaregistrovať sa\",\n    \"login_link_signup\": \"Zaregistrovať sa\",\n    \"signup_already_have_account\": \"Už máte účet? Prihláste sa!\",\n    \"signup_disabled\": \"Registrácia je vypnutá\",\n    \"login_title\": \"Prihláste sa do svojho konta ntfy\",\n    \"action_bar_show_menu\": \"Zobraziť menu\",\n    \"action_bar_reservation_add\": \"Rezervovať tému\",\n    \"action_bar_reservation_delete\": \"Odstrániť rezerváciu\",\n    \"action_bar_reservation_limit_reached\": \"Dosiahnutý limit\",\n    \"action_bar_send_test_notification\": \"Odoslať testovacie oznámenie\",\n    \"action_bar_clear_notifications\": \"Vymazať všetky oznámenia\",\n    \"publish_dialog_message_placeholder\": \"Sem napíšte správu\",\n    \"action_bar_profile_logout\": \"Odhlásiť sa\",\n    \"message_bar_type_message\": \"Sem napíšte správu\",\n    \"message_bar_error_publishing\": \"Chyba pri zverejňovaní oznámenia\",\n    \"nav_button_documentation\": \"Dokumentácia\",\n    \"nav_button_publish_message\": \"Zverejniť oznámenie\",\n    \"nav_button_subscribe\": \"Odoberať tému\",\n    \"nav_button_muted\": \"Oznámenia stlmené\",\n    \"nav_button_connecting\": \"pripájanie\",\n    \"nav_upgrade_banner_description\": \"Rezervovať témy, viac správ a e-mailov a väčšie prílohy\",\n    \"nav_upgrade_banner_label\": \"Vylepšiť na ntfy Pro\",\n    \"alert_grant_title\": \"Oznámenia sú vypnuté\",\n    \"alert_grant_button\": \"Prideliť teraz\",\n    \"alert_not_supported_title\": \"Oznámenia nie sú podporované\",\n    \"alert_not_supported_description\": \"Oznámenia nie sú vo vašom prehliadači podporované\",\n    \"notifications_attachment_copy_url_title\": \"Kopírovať URL adresu prílohy do schránky\",\n    \"notifications_attachment_copy_url_button\": \"Kopírovať adresu URL\",\n    \"notifications_attachment_open_title\": \"Prejsť na {{url}}\",\n    \"notifications_actions_open_url_title\": \"Prejsť na {{url}}\",\n    \"notifications_attachment_open_button\": \"Otvoriť prílohu\",\n    \"notifications_attachment_link_expires\": \"platnosť odkazu vyprší {{date}}\",\n    \"notifications_none_for_topic_description\": \"Ak chcete posielať oznámenia do tejto témy, jednoducho zadajte adresu PUT alebo POST na URL adresu témy.\",\n    \"notifications_actions_http_request_title\": \"Odoslať HTTP {{method}} na {{url}}\",\n    \"display_name_dialog_description\": \"Nastavenie alternatívneho názvu témy, ktorá sa zobrazuje v zozname odberov. Pomáha to ľahšie identifikovať témy so zložitými názvami.\",\n    \"prefs_users_table_base_url_header\": \"URL Adresa služby\",\n    \"publish_dialog_tags_placeholder\": \"Zoznam štítkov oddelených čiarkou, napr. varovanie, srv1-backup\",\n    \"publish_dialog_chip_click_label\": \"Kliknite na URL\",\n    \"publish_dialog_email_reset\": \"Odstrániť email na preposielanie\",\n    \"publish_dialog_click_reset\": \"Odobrať URL kliknutím\",\n    \"publish_dialog_attach_placeholder\": \"Pripojiť súbor pomocou URL adresy, napr. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_chip_email_label\": \"Preposlanie na email\",\n    \"publish_dialog_chip_attach_file_label\": \"Pripojiť miestny súbor\",\n    \"publish_dialog_details_examples_description\": \"Príklady a podrobný opis všetkých funkcií odosielania nájdete v <docsLink>dokumentácii</docsLink>.\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Žiadne telefonáty\",\n    \"account_upgrade_dialog_billing_contact_email\": \"V prípade otázok týkajúcich sa fakturácie nás prosím <Link>kontaktujte tu</Link>.\",\n    \"account_tokens_dialog_title_create\": \"Vytvoriť prístupový token\",\n    \"prefs_reservations_dialog_title_edit\": \"Upraviť rezervovanú tému\",\n    \"account_basics_tier_interval_monthly\": \"mesačne\",\n    \"account_basics_tier_canceled_subscription\": \"Vaše predplatné bolo zrušené a bude preradené na bezplatné konto k dátumu {{date}}.\",\n    \"priority_default\": \"predvolená\",\n    \"prefs_notifications_min_priority_title\": \"Najnižšia priorita\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} denný telefonát\",\n    \"account_upgrade_dialog_tier_current_label\": \"Aktuálne\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Nesprávne heslo\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"prefs_notifications_delete_after_never\": \"Nikdy\",\n    \"prefs_users_description\": \"Tu môžete pridávať/odstraňovať používateľov pre svoje chránené témy. Upozorňujeme, že používateľské meno a heslo sú uložené v lokálnom úložisku prehliadača.\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefónne číslo\",\n    \"subscribe_dialog_subscribe_description\": \"Témy nemusia byť chránené heslom, preto vyberte názov, ktorý nie je ľahké uhádnuť. Po prihlásení sa na odber môžete PUT/POST oznámenia.\",\n    \"account_basics_password_dialog_button_submit\": \"Zmeniť heslo\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Potvrdiť kód\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"ušetrite až {{discount}}%\",\n    \"account_tokens_dialog_label\": \"Označenie, napr. Radarr notifications\",\n    \"account_tokens_table_expires_header\": \"Vyprší\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Vyhlásenie</strong>: Pri prechode medzi platenými plánmi sa rozdiel v cene <strong>účtuje okamžite</strong>. Pri prechode na nižšiu úroveň sa zostatok použije na platbu za budúce fakturačné obdobia.\",\n    \"prefs_reservations_dialog_access_label\": \"Prístup\",\n    \"account_usage_attachment_storage_title\": \"Ukladanie príloh\",\n    \"prefs_users_dialog_username_label\": \"Používateľské meno, napr. phil\",\n    \"account_usage_messages_title\": \"Zverejnené správy\",\n    \"emoji_picker_search_clear\": \"Vymazať vyhľadávanie\",\n    \"prefs_reservations_table_not_subscribed\": \"Odber nie je prihlásený\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} denné emaily\",\n    \"prefs_notifications_min_priority_max_only\": \"Iba najvyššia priorita\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} denné telefonáty\",\n    \"prefs_notifications_sound_description_some\": \"Oznámenia pri príchode prehrávajú zvuk {{sound}}\",\n    \"prefs_reservations_edit_button\": \"Upraviť prístup k téme\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Poslať SMS\",\n    \"account_basics_tier_change_button\": \"Zmeniť\",\n    \"account_tokens_dialog_expires_never\": \"Platnosť tokenu nikdy nevyprší\",\n    \"subscribe_dialog_login_title\": \"Vyžaduje sa prihlásenie\",\n    \"account_tokens_dialog_expires_x_days\": \"Token vyprší za {{days}} dní\",\n    \"prefs_reservations_table_everyone_read_only\": \"Môžem publikovať a odoberať, každý môže odoberať\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Iba ja môžem publikovať a odoberať\",\n    \"account_basics_phone_numbers_dialog_description\": \"Ak chcete používať funkciu oznamovanie hovorom, musíte pridať a overiť aspoň jedno telefónne číslo. Overenie je možné vykonať prostredníctvom SMS alebo telefonického hovoru.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} rezervovaná téma\",\n    \"account_delete_title\": \"Odstrániť účet\",\n    \"subscribe_dialog_login_button_login\": \"Prihlásenie\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Žiadne rezervované témy\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Nie je možné odstrániť alebo upraviť prihláseného používateľa\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(s úrovňou {{tier}})\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Oznámenia sa automaticky odstránia po troch hodinách\",\n    \"prefs_notifications_delete_after_three_hours\": \"Po troch hodinách\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Zobraziť oznámenia, ak je priorita {{number}} ({{name}}) alebo vyššia\",\n    \"reservation_delete_dialog_description\": \"Odstránením rezervácie sa vzdáte vlastníctva témy a umožníte ostatným, aby si ju rezervovali. Existujúce správy a prílohy si môžete ponechať alebo odstrániť.\",\n    \"subscribe_dialog_login_username_label\": \"Používateľské meno, napr. phil\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Používateľ {{username}} nie je autorizovaný\",\n    \"prefs_reservations_table_everyone_read_write\": \"Každý môže publikovať a odoberať\",\n    \"prefs_reservations_dialog_title_delete\": \"Odstrániť rezervovanú tému\",\n    \"prefs_users_table\": \"Tabuľka používateľov\",\n    \"prefs_reservations_table_topic_header\": \"Téma\",\n    \"reservation_delete_dialog_submit_button\": \"Vymazať rezerváciu\",\n    \"prefs_reservations_limit_reached\": \"Dosiahli ste limit rezervovaných tém.\",\n    \"account_upgrade_dialog_interval_monthly\": \"Mesačne\",\n    \"prefs_users_add_button\": \"Pridať používateľa\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} denné správy\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Zatiaľ žiadne telefónne čísla\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Vygenerovať názov\",\n    \"prefs_appearance_language_title\": \"Jazyk\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Oznámenia sa automaticky odstránia po jednom dni\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Odoberať\",\n    \"account_tokens_table_never_expires\": \"Nikdy nevyprší\",\n    \"account_tokens_delete_dialog_title\": \"Odstrániť prístupový token\",\n    \"prefs_notifications_delete_after_one_month\": \"Po jednom mesiaci\",\n    \"account_basics_phone_numbers_dialog_title\": \"Pridať telefónne číslo\",\n    \"account_tokens_delete_dialog_description\": \"Pred odstránením prístupového tokenu sa uistite, že ho aktívne nepoužívajú žiadne aplikácie ani skripty. <strong>Túto akciu nie je možné vrátiť späť</strong>.\",\n    \"account_tokens_table_label_header\": \"Označenie\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Otázky týkajúce sa fakturácie nájdete na našej <Link>webovej stránke</Link>.\",\n    \"account_basics_username_admin_tooltip\": \"Ste Admin\",\n    \"prefs_notifications_delete_after_never_description\": \"Oznámenia sa nikdy automaticky neodstránia\",\n    \"account_delete_dialog_description\": \"Tým sa vaše konto natrvalo odstráni vrátane všetkých údajov uložených na serveri. Po vymazaní bude vaše používateľské meno 7 dní nedostupné. Ak naozaj chcete pokračovať, potvrďte svoje heslo v poli nižšie.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} rezervované témy\",\n    \"account_usage_reservations_none\": \"Žiadne rezervované témy pre toto konto\",\n    \"prefs_notifications_sound_description_none\": \"Pri príchode oznámení sa neprehráva žiadny zvuk\",\n    \"account_tokens_description\": \"Pri publikovaní a prihlasovaní prostredníctvom rozhrania ntfy API používajte prístupové tokeny, aby ste nemuseli posielať prihlasovacie údaje k účtu. Viacej informácií nájdete v <Link>dokumentácií</Link>.\",\n    \"prefs_reservations_table\": \"Tabuľka rezervovaných tém\",\n    \"emoji_picker_search_placeholder\": \"Vyhľadať emoji\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Zrušiť predplatné\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} denný email\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} na jeden súbor\",\n    \"prefs_reservations_description\": \"Tu si môžete rezervovať názvy tém na osobné použitie. Rezervovaním témy získate vlastníctvo nad témou a môžete definovať prístupové práva pre ostatných používateľov k téme.\",\n    \"account_usage_title\": \"Používanie\",\n    \"account_basics_tier_upgrade_button\": \"Vylepšiť na PRO verziu\",\n    \"prefs_users_description_no_sync\": \"Používatelia a heslá nie sú synchronizované s vaším účtom.\",\n    \"account_tokens_dialog_title_edit\": \"Upraviť prístupový token\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} denná správa\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň jednu rezerváciu</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Téma je už rezervovaná\",\n    \"prefs_users_table_user_header\": \"Používateľ\",\n    \"error_boundary_stack_trace\": \"Výpis zásobníka\",\n    \"prefs_notifications_delete_after_one_week\": \"Po jednom týždni\",\n    \"prefs_reservations_delete_button\": \"Resetovať prístup k téme\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(bez úrovne)\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Oznámenia sa automaticky odstránia po jednom týždni\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Webová aplikácia ntfy potrebuje na fungovanie IndexedDB a váš prehliadač nepodporuje IndexedDB v režime súkromného prehliadania.<br/><br/>Je to síce nešťastné, ale aj tak nemá veľký zmysel používať webovú aplikáciu ntfy v režime súkromného prehliadania, pretože všetko je uložené v úložisku prehliadača. Viac informácií si môžete prečítať <githubLink>v tomto probléme GitHubu</githubLink> alebo sa s nami porozprávať na <discordLink>Discord</discordLink> alebo <matrixLink>Matrix</matrixLink>.\",\n    \"account_basics_tier_payment_overdue\": \"Vaša platba je po termíne splatnosti. Aktualizujte prosím svoj spôsob platby, inak bude váš účet preradený do nižšej kategórie.\",\n    \"account_basics_tier_description\": \"Úroveň výkonu vášho účtu\",\n    \"account_basics_phone_numbers_description\": \"Pre oznamovanie hovorom\",\n    \"account_basics_tier_free\": \"Zadarmo\",\n    \"account_upgrade_dialog_cancel_warning\": \"Týmto <strong>zrušíte svoje predplatné</strong> a {{date}} prejdete na nižšiu úroveň svojho účtu. V tento deň <strong>budú odstránené</strong> rezervácie tém, ako aj správy uložené vo vyrovnávacej pamäti servera.\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"prefs_notifications_sound_title\": \"Zvuk oznámenia\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Predvolená priorita a vyššia\",\n    \"prefs_reservations_table_access_header\": \"Prístup\",\n    \"account_tokens_table_copied_to_clipboard\": \"Prístupový token skopírovaný\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token vyprší za {{hours}} hodín\",\n    \"prefs_users_edit_button\": \"Upraviť používateľa\",\n    \"account_upgrade_dialog_title\": \"Zmeniť úroveň účtu\",\n    \"priority_low\": \"nízka\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Kliknutím sa prihlásite na odber\",\n    \"account_basics_password_description\": \"Zmeniť heslo účtu\",\n    \"account_usage_calls_title\": \"Uskutočnené telefonické hovory\",\n    \"error_boundary_description\": \"Toto samozrejme nemalo nastať. Je mi to veľmi ľúto.<br/>Ak máte chvíľu, <githubLink>nahláste to na GitHub</githubLink> alebo nám dajte vedieť cez <discordLink>Discord</discordLink> alebo <matrixLink>Matrix</matrixLink>.\",\n    \"priority_min\": \"najnižšia\",\n    \"account_basics_tier_basic\": \"Základný\",\n    \"prefs_notifications_min_priority_description_any\": \"Zobraziť všetky oznámenia bez ohľadu na prioritu\",\n    \"error_boundary_gathering_info\": \"Získajte viac informácií…\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Súkromné prehliadanie nie je podporované\",\n    \"prefs_notifications_delete_after_one_day\": \"Po jednom dni\",\n    \"error_boundary_title\": \"Ale nie, ntfy prestalo fungovať\",\n    \"reservation_delete_dialog_action_keep_description\": \"Správy a prílohy, ktoré sú uložené v medzipamäti na serveri, budú verejne viditeľné pre ľudí, ktorí poznajú názov témy.\",\n    \"prefs_reservations_add_button\": \"Pridať rezervovanú tému\",\n    \"prefs_reservations_title\": \"Rezervované témy\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefónne číslo skopírované do schránky\",\n    \"prefs_reservations_dialog_description\": \"Rezervovaním témy získate vlastníctvo nad témou a môžete definovať prístupové práva pre ostatných používateľov k téme.\",\n    \"account_basics_tier_title\": \"Typ účtu\",\n    \"account_usage_cannot_create_portal_session\": \"Nemožnosť otvoriť fakturačný portál\",\n    \"account_tokens_delete_dialog_submit_button\": \"Trvalo odstrániť token\",\n    \"account_delete_description\": \"Natrvalo odstrániť vaše konto\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"napr. +1222333444\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"napr. 123456\",\n    \"prefs_notifications_title\": \"Oznámenia\",\n    \"account_basics_tier_manage_billing_button\": \"Spravovať fakturáciu\",\n    \"account_tokens_title\": \"Prístupové tokeny\",\n    \"account_basics_username_description\": \"Hej, to si ty ❤\",\n    \"prefs_reservations_dialog_topic_label\": \"Téma\",\n    \"prefs_users_title\": \"Správa používateľov\",\n    \"account_basics_tier_interval_yearly\": \"ročne\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} za rok. Účtuje sa mesačne.\",\n    \"account_delete_dialog_button_submit\": \"Natrvalo odstrániť konto\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Hovor\",\n    \"account_basics_password_dialog_new_password_label\": \"Nové heslo\",\n    \"account_tokens_dialog_expires_unchanged\": \"Ponechať dátum skončenia platnosti nezmenený\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopírovať výpis zásobníka\",\n    \"account_tokens_dialog_title_delete\": \"Odstrániť prístupový token\",\n    \"account_usage_of_limit\": \"z {{limit}}\",\n    \"reservation_delete_dialog_action_keep_title\": \"Ponechať správy a prílohy uložené v medzipamäti\",\n    \"prefs_notifications_sound_no_sound\": \"Bez zvuku\",\n    \"account_upgrade_dialog_interval_yearly\": \"Ročne\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Zaregistrujte sa teraz\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonymný\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} účtovaná ročne. Uložiť {{save}}.\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Vysoká priorita a vyššia\",\n    \"account_usage_basis_ip_description\": \"Štatistiky a limity používania tohto účtu sú založené na vašej IP adrese, takže môžu byť zdieľané s ostatnými používateľmi. Vyššie uvedené limity sú približné hodnoty založené na existujúcich rýchlostných limitoch.\",\n    \"account_basics_password_dialog_title\": \"Zmeniť heslo\",\n    \"priority_max\": \"najvyššia\",\n    \"account_usage_limits_reset_daily\": \"Limity používania sa obnovujú denne o polnoci (UTC)\",\n    \"account_usage_unlimited\": \"Nekonečné\",\n    \"prefs_users_delete_button\": \"Odstrániť používateľa\",\n    \"prefs_notifications_min_priority_any\": \"Akákoľvek priorita\",\n    \"account_tokens_dialog_expires_label\": \"Platnosť prístupového tokenu vyprší za\",\n    \"account_basics_phone_numbers_title\": \"Telefónne čísla\",\n    \"prefs_notifications_delete_after_title\": \"Odstrániť oznámenia\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"ušetríte {{discount}}%\",\n    \"prefs_users_dialog_title_edit\": \"Upraviť používateľa\",\n    \"account_basics_password_dialog_current_password_label\": \"Aktuálne heslo\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Nízka priorita a vyššia\",\n    \"account_tokens_dialog_button_update\": \"Aktualizovať token\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} celkový úložný priestor\",\n    \"prefs_reservations_table_everyone_write_only\": \"Môžem publikovať a odoberať, každý môže publikovať\",\n    \"prefs_appearance_title\": \"Vzhlad\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Nie je možné upraviť alebo odstrániť aktuálny token relácie\",\n    \"prefs_notifications_sound_play\": \"Prehrať vybraný zvuk\",\n    \"account_tokens_table_last_access_header\": \"Posledný prístup\",\n    \"account_tokens_table_last_origin_tooltip\": \"Z IP adresy {{ip}}, kliknite na vyhľadávanie\",\n    \"account_usage_reservations_title\": \"Rezervované témy\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"mesiac\",\n    \"account_usage_calls_none\": \"S týmto účtom nie je možné uskutočňovať žiadne telefonické hovory\",\n    \"account_tokens_table_current_session\": \"Aktuálna relácia prehliadača\",\n    \"account_upgrade_dialog_button_pay_now\": \"Zaplatiť a predplatiť si\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Použiť iný server\",\n    \"reservation_delete_dialog_action_delete_title\": \"Odstrániť správy a prílohy uložené v medzipamäti\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Overovací kód\",\n    \"reservation_delete_dialog_action_delete_description\": \"Správy a prílohy uložené v medzipamäti sa natrvalo vymažú. Túto akciu nemožno vrátiť späť.\",\n    \"account_basics_tier_paid_until\": \"Predplatné zaplatené do {{date}} s automatickou obnovou\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} na súbor, vymazaný po {{expiry}}\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Oznámenia sa automaticky odstránia po jednom mesiaci\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Zavolajte mi\",\n    \"prefs_users_dialog_base_url_label\": \"URL adresa služby, napr. https://ntfy.sh\",\n    \"account_usage_emails_title\": \"Odoslané emaily\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Vybrané\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Aktualizovať predplatné\",\n    \"priority_high\": \"vysoká\",\n    \"account_delete_dialog_billing_warning\": \"Odstránením konta sa okamžite zruší aj vaše fakturačné predplatné. Už nebudete mať prístup k fakturačnému panelu.\",\n    \"prefs_notifications_min_priority_description_max\": \"Zobraziť oznámenia, ak je priorita 5 (max)\",\n    \"subscribe_dialog_login_description\": \"Táto téma je chránená heslom. Ak sa chcete prihlásiť na odber témy, zadajte používateľské meno a heslo.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň {{count}} rezervácií</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.\",\n    \"prefs_users_dialog_title_add\": \"Pridať používateľa\",\n    \"account_tokens_dialog_button_create\": \"Vytvoriť token\",\n    \"account_tokens_table_create_token_button\": \"Vytvoriť prístupový token\",\n    \"action_bar_mute_notifications\": \"Stlmiť oznámenia\",\n    \"action_bar_unmute_notifications\": \"Zrušiť stlmenie oznámení\",\n    \"alert_notification_permission_required_description\": \"Udeliť povolenie prehliadaču na zobrazovanie oznámení na ploche\",\n    \"alert_notification_permission_required_button\": \"Udeliť teraz\",\n    \"alert_notification_permission_denied_title\": \"Oznámenia sú zablokované\",\n    \"alert_notification_permission_denied_description\": \"Opätovne ich povoľte vo svojom prehliadači\",\n    \"alert_notification_ios_install_required_title\": \"Vyžaduje sa inštalácia iOS\",\n    \"notifications_actions_failed_notification\": \"Neúspešná akcia\",\n    \"publish_dialog_checkbox_markdown\": \"Formátovať ako Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Oznámenia z iných serverov sa nebudú prijímať, keď webová aplikácia nie je otvorená\",\n    \"prefs_notifications_web_push_title\": \"Oznámenia na pozadí\",\n    \"prefs_notifications_web_push_enabled_description\": \"Oznámenia sa prijímajú, aj keď webová aplikácia nie je spustená (prostredníctvom Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Oznámenia sa prijímajú, keď je webová aplikácia spustená (cez WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Povolené pre {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Zakázané\",\n    \"prefs_appearance_theme_title\": \"Téma\",\n    \"prefs_appearance_theme_system\": \"Systémové (predvolené)\",\n    \"prefs_appearance_theme_dark\": \"Tmavý režim\",\n    \"prefs_appearance_theme_light\": \"Svetlý režim\",\n    \"error_boundary_button_reload_ntfy\": \"Obnoviť ntfy\",\n    \"web_push_subscription_expiring_title\": \"Oznámenia budú pozastavené\",\n    \"web_push_subscription_expiring_body\": \"Ak chcete pokračovať v prijímaní upozornení, otvorte ntfy\",\n    \"web_push_unknown_notification_title\": \"Neznáme oznámenie prijaté zo servera\",\n    \"web_push_unknown_notification_body\": \"Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie\",\n    \"alert_notification_permission_required_title\": \"Oznámenia sú vypnuté\",\n    \"alert_notification_ios_install_required_description\": \"Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Prideleného používateľa nemožno upraviť ani odstrániť\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Pridelený token nemožno upraviť ani odstrániť\"\n}\n"
  },
  {
    "path": "web/public/static/langs/sq.json",
    "content": "{\n    \"common_back\": \"Prapa\",\n    \"signup_form_username\": \"Emri i përdoruesit\",\n    \"signup_title\": \"Krijo një llogari \\\"ntfy\\\"\",\n    \"signup_form_toggle_password_visibility\": \"Ndrysho dukshmërinë e fjalëkalimit\",\n    \"common_save\": \"Ruaj\",\n    \"signup_form_confirm_password\": \"Konfirmo Fjalëkalimin\",\n    \"common_copy_to_clipboard\": \"Kopjo\",\n    \"signup_form_button_submit\": \"Regjistrohu\",\n    \"signup_already_have_account\": \"Keni tashmë llogari? Identifikohu!\",\n    \"signup_disabled\": \"Regjistrimi është i çaktivizuar\",\n    \"signup_error_username_taken\": \"Emri i përdoruesit {{username}} është marrë tashmë\",\n    \"signup_error_creation_limit_reached\": \"U arrit kufiri i krijimit të llogarisë\",\n    \"login_title\": \"Hyni në llogarinë tuaj ntfy\",\n    \"login_form_button_submit\": \"Identifikohu\",\n    \"login_disabled\": \"Identifikimi është i çaktivizuar\",\n    \"action_bar_show_menu\": \"Shfaq menunë\",\n    \"action_bar_settings\": \"Parametrat\",\n    \"action_bar_account\": \"Llogaria\",\n    \"action_bar_change_display_name\": \"Ndrysho emrin e shfaqur\",\n    \"action_bar_reservation_add\": \"Rezervo temën\",\n    \"action_bar_reservation_edit\": \"Ndrysho rezervimin\",\n    \"action_bar_reservation_delete\": \"Hiq rezervimin\",\n    \"action_bar_reservation_limit_reached\": \"U arrit kufiri\",\n    \"action_bar_send_test_notification\": \"Dërgo njoftim testues\",\n    \"action_bar_clear_notifications\": \"Pastro të gjitha njoftimet\",\n    \"action_bar_mute_notifications\": \"Heshti njoftimet\",\n    \"action_bar_unmute_notifications\": \"Lejo njoftimet\",\n    \"action_bar_unsubscribe\": \"Ç'abonohu\",\n    \"action_bar_toggle_mute\": \"Hesht/lejo njoftimet\",\n    \"action_bar_toggle_action_menu\": \"Hap/mbyll menynë e veprimit\",\n    \"action_bar_profile_title\": \"Profili\",\n    \"action_bar_profile_settings\": \"Parametrat\",\n    \"action_bar_profile_logout\": \"Dil\",\n    \"action_bar_sign_in\": \"Identifikohu\",\n    \"action_bar_sign_up\": \"Regjistrohu\",\n    \"message_bar_type_message\": \"Shkruaj një mesazh këtu\",\n    \"common_cancel\": \"Anullo\",\n    \"signup_form_password\": \"Fjalëkalimi\",\n    \"common_add\": \"Shto\",\n    \"login_link_signup\": \"Regjistrohu\",\n    \"action_bar_logo_alt\": \"logo e ntfy\",\n    \"message_bar_error_publishing\": \"Gabim duke postuar njoftimin\",\n    \"message_bar_show_dialog\": \"Trego dialogun e publikimit\",\n    \"message_bar_publish\": \"Publiko mesazhin\",\n    \"nav_topics_title\": \"Temat e abonuara\",\n    \"nav_button_all_notifications\": \"Të gjitha njoftimet\",\n    \"nav_button_account\": \"Llogaria\",\n    \"nav_button_settings\": \"Cilësimet\",\n    \"nav_button_publish_message\": \"Publiko njoftimin\",\n    \"nav_button_subscribe\": \"Abunohu tek tema\",\n    \"nav_button_connecting\": \"duke u lidhur\",\n    \"nav_upgrade_banner_label\": \"Përmirëso në ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"Rezervoni tema, më shumë mesazhe dhe email-e, si dhe bashkëngjitje më të mëdha\",\n    \"nav_button_muted\": \"Njoftimet janë të fikura\",\n    \"alert_notification_permission_required_title\": \"Njoftimet janë të çaktivizuar\",\n    \"alert_notification_permission_required_description\": \"Jepni leje Browser-it tuaj për të shfaqur njoftimet në desktop\",\n    \"alert_notification_permission_denied_title\": \"Njoftimet janë të bllokuara\",\n    \"alert_notification_ios_install_required_title\": \"Instalimi i iOS-it detyrohet\",\n    \"alert_notification_permission_denied_description\": \"Ju lutemi riaktivizoni ato në Browser-in tuaj\",\n    \"nav_button_documentation\": \"Dokumentacion\",\n    \"alert_notification_permission_required_button\": \"Lejo tani\"\n}\n"
  },
  {
    "path": "web/public/static/langs/sv.json",
    "content": "{\n    \"action_bar_settings\": \"Inställningar\",\n    \"action_bar_send_test_notification\": \"Skicka testnotis\",\n    \"action_bar_toggle_action_menu\": \"Öppna/stäng åtgärdsmeny\",\n    \"message_bar_type_message\": \"Skriv ett meddelande här\",\n    \"message_bar_error_publishing\": \"Fel vid publicering av notis\",\n    \"message_bar_show_dialog\": \"Visa publiceringsdialog\",\n    \"message_bar_publish\": \"Publicera meddelande\",\n    \"nav_topics_title\": \"Prenumererade kategorier\",\n    \"nav_button_all_notifications\": \"Alla notiser\",\n    \"nav_button_documentation\": \"Dokumentation\",\n    \"nav_button_publish_message\": \"Publicera notis\",\n    \"nav_button_subscribe\": \"Prenumerera på kategori\",\n    \"alert_notification_permission_required_title\": \"Notiser är avstängda\",\n    \"alert_notification_permission_required_button\": \"Bevilja nu\",\n    \"alert_not_supported_title\": \"Notiser stöds inte\",\n    \"notifications_list\": \"Notifieringslista\",\n    \"notifications_list_item\": \"Notis\",\n    \"notifications_delete\": \"Radera\",\n    \"notifications_copied_to_clipboard\": \"Kopierat till urklipp\",\n    \"notifications_tags\": \"Taggar\",\n    \"notifications_new_indicator\": \"Ny notis\",\n    \"notifications_attachment_copy_url_title\": \"Kopiera bifogad URL till urklipp\",\n    \"notifications_attachment_copy_url_button\": \"Kopiera URL\",\n    \"notifications_attachment_open_title\": \"Gå till {{url}}\",\n    \"notifications_attachment_open_button\": \"Öppna bilagan\",\n    \"notifications_attachment_link_expired\": \"Nedladdningslänk utgått\",\n    \"notifications_priority_x\": \"Prioritet {{priority}}\",\n    \"action_bar_show_menu\": \"Visa meny\",\n    \"action_bar_logo_alt\": \"ntfy-logga\",\n    \"action_bar_unsubscribe\": \"Avprenumerera\",\n    \"action_bar_toggle_mute\": \"Tysta/aktivera notiser\",\n    \"action_bar_clear_notifications\": \"Rensa alla notiser\",\n    \"nav_button_connecting\": \"ansluter\",\n    \"notifications_attachment_image\": \"Bifogad bild\",\n    \"nav_button_settings\": \"Inställningar\",\n    \"nav_button_muted\": \"Notiser tystade\",\n    \"notifications_attachment_link_expires\": \"länken utgår {{date}}\",\n    \"notifications_attachment_file_image\": \"bildfil\",\n    \"notifications_attachment_file_audio\": \"ljudfil\",\n    \"alert_notification_permission_required_description\": \"Ge din webbläsare behörighet att visa skrivbordsnotiser\",\n    \"alert_not_supported_description\": \"Notiser stöds inte i din webbläsare\",\n    \"notifications_mark_read\": \"Markera som läst\",\n    \"notifications_attachment_file_video\": \"videofil\",\n    \"notifications_click_copy_url_button\": \"Kopiera länk\",\n    \"notifications_click_open_button\": \"Öppna länk\",\n    \"notifications_actions_open_url_title\": \"Gå till {{url}}\",\n    \"notifications_none_for_any_title\": \"Du har inte fått några notiser.\",\n    \"notifications_example\": \"Exempel\",\n    \"notifications_loading\": \"Laddar notiser …\",\n    \"signup_title\": \"Skapa ett nytt konto\",\n    \"signup_form_confirm_password\": \"Bekräfta lösenord\",\n    \"signup_form_button_submit\": \"Skapa konto\",\n    \"login_title\": \"Logga in på ditt konto\",\n    \"login_form_button_submit\": \"Logga in\",\n    \"login_link_signup\": \"Registrera\",\n    \"login_disabled\": \"Inloggning är inaktiverat\",\n    \"action_bar_account\": \"Konto\",\n    \"action_bar_change_display_name\": \"Ändra visningsnamn\",\n    \"action_bar_reservation_add\": \"Reservera ämne\",\n    \"action_bar_reservation_edit\": \"Ändra reservation\",\n    \"action_bar_reservation_delete\": \"Ta bort reservation\",\n    \"action_bar_reservation_limit_reached\": \"Gräns nådd\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_settings\": \"Inställningar\",\n    \"action_bar_profile_logout\": \"Logga ut\",\n    \"action_bar_sign_in\": \"Logga in\",\n    \"action_bar_sign_up\": \"Registrera\",\n    \"nav_button_account\": \"Konto\",\n    \"nav_upgrade_banner_label\": \"Uppgradera till Pro\",\n    \"common_add\": \"Lägg till\",\n    \"signup_form_password\": \"Lösenord\",\n    \"signup_form_toggle_password_visibility\": \"Visa/dölj lösenord\",\n    \"common_cancel\": \"Avbryt\",\n    \"common_save\": \"Spara\",\n    \"signup_form_username\": \"Användarnamn\",\n    \"signup_already_have_account\": \"Har du redan ett konto? Logga in!\",\n    \"signup_disabled\": \"Registrering är inaktiverad\",\n    \"signup_error_username_taken\": \"Användarnamn [[username]] används redan\",\n    \"notifications_attachment_file_document\": \"annat dokument\",\n    \"notifications_attachment_file_app\": \"Android-appfil\",\n    \"notifications_click_copy_url_title\": \"Kopiera länk till urklipp\",\n    \"notifications_none_for_topic_title\": \"Du har inte fått några notiser för detta ämnet ännu.\",\n    \"notifications_none_for_topic_description\": \"För att kunna skicka notiser till detta ämne, använd PUT eller POST till ämnets URL.\",\n    \"notifications_actions_http_request_title\": \"Skicka HTTP {{method}} till {{url}}\",\n    \"publish_dialog_progress_uploading\": \"Laddar upp …\",\n    \"nav_upgrade_banner_description\": \"Reservera ämnen, fler meddelanden och e-postmeddelanden och större bilagor\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"överskrider {{fileSizeLimit}} filgräns och kvot, {{remainingBytes}} återstående\",\n    \"publish_dialog_attachment_limits_file_reached\": \"överskrider {{fileSizeLimit}} filgräns\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"överskrider kvoten, {{remainingBytes}} återstår\",\n    \"publish_dialog_message_placeholder\": \"Skriv ett meddelande här\",\n    \"publish_dialog_checkbox_publish_another\": \"Publicera en till\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonym\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Bekräfta lösenord\",\n    \"publish_dialog_email_placeholder\": \"Adress att vidarebefordra meddelandet till, t.ex. phil@example.com\",\n    \"publish_dialog_details_examples_description\": \"Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .\",\n    \"publish_dialog_button_send\": \"Skicka\",\n    \"common_back\": \"Tillbaka\",\n    \"account_basics_tier_free\": \"Gratis\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} reserverat ämne\",\n    \"account_delete_title\": \"Ta bort konto\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} dagliga meddelanden\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} dagligt e-postmeddelande\",\n    \"account_upgrade_dialog_button_cancel\": \"Avbryt\",\n    \"common_copy_to_clipboard\": \"Kopiera till urklipp\",\n    \"account_tokens_table_copied_to_clipboard\": \"Åtkomsttoken kopierad\",\n    \"account_tokens_description\": \"Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.\",\n    \"account_tokens_table_create_token_button\": \"Skapa åtkomsttoken\",\n    \"prefs_users_description_no_sync\": \"Användare och lösenord synkroniseras inte till ditt konto.\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy-webbappen behöver IndexedDB för att fungera och din webbläsare har inte stöd för IndexedDB i privat surfläge.<br/><br/>Detta är beklagligt, men det är inte heller särskilt meningsfullt att använda ntfy-webbappen i privat surfläge, eftersom allt lagras i webbläsarens lagringsutrymme. Du kan läsa mer om det <githubLink>i detta GitHub-ärende</githubLink>, eller prata med oss på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"account_basics_tier_interval_monthly\": \"månadsvis\",\n    \"account_basics_tier_interval_yearly\": \"årligen\",\n    \"account_basics_tier_canceled_subscription\": \"Din prenumeration avbröts och kommer att nedgraderas till ett gratis konto den {{date}}.\",\n    \"account_basics_tier_manage_billing_button\": \"Hantera fakturering\",\n    \"account_usage_messages_title\": \"Publicerade meddelande\",\n    \"account_usage_emails_title\": \"Skickade e-postmeddelanden\",\n    \"account_usage_reservations_title\": \"Reserverade ämnen\",\n    \"account_usage_reservations_none\": \"Inga reserverade ämnen för det här kontot\",\n    \"account_usage_attachment_storage_title\": \"Lagring av bilagor\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} per fil, raderas efter {{expiry}}\",\n    \"account_delete_description\": \"Ta bort ditt konto permanent\",\n    \"account_delete_dialog_description\": \"Detta kommer att radera ditt konto permanent, inklusive all data som lagras på servern. Efter raderingen kommer ditt användarnamn att vara otillgängligt i 7 dagar. Om du verkligen vill fortsätta, bekräfta med ditt lösenord i rutan nedan.\",\n    \"account_delete_dialog_label\": \"Lösenord\",\n    \"account_delete_dialog_button_cancel\": \"Avbryt\",\n    \"account_delete_dialog_button_submit\": \"Ta bort kontot permanent\",\n    \"account_delete_dialog_billing_warning\": \"Om du raderar ditt konto annulleras också din faktureringsprenumeration omedelbart. Du kommer inte längre att ha tillgång till instrumentpanelen för fakturering.\",\n    \"account_upgrade_dialog_title\": \"Ändra kontonivå\",\n    \"account_upgrade_dialog_interval_monthly\": \"Månadsvis\",\n    \"account_upgrade_dialog_interval_yearly\": \"Årligen\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"spara {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"spara upp till {{discount}}%\",\n    \"account_upgrade_dialog_cancel_warning\": \"Detta kommer att <strong>säga upp din prenumeration</strong> och nedgradera ditt konto {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern <strong>att raderas</strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Deklaration</strong>: När du uppgraderar mellan betalda planer kommer prisskillnaden att <strong>debiteras omedelbart</strong>. Vid nedgradering till en lägre nivå kommer saldot att användas för att betala för framtida faktureringsperioder.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>bör du ta bort minst en reservation</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>ta bort minst {{count}} reservationer</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Inga reserverade ämnen\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} per fil\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} total lagring\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"månad\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Vald\",\n    \"account_tokens_table_token_header\": \"Token\",\n    \"account_tokens_dialog_title_create\": \"Skapa åtkomsttoken\",\n    \"account_tokens_dialog_title_delete\": \"Ta bort åtkomsttoken\",\n    \"account_tokens_dialog_label\": \"Etikett, t.ex. Radarr-meddelanden\",\n    \"account_tokens_dialog_title_edit\": \"Redigera åtkomsttoken\",\n    \"account_tokens_dialog_button_create\": \"Skapa token\",\n    \"account_tokens_dialog_button_update\": \"Uppdatera token\",\n    \"account_tokens_delete_dialog_submit_button\": \"Ta bort token permanent\",\n    \"prefs_notifications_delete_after_one_day\": \"Efter en dag\",\n    \"reservation_delete_dialog_action_delete_description\": \"Cachade meddelanden och bilagor raderas permanent. Denna åtgärd kan inte ångras.\",\n    \"error_boundary_gathering_info\": \"Samla mer information …\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Privat surfning stöds inte\",\n    \"reservation_delete_dialog_submit_button\": \"Ta bort reservationen\",\n    \"priority_low\": \"låg\",\n    \"error_boundary_title\": \"Åh nej, ntfy kraschade\",\n    \"error_boundary_description\": \"Detta får naturligtvis inte ske. Vi beklagar verkligen detta.<br/>Om du har tid, vänligen <githubLink>rapportera detta på GitHub</githubLink>, eller meddela oss via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.\",\n    \"notifications_no_subscriptions_title\": \"Det ser ut som om du inte har några prenumerationer ännu.\",\n    \"notifications_more_details\": \"Mer information finns på <websiteLink>webbplatsen</websiteLink> eller i <docsLink>dokumentationen</docsLink> .\",\n    \"publish_dialog_title_topic\": \"Publicera till {{topic}}\",\n    \"publish_dialog_message_published\": \"Meddelande publicerat\",\n    \"publish_dialog_emoji_picker_show\": \"Välj emoji\",\n    \"publish_dialog_base_url_placeholder\": \"Service-URL, t.ex. https://example.com\",\n    \"publish_dialog_topic_label\": \"Ämnesnamn\",\n    \"publish_dialog_topic_placeholder\": \"Ämnesnamn, t.ex. phils_alerts\",\n    \"publish_dialog_topic_reset\": \"Återställ ämne\",\n    \"publish_dialog_title_label\": \"Titel\",\n    \"publish_dialog_title_placeholder\": \"Meddelandets rubrik, t.ex. Varning för diskutrymme\",\n    \"publish_dialog_tags_label\": \"Taggar\",\n    \"publish_dialog_message_label\": \"Meddelande\",\n    \"publish_dialog_tags_placeholder\": \"Kommaseparerad lista med taggar, t.ex. warning, srv1-backup\",\n    \"publish_dialog_priority_label\": \"Prioritet\",\n    \"publish_dialog_click_label\": \"Klicka på URL\",\n    \"publish_dialog_click_placeholder\": \"URL som öppnas när man klickar på anmälan\",\n    \"publish_dialog_click_reset\": \"Ta bort klickbar URL\",\n    \"publish_dialog_email_reset\": \"Ta bort vidarebefordran av e-post\",\n    \"publish_dialog_attach_label\": \"URL för bifogade filer\",\n    \"publish_dialog_attach_placeholder\": \"Bifoga fil via URL, t.ex. https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_filename_label\": \"Filnamn\",\n    \"publish_dialog_delay_label\": \"Fördröjning\",\n    \"publish_dialog_filename_placeholder\": \"Filnamn för bifogad fil\",\n    \"publish_dialog_delay_placeholder\": \"Fördröj leverans, t.ex. {{unixTimestamp}}, {{relativeTime}} eller \\\"{{naturalLanguage}}\\\" (endast engelska)\",\n    \"publish_dialog_delay_reset\": \"Ta bort försenad leverans\",\n    \"publish_dialog_other_features\": \"Andra funktioner:\",\n    \"publish_dialog_chip_click_label\": \"Klicka på URL\",\n    \"publish_dialog_attached_file_title\": \"Bifogad fil:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Filnamn för bifogad fil\",\n    \"emoji_picker_search_placeholder\": \"Sök emoji\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Avbryt\",\n    \"prefs_notifications_sound_description_some\": \"Meddelanden spelar upp ljudet {{sound}} när de anländer\",\n    \"prefs_notifications_sound_no_sound\": \"Inget ljud\",\n    \"prefs_notifications_min_priority_any\": \"Alla prioriteringar\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Låg prioritet och högre\",\n    \"prefs_notifications_delete_after_three_hours\": \"Efter tre timmar\",\n    \"prefs_notifications_delete_after_never\": \"Aldrig\",\n    \"prefs_users_table\": \"Användartabell\",\n    \"prefs_users_add_button\": \"Lägg till användare\",\n    \"prefs_users_edit_button\": \"Redigera användare\",\n    \"prefs_users_dialog_title_add\": \"Lägg till användare\",\n    \"prefs_users_dialog_title_edit\": \"Redigera användare\",\n    \"prefs_users_dialog_base_url_label\": \"Tjänstens URL, t.ex. https://ntfy.sh\",\n    \"prefs_users_dialog_password_label\": \"Lösenord\",\n    \"prefs_appearance_title\": \"Utseende\",\n    \"prefs_appearance_language_title\": \"Språk\",\n    \"priority_min\": \"min\",\n    \"priority_default\": \"standard\",\n    \"priority_high\": \"hög\",\n    \"priority_max\": \"max\",\n    \"error_boundary_button_copy_stack_trace\": \"Kopiera stackspårning\",\n    \"error_boundary_stack_trace\": \"Stackspårning\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} reserverade ämnen\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} dagligt meddelande\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} dagliga e-postmeddelanden\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} per år. Faktureras månadsvis.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} faktureras årligen. Spara {{save}}.\",\n    \"account_upgrade_dialog_tier_current_label\": \"Aktuell\",\n    \"account_upgrade_dialog_billing_contact_email\": \"För faktureringsfrågor, vänligen <Link>kontakta oss</Link> direkt.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"För frågor om fakturering hänvisar vi till vår <Link>webbplats</Link>.\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Registrera dig nu\",\n    \"account_upgrade_dialog_button_pay_now\": \"Betala nu och prenumerera\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Avbryt prenumeration\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Uppdatera prenumeration\",\n    \"account_tokens_table_label_header\": \"Etikett\",\n    \"account_tokens_table_last_access_header\": \"Sista åtkomst\",\n    \"account_tokens_table_expires_header\": \"Upphör\",\n    \"account_tokens_table_never_expires\": \"Upphör aldrig\",\n    \"account_tokens_table_current_session\": \"Nuvarande webbläsarsession\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Det går inte att redigera eller ta bort aktuell sessionstoken\",\n    \"account_tokens_table_last_origin_tooltip\": \"Från IP-adress {{ip}}, klicka för att söka upp\",\n    \"account_tokens_dialog_button_cancel\": \"Avbryt\",\n    \"account_tokens_dialog_expires_label\": \"Åtkomsttoken löper ut om\",\n    \"account_tokens_dialog_expires_unchanged\": \"Lämna utgångsdatumet oförändrat\",\n    \"account_tokens_dialog_expires_x_hours\": \"Token går ut om {{hours}} timmar\",\n    \"account_tokens_dialog_expires_x_days\": \"Token löper ut om {{days}} dagar\",\n    \"account_tokens_dialog_expires_never\": \"Token upphör aldrig att gälla\",\n    \"account_tokens_delete_dialog_title\": \"Ta bort åtkomsttoken\",\n    \"account_tokens_delete_dialog_description\": \"Innan du tar bort en åtkomsttoken bör du se till att inga program eller skript använder den aktivt. <strong>Den här åtgärden kan inte ångras</strong>.\",\n    \"prefs_notifications_title\": \"Notifieringar\",\n    \"prefs_notifications_sound_title\": \"Ljud för meddelanden\",\n    \"prefs_notifications_sound_description_none\": \"Meddelanden spelar inte upp något ljud när de kommer\",\n    \"prefs_notifications_sound_play\": \"Spela upp valt ljud\",\n    \"prefs_notifications_min_priority_title\": \"Lägsta prioritet\",\n    \"prefs_notifications_min_priority_description_any\": \"Visa alla meddelanden, oavsett prioritet\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Visa meddelanden om prioritet är {{number}} ({{name}}) eller högre\",\n    \"prefs_notifications_min_priority_description_max\": \"Visa notifieringar om prioritet är 5 (max)\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Standardprioritet och högre\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Hög prioritet och högre\",\n    \"prefs_notifications_min_priority_max_only\": \"Bara högsta prioritet\",\n    \"prefs_notifications_delete_after_title\": \"Radera meddelanden\",\n    \"prefs_notifications_delete_after_one_week\": \"Efter en vecka\",\n    \"prefs_notifications_delete_after_one_month\": \"Efter en månad\",\n    \"prefs_notifications_delete_after_never_description\": \"Meddelanden raderas aldrig automatiskt\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Meddelanden raderas automatiskt efter tre timmar\",\n    \"prefs_users_description\": \"Lägg till/ta bort användare för dina skyddade ämnen här. Observera att användarnamn och lösenord lagras i webbläsarens lokala lagring.\",\n    \"prefs_users_delete_button\": \"Ta bort användare\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Kan inte ta bort eller redigera inloggad användare\",\n    \"prefs_users_table_user_header\": \"Användare\",\n    \"prefs_users_table_base_url_header\": \"Service-URL\",\n    \"prefs_users_dialog_username_label\": \"Användarnamn, t.ex. phil\",\n    \"prefs_reservations_title\": \"Reserverade ämnen\",\n    \"prefs_reservations_description\": \"Du kan reservera ämnesnamn för personligt bruk här. Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.\",\n    \"prefs_reservations_limit_reached\": \"Du har nått gränsen för reserverade ämnen.\",\n    \"prefs_reservations_add_button\": \"Lägg till reserverat ämne\",\n    \"prefs_reservations_dialog_title_edit\": \"Redigera reserverat ämne\",\n    \"prefs_reservations_dialog_title_delete\": \"Ta bort ämnesreservation\",\n    \"signup_error_creation_limit_reached\": \"Gränsen för skapande av konton har uppnåtts\",\n    \"alert_not_supported_context_description\": \"Meddelanden stöds endast via HTTPS. Detta är en begränsning av <mdnLink>Notifications API</mdnLink>.\",\n    \"notifications_actions_not_supported\": \"Åtgärd stöds inte i webbapplikationen\",\n    \"notifications_none_for_any_description\": \"För att skicka meddelanden till ett ämne är det bara att PUT eller POST till ämnets URL. Här är ett exempel med ett av dina ämnen.\",\n    \"notifications_no_subscriptions_description\": \"Klicka på länken \\\"{{linktext}}\\\" för att skapa eller prenumerera på ett ämne. Därefter kan du skicka meddelanden via PUT eller POST och du får meddelanden här.\",\n    \"display_name_dialog_title\": \"Ändra visningsnamn\",\n    \"display_name_dialog_description\": \"Ange ett alternativt namn för ett ämne som visas i prenumerationslistan. På så sätt kan du lättare identifiera ämnen med komplicerade namn.\",\n    \"display_name_dialog_placeholder\": \"Visningsnamn\",\n    \"reserve_dialog_checkbox_label\": \"Reservera ämne och konfigurera åtkomst\",\n    \"publish_dialog_title_no_topic\": \"Publicera meddelande\",\n    \"publish_dialog_progress_uploading_detail\": \"Laddar upp {{loaded}}/{{{total}} ({{procent}}}%) …\",\n    \"publish_dialog_priority_min\": \"Lägsta prioritet\",\n    \"publish_dialog_priority_low\": \"Låg prioritet\",\n    \"publish_dialog_priority_default\": \"Standard prioritet\",\n    \"publish_dialog_priority_high\": \"Hög prioritet\",\n    \"publish_dialog_priority_max\": \"Max. prioritet\",\n    \"publish_dialog_base_url_label\": \"Service-URL\",\n    \"publish_dialog_email_label\": \"E-post\",\n    \"publish_dialog_attach_reset\": \"Ta bort URL för bifogade filer\",\n    \"publish_dialog_chip_email_label\": \"Vidarebefordra till e-post\",\n    \"publish_dialog_chip_attach_url_label\": \"Bifoga fil via URL\",\n    \"publish_dialog_chip_attach_file_label\": \"Bifoga lokal fil\",\n    \"publish_dialog_chip_delay_label\": \"Fördröj leveransen\",\n    \"publish_dialog_chip_topic_label\": \"Ändra ämne\",\n    \"publish_dialog_button_cancel_sending\": \"Avbryt sändning\",\n    \"publish_dialog_button_cancel\": \"Avbryt\",\n    \"publish_dialog_attached_file_remove\": \"Ta bort bifogad fil\",\n    \"publish_dialog_drop_file_here\": \"Släpp filen här\",\n    \"emoji_picker_search_clear\": \"Rensa sökning\",\n    \"subscribe_dialog_subscribe_title\": \"Prenumerera på ämnet\",\n    \"subscribe_dialog_subscribe_description\": \"Ämnen kanske inte är lösenordsskyddade, så välj ett namn som inte är lätt att gissa. När du har prenumererat kan du lägga in/lägga in meddelanden.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Ämnesnamn, t.ex. phils_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Använd en annan server\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Service-URL\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Generera namn\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Prenumerera\",\n    \"subscribe_dialog_login_title\": \"Inloggning krävs\",\n    \"subscribe_dialog_login_description\": \"Det här ämnet är lösenordsskyddat. Ange användarnamn och lösenord för att prenumerera.\",\n    \"subscribe_dialog_login_username_label\": \"Användarnamn, t.ex. phil\",\n    \"subscribe_dialog_login_password_label\": \"Lösenord\",\n    \"subscribe_dialog_login_button_login\": \"Logga in\",\n    \"subscribe_dialog_error_user_not_authorized\": \"Användaren {{användarnamn}} inte auktoriserad\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Ämnet är redan reserverat\",\n    \"account_basics_title\": \"Konto\",\n    \"account_basics_tier_paid_until\": \"Prenumerationen är betald fram till {{datum}}, och kommer att förnyas automatiskt\",\n    \"account_basics_username_title\": \"Användarnamn\",\n    \"account_basics_username_description\": \"Hej, det är du ❤\",\n    \"account_basics_username_admin_tooltip\": \"Du är admin\",\n    \"account_basics_password_title\": \"Lösenord\",\n    \"account_basics_password_description\": \"Ändra lösenordet till ditt konto\",\n    \"account_basics_tier_payment_overdue\": \"Din betalning är försenad. Vänligen uppdatera din betalningsmetod, annars kommer ditt konto att nedgraderas inom kort.\",\n    \"account_basics_password_dialog_title\": \"Byt lösenord\",\n    \"account_basics_password_dialog_current_password_label\": \"Aktuellt lösenord\",\n    \"account_basics_password_dialog_new_password_label\": \"Nytt lösenord\",\n    \"account_basics_password_dialog_button_submit\": \"Byt lösenord\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Felaktigt lösenord\",\n    \"account_usage_title\": \"Användning\",\n    \"account_usage_of_limit\": \"av {{limit}}\",\n    \"account_usage_unlimited\": \"Obegränsad\",\n    \"account_usage_limits_reset_daily\": \"Användningsgränserna återställs dagligen vid midnatt (UTC)\",\n    \"account_basics_tier_title\": \"Kontotyp\",\n    \"account_basics_tier_description\": \"Ditt kontos nivå\",\n    \"account_basics_tier_admin\": \"Admin\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(med {{tier}}} nivå)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(ingen nivå)\",\n    \"account_basics_tier_basic\": \"Grundläggande\",\n    \"account_basics_tier_upgrade_button\": \"Uppgradera till Pro\",\n    \"account_basics_tier_change_button\": \"Ändra\",\n    \"account_usage_cannot_create_portal_session\": \"Det går inte att öppna faktureringsportalen\",\n    \"account_usage_basis_ip_description\": \"Användningsstatistik och begränsningar för det här kontot baseras på din IP-adress, så de kan delas med andra användare. De gränser som visas ovan är ungefärliga och baseras på befintliga gränser.\",\n    \"account_tokens_title\": \"Åtkomsttoken\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Meddelanden raderas automatiskt efter en dag\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Meddelanden raderas automatiskt efter en vecka\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Meddelanden raderas automatiskt efter en månad\",\n    \"prefs_users_title\": \"Hantera användare\",\n    \"prefs_reservations_table_not_subscribed\": \"Prenumererar inte\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Klicka för att prenumerera\",\n    \"prefs_reservations_edit_button\": \"Redigera ämnesåtkomst\",\n    \"prefs_reservations_delete_button\": \"Återställ ämnesåtkomst\",\n    \"prefs_reservations_table\": \"Tabell över reserverade ämnen\",\n    \"prefs_reservations_table_topic_header\": \"Ämne\",\n    \"prefs_reservations_table_access_header\": \"Tillgång\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Endast jag kan publicera och prenumerera\",\n    \"prefs_reservations_table_everyone_read_only\": \"Jag kan publicera och prenumerera, alla kan prenumerera\",\n    \"prefs_reservations_table_everyone_write_only\": \"Jag kan publicera och prenumerera, alla kan publicera\",\n    \"prefs_reservations_table_everyone_read_write\": \"Alla kan publicera och prenumerera\",\n    \"prefs_reservations_dialog_title_add\": \"Reserverade ämnen\",\n    \"prefs_reservations_dialog_description\": \"Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.\",\n    \"prefs_reservations_dialog_topic_label\": \"Ämne\",\n    \"prefs_reservations_dialog_access_label\": \"Tillgång\",\n    \"reservation_delete_dialog_action_keep_title\": \"Behåll cachade meddelanden och bilagor\",\n    \"reservation_delete_dialog_action_keep_description\": \"Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Ta bort meddelanden och bilagor som sparats i cacheminnet\",\n    \"reservation_delete_dialog_description\": \"Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.\",\n    \"publish_dialog_call_label\": \"Telefonsamtal\",\n    \"publish_dialog_call_reset\": \"Ta bort telefonsamtal\",\n    \"publish_dialog_chip_call_label\": \"Telefonsamtal\",\n    \"account_basics_phone_numbers_title\": \"Telefonnummer\",\n    \"account_basics_phone_numbers_description\": \"För notifieringar via telefonsamtal\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Inga telefonnummer ännu\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefonnummer kopierat till urklipp\",\n    \"account_basics_phone_numbers_dialog_title\": \"Lägg till telefonnummer\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefonnummer\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"t.ex. +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Skicka SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Ring mig\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Verifieringskod\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Ring\",\n    \"account_usage_calls_title\": \"Telefonsamtal som gjorts\",\n    \"account_usage_calls_none\": \"Inga telefonsamtal kan göras med detta konto\",\n    \"publish_dialog_call_item\": \"Ring telefonnummer {{number}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Inga verifierade telefonnummer\",\n    \"account_basics_phone_numbers_dialog_description\": \"För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"t.ex. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Bekräfta kod\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} dagliga telefonsamtal\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Inga telefonsamtal\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} dagliga telefonsamtal\",\n    \"action_bar_mute_notifications\": \"Stäng av aviseringar\",\n    \"action_bar_unmute_notifications\": \"Slå på aviseringar\",\n    \"alert_notification_permission_denied_description\": \"Vänligen aktivera dem i din weblsäare\",\n    \"alert_notification_ios_install_required_title\": \"iOS installation krävs\",\n    \"notifications_actions_failed_notification\": \"Misslyckad åtgärd\",\n    \"alert_notification_permission_denied_title\": \"Notifieringar är blockerade\",\n    \"alert_notification_ios_install_required_description\": \"Klicka på delaikonen och Lägg till på hemskärmen för att aktivera notifieringarna i iOS\",\n    \"publish_dialog_checkbox_markdown\": \"Formatera som Markdown\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Meddelanden från andra servrar kommer inte att tas emot när webbappen inte är öppen\",\n    \"prefs_notifications_web_push_title\": \"Bakgrundsnotifikationer\",\n    \"prefs_notifications_web_push_enabled_description\": \"Meddelanden tas emot även när webbappen inte körs (via Web Push)\",\n    \"prefs_notifications_web_push_enabled\": \"Aktivera för {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Avaktivera\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_system\": \"System (basinställning)\",\n    \"prefs_appearance_theme_dark\": \"Mörkt läge\",\n    \"prefs_appearance_theme_light\": \"Ljust läge\",\n    \"error_boundary_button_reload_ntfy\": \"Ladda om ntfy\",\n    \"web_push_subscription_expiring_title\": \"Notifikationer kommer att pausas\",\n    \"web_push_subscription_expiring_body\": \"Öppna ntfy för att fortsätta ta emot notifikationer\",\n    \"web_push_unknown_notification_body\": \"Du kan behöva uppdatera ntfy genom att öppna webbappen\",\n    \"prefs_notifications_web_push_disabled_description\": \"Meddelanden tas emot när webbappen körs (via WebSocket)\",\n    \"web_push_unknown_notification_title\": \"Okänd notifikation mottagen från server\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"En provisionerad användare kan inte redigeras eller tas bort\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Kan inte redigera eller ta bort en provisionerad token\"\n}\n"
  },
  {
    "path": "web/public/static/langs/ta.json",
    "content": "{\n    \"action_bar_account\": \"கணக்கு\",\n    \"action_bar_change_display_name\": \"காட்சி பெயரை மாற்றவும்\",\n    \"action_bar_show_menu\": \"மெனுவைக் காட்டு\",\n    \"action_bar_logo_alt\": \"ntfy லோகோ\",\n    \"action_bar_settings\": \"அமைப்புகள்\",\n    \"action_bar_reservation_add\": \"இருப்பு தலைப்பு\",\n    \"message_bar_publish\": \"செய்தியை வெளியிடுங்கள்\",\n    \"nav_topics_title\": \"சந்தா தலைப்புகள்\",\n    \"nav_button_all_notifications\": \"அனைத்து அறிவிப்புகளும்\",\n    \"nav_button_account\": \"கணக்கு\",\n    \"nav_button_settings\": \"அமைப்புகள்\",\n    \"nav_button_documentation\": \"ஆவணப்படுத்துதல்\",\n    \"nav_button_publish_message\": \"அறிவிப்பை வெளியிடுங்கள்\",\n    \"alert_not_supported_description\": \"உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை\",\n    \"alert_not_supported_context_description\": \"அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது<mdnLink>அறிவிப்புகள் பநிஇ</mdnLink> இன் வரம்பு.\",\n    \"notifications_list\": \"அறிவிப்புகள் பட்டியல்\",\n    \"notifications_delete\": \"நீக்கு\",\n    \"notifications_copied_to_clipboard\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n    \"notifications_list_item\": \"அறிவிப்பு\",\n    \"notifications_mark_read\": \"படித்தபடி குறி\",\n    \"notifications_tags\": \"குறிச்சொற்கள்\",\n    \"notifications_priority_x\": \"முன்னுரிமை {{priority}}\",\n    \"notifications_actions_not_supported\": \"வலை பயன்பாட்டில் நடவடிக்கை ஆதரிக்கப்படவில்லை\",\n    \"notifications_none_for_topic_title\": \"இந்த தலைப்புக்கு நீங்கள் இதுவரை எந்த அறிவிப்புகளையும் பெறவில்லை.\",\n    \"notifications_actions_http_request_title\": \"Http {{method}} {{url}} க்கு அனுப்பவும்\",\n    \"notifications_actions_failed_notification\": \"தோல்வியுற்ற செயல்\",\n    \"notifications_none_for_topic_description\": \"இந்த தலைப்புக்கு அறிவிப்புகளை அனுப்ப, தலைப்பு முகவரி க்கு வைக்கவும் அல்லது இடுகையிடவும்.\",\n    \"notifications_loading\": \"அறிவிப்புகளை ஏற்றுகிறது…\",\n    \"publish_dialog_title_topic\": \"{{topic}} க்கு வெளியிடுங்கள்\",\n    \"publish_dialog_title_no_topic\": \"அறிவிப்பை வெளியிடுங்கள்\",\n    \"publish_dialog_progress_uploading\": \"பதிவேற்றுதல்…\",\n    \"publish_dialog_message_published\": \"அறிவிப்பு வெளியிடப்பட்டது\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"{{fileSizeLimit}} கோப்பு வரம்பு மற்றும் ஒதுக்கீடு, {{remainingBytes}} மீதமுள்ளது\",\n    \"publish_dialog_attachment_limits_file_reached\": \"{{fileSizeLimit}} கோப்பு வரம்பை மீறுகிறது\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"ஒதுக்கீட்டை மீறுகிறது, {{remainingBytes}} மீதமுள்ளவை\",\n    \"publish_dialog_progress_uploading_detail\": \"பதிவேற்றுவது {{loaded}}/{{{total}} ({{percent}}%)…\",\n    \"publish_dialog_priority_min\": \"மணித்துளி. முன்னுரிமை\",\n    \"publish_dialog_emoji_picker_show\": \"ஈமோசியைத் தேர்ந்தெடுங்கள்\",\n    \"publish_dialog_priority_low\": \"குறைந்த முன்னுரிமை\",\n    \"publish_dialog_priority_default\": \"இயல்புநிலை முன்னுரிமை\",\n    \"publish_dialog_priority_high\": \"அதிக முன்னுரிமை\",\n    \"publish_dialog_priority_max\": \"அதிகபட்சம். முன்னுரிமை\",\n    \"publish_dialog_base_url_label\": \"பணி முகவரி\",\n    \"publish_dialog_base_url_placeholder\": \"பணி முகவரி, எ.கா. https://example.com\",\n    \"publish_dialog_topic_label\": \"தலைப்பு பெயர்\",\n    \"publish_dialog_topic_placeholder\": \"தலைப்பு பெயர், எ.கா. phil_alerts\",\n    \"publish_dialog_topic_reset\": \"தலைப்பை மீட்டமைக்கவும்\",\n    \"publish_dialog_title_label\": \"தலைப்பு\",\n    \"publish_dialog_title_placeholder\": \"அறிவிப்பு தலைப்பு, எ.கா. வட்டு விண்வெளி எச்சரிக்கை\",\n    \"publish_dialog_message_label\": \"செய்தி\",\n    \"publish_dialog_message_placeholder\": \"இங்கே ஒரு செய்தியைத் தட்டச்சு செய்க\",\n    \"publish_dialog_tags_label\": \"குறிச்சொற்கள்\",\n    \"publish_dialog_tags_placeholder\": \"குறிச்சொற்களின் கமாவால் பிரிக்கப்பட்ட பட்டியல், எ.கா. எச்சரிக்கை, SRV1-Backup\",\n    \"publish_dialog_priority_label\": \"முன்னுரிமை\",\n    \"publish_dialog_click_label\": \"முகவரி ஐக் சொடுக்கு செய்க\",\n    \"publish_dialog_click_placeholder\": \"அறிவிப்பைக் சொடுக்கு செய்யும் போது திறக்கப்படும் முகவரி\",\n    \"publish_dialog_click_reset\": \"சொடுக்கு முகவரி ஐ அகற்று\",\n    \"publish_dialog_email_label\": \"மின்னஞ்சல்\",\n    \"publish_dialog_email_placeholder\": \"அறிவிப்பை அனுப்ப முகவரி, எ.கா. phil@example.com\",\n    \"publish_dialog_email_reset\": \"மின்னஞ்சலை முன்னோக்கி அகற்றவும்\",\n    \"publish_dialog_call_label\": \"தொலைபேசி அழைப்பு\",\n    \"publish_dialog_call_item\": \"தொலைபேசி எண்ணை அழைக்கவும் {{number}}\",\n    \"publish_dialog_call_reset\": \"தொலைபேசி அழைப்பை அகற்று\",\n    \"publish_dialog_attach_label\": \"இணைப்பு முகவரி\",\n    \"publish_dialog_attach_placeholder\": \"முகவரி ஆல் கோப்பை இணைக்கவும், எ.கா. https://f-droid.org/f-droid.apk\",\n    \"publish_dialog_attach_reset\": \"இணைப்பு முகவரி ஐ அகற்று\",\n    \"publish_dialog_filename_label\": \"கோப்புப்பெயர்\",\n    \"publish_dialog_filename_placeholder\": \"இணைப்பு கோப்பு பெயர்\",\n    \"publish_dialog_delay_label\": \"சுணக்கம்\",\n    \"publish_dialog_delay_placeholder\": \"நேரந்தவறுகை வழங்கல், எ.கா. {{unixTimestamp}}, {{relativeTime}}, அல்லது \\\"{{naturalLanguage}}\\\" (ஆங்கிலம் மட்டும்)\",\n    \"publish_dialog_delay_reset\": \"தாமதமான விநியோகத்தை அகற்று\",\n    \"publish_dialog_other_features\": \"பிற அம்சங்கள்:\",\n    \"publish_dialog_chip_click_label\": \"முகவரி ஐக் சொடுக்கு செய்க\",\n    \"publish_dialog_chip_call_label\": \"தொலைபேசி அழைப்பு\",\n    \"publish_dialog_chip_email_label\": \"மின்னஞ்சலுக்கு அனுப்பவும்\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை\",\n    \"publish_dialog_chip_attach_url_label\": \"முகவரி மூலம் கோப்பை இணைக்கவும்\",\n    \"publish_dialog_details_examples_description\": \"எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <docsLink>ஆவணங்கள் </docsLink> ஐப் பார்க்கவும்.\",\n    \"publish_dialog_chip_attach_file_label\": \"உள்ளக கோப்பை இணைக்கவும்\",\n    \"publish_dialog_chip_delay_label\": \"நேரந்தவறுகை வழங்கல்\",\n    \"publish_dialog_chip_topic_label\": \"தலைப்பை மாற்றவும்\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"பெயரை உருவாக்குங்கள்\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"வலை பயன்பாடு திறக்கப்படாதபோது பிற சேவையகங்களிலிருந்து அறிவிப்புகள் பெறப்படாது\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"ரத்துசெய்\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"குழுசேர்\",\n    \"subscribe_dialog_login_title\": \"உள்நுழைவு தேவை\",\n    \"account_basics_password_dialog_confirm_password_label\": \"கடவுச்சொல்லை உறுதிப்படுத்தவும்\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"கடவுச்சொல் தவறானது\",\n    \"account_basics_password_dialog_button_submit\": \"கடவுச்சொல்லை மாற்றவும்\",\n    \"account_basics_phone_numbers_title\": \"தொலைபேசி எண்கள்\",\n    \"account_basics_phone_numbers_dialog_description\": \"அழைப்பு அறிவிப்பு அம்சத்தைப் பயன்படுத்த, நீங்கள் குறைந்தது ஒரு தொலைபேசி எண்ணையாவது சேர்த்து சரிபார்க்க வேண்டும். சரிபார்ப்பு எச்எம்எச் அல்லது தொலைபேசி அழைப்பு வழியாக செய்யப்படலாம்.\",\n    \"account_basics_phone_numbers_description\": \"தொலைபேசி அழைப்பு அறிவிப்புகளுக்கு\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"தொலைபேசி எண்கள் இதுவரை இல்லை\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"தொலைபேசி எண் இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n    \"account_basics_phone_numbers_dialog_title\": \"தொலைபேசி எண்ணைச் சேர்க்கவும்\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"எ.கா. +122333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"எச்எம்எச் அனுப்பு\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"எ.கா. 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"குறியீட்டை உறுதிப்படுத்தவும்\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"என்னை அழைக்கவும்\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"சரிபார்ப்பு குறியீடு\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"எச்.எம்.எச்\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"அழைப்பு\",\n    \"account_usage_title\": \"பயன்பாடு\",\n    \"account_usage_unlimited\": \"வரம்பற்றது\",\n    \"account_usage_of_limit\": \"{{limit}} of\",\n    \"account_usage_limits_reset_daily\": \"பயன்பாட்டு வரம்புகள் நள்ளிரவில் தினமும் மீட்டமைக்கப்படுகின்றன (UTC)\",\n    \"account_basics_tier_title\": \"கணக்கு வகை\",\n    \"account_basics_tier_description\": \"உங்கள் கணக்கின் ஆற்றல் நிலை\",\n    \"account_basics_tier_admin\": \"நிர்வாகி\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"({{tier}} அடுக்கு)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(அடுக்கு இல்லை)\",\n    \"account_basics_tier_basic\": \"அடிப்படை\",\n    \"account_basics_tier_free\": \"இலவசம்\",\n    \"account_basics_tier_interval_monthly\": \"மாதாந்திர\",\n    \"account_basics_tier_interval_yearly\": \"ஆண்டுதோறும்\",\n    \"account_basics_tier_upgrade_button\": \"சார்புக்கு மேம்படுத்தவும்\",\n    \"account_basics_tier_change_button\": \"மாற்றம்\",\n    \"account_basics_tier_paid_until\": \"சந்தா {{date}} வரை செலுத்தப்படுகிறது, மேலும் தானாக புதுப்பிக்கப்படும்\",\n    \"account_basics_tier_canceled_subscription\": \"உங்கள் சந்தா ரத்து செய்யப்பட்டது மற்றும் {{date} at இல் இலவச கணக்கிற்கு தரமிறக்கப்படும்.\",\n    \"account_basics_tier_manage_billing_button\": \"பட்டியலிடல் நிர்வகிக்கவும்\",\n    \"account_basics_tier_payment_overdue\": \"உங்கள் கட்டணம் தாமதமானது. தயவுசெய்து உங்கள் கட்டண முறையைப் புதுப்பிக்கவும், அல்லது உங்கள் கணக்கு விரைவில் தரமிறக்கப்படும்.\",\n    \"account_usage_messages_title\": \"வெளியிடப்பட்ட செய்திகள்\",\n    \"account_usage_emails_title\": \"மின்னஞ்சல்கள் அனுப்பப்பட்டன\",\n    \"account_usage_calls_title\": \"தொலைபேசி அழைப்புகள் செய்யப்பட்டன\",\n    \"account_usage_calls_none\": \"இந்த கணக்கில் தொலைபேசி அழைப்புகள் எதுவும் செய்ய முடியாது\",\n    \"account_usage_reservations_title\": \"ஒதுக்கப்பட்ட தலைப்புகள்\",\n    \"account_usage_reservations_none\": \"இந்த கணக்கிற்கு ஒதுக்கப்பட்ட தலைப்புகள் இல்லை\",\n    \"account_usage_attachment_storage_title\": \"இணைப்பு சேமிப்பு\",\n    \"account_usage_attachment_storage_description\": \"கோப்பு {{filesize}} க்குப் பிறகு நீக்கப்பட்ட ஒரு கோப்பிற்கு {{expiry}}}}\",\n    \"account_usage_basis_ip_description\": \"இந்த கணக்கிற்கான பயன்பாட்டு புள்ளிவிவரங்கள் மற்றும் வரம்புகள் உங்கள் ஐபி முகவரியை அடிப்படையாகக் கொண்டவை, எனவே அவை மற்ற பயனர்களுடன் பகிரப்படலாம். மேலே காட்டப்பட்டுள்ள வரம்புகள் தற்போதுள்ள விகித வரம்புகளின் அடிப்படையில் தோராயங்கள்.\",\n    \"account_usage_cannot_create_portal_session\": \"பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை\",\n    \"account_delete_title\": \"கணக்கை நீக்கு\",\n    \"account_delete_description\": \"உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்\",\n    \"account_upgrade_dialog_cancel_warning\": \"இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date}} இல் தரமிறக்குகிறது. அந்தத் தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாகச் சேமிக்கப்பட்ட செய்திகளும் <strong>நீக்கப்படும் </strong>.\",\n    \"account_upgrade_dialog_proration_info\": \"<strong> புரோரேசன் </strong>: கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு <strong> உடனடியாக கட்டணம் வசூலிக்கப்படும் </strong>. குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"ஒதுக்கப்பட்ட தலைப்புகள் இல்லை\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} நாள்தோறும் செய்தி\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} நாள்தோறும் செய்திகள்\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} நாள்தோறும் மின்னஞ்சல்\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} நாள்தோறும் மின்னஞ்சல்கள்\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} நாள்தோறும் தொலைபேசி அழைப்புகள்\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} நாள்தோறும் தொலைபேசி அழைப்புகள்\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} மொத்த சேமிப்பு\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"மாதம்\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}}}}}}. மாதந்தோறும் பாடு.\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"தொலைபேசி அழைப்புகள் இல்லை\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"கோப்பு {filesize}}} ஒரு கோப்பிற்கு\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.\",\n    \"account_upgrade_dialog_tier_selected_label\": \"தேர்ந்தெடுக்கப்பட்டது\",\n    \"account_upgrade_dialog_tier_current_label\": \"மின்னோட்ட்ம், ஓட்டம்\",\n    \"account_upgrade_dialog_billing_contact_email\": \"பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <Link>எங்களைத் தொடர்பு கொள்ளவும் </Link>நேரடியாக.\",\n    \"account_upgrade_dialog_button_cancel\": \"ரத்துசெய்\",\n    \"account_upgrade_dialog_billing_contact_website\": \"பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <Link>வலைத்தளம்</Link> ஐப் பார்க்கவும்.\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"இப்போது பதிவுபெறுக\",\n    \"account_upgrade_dialog_button_pay_now\": \"இப்போது பணம் செலுத்தி குழுசேரவும்\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"சந்தாவை ரத்துசெய்\",\n    \"account_tokens_title\": \"டோக்கன்களை அணுகவும்\",\n    \"account_tokens_description\": \"NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும்போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <Link> ஆவணங்கள்</Link> ஐப் பாருங்கள்.\",\n    \"account_upgrade_dialog_button_update_subscription\": \"சந்தாவைப் புதுப்பிக்கவும்\",\n    \"account_tokens_table_token_header\": \"கிள்ளாக்கு\",\n    \"account_tokens_table_label_header\": \"சிட்டை\",\n    \"account_tokens_table_last_access_header\": \"கடைசி அணுகல்\",\n    \"account_tokens_table_expires_header\": \"காலாவதியாகிறது\",\n    \"account_tokens_table_never_expires\": \"ஒருபோதும் காலாவதியாகாது\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"தற்போதைய அமர்வு டோக்கனைத் திருத்தவோ நீக்கவோ முடியாது\",\n    \"account_tokens_table_current_session\": \"தற்போதைய உலாவி அமர்வு\",\n    \"account_tokens_table_copied_to_clipboard\": \"அணுகல் கிள்ளாக்கு நகலெடுக்கப்பட்டது\",\n    \"account_tokens_table_create_token_button\": \"அணுகல் கிள்ளாக்கை உருவாக்கவும்\",\n    \"account_tokens_dialog_title_create\": \"அணுகல் கிள்ளாக்கை உருவாக்கவும்\",\n    \"account_tokens_table_last_origin_tooltip\": \"ஐபி முகவரி {{ip} இருந்து இலிருந்து, தேடலைக் சொடுக்கு செய்க\",\n    \"account_tokens_dialog_title_edit\": \"அணுகல் டோக்கனைத் திருத்தவும்\",\n    \"account_tokens_dialog_title_delete\": \"அணுகல் கிள்ளாக்கை நீக்கு\",\n    \"account_tokens_dialog_label\": \"சிட்டை, எ.கா. ராடார் அறிவிப்புகள்\",\n    \"account_tokens_dialog_button_create\": \"கிள்ளாக்கை உருவாக்கவும்\",\n    \"account_tokens_dialog_button_update\": \"கிள்ளாக்கைப் புதுப்பிக்கவும்\",\n    \"account_tokens_dialog_button_cancel\": \"ரத்துசெய்\",\n    \"account_tokens_dialog_expires_label\": \"அணுகல் கிள்ளாக்கு காலாவதியாகிறது\",\n    \"account_tokens_dialog_expires_unchanged\": \"காலாவதி தேதி மாறாமல் விடுங்கள்\",\n    \"account_tokens_dialog_expires_x_hours\": \"கிள்ளாக்கு {{hours}} மணிநேரங்களில் காலாவதியாகிறது\",\n    \"account_tokens_dialog_expires_x_days\": \"கிள்ளாக்கு {{days}} நாட்களில் காலாவதியாகிறது\",\n    \"account_tokens_dialog_expires_never\": \"கிள்ளாக்கு ஒருபோதும் காலாவதியாகாது\",\n    \"account_tokens_delete_dialog_title\": \"அணுகல் கிள்ளாக்கை நீக்கு\",\n    \"account_tokens_delete_dialog_description\": \"அணுகல் கிள்ளாக்கை நீக்குவதற்கு முன், பயன்பாடுகள் அல்லது ச்கிரிப்ட்கள் எதுவும் தீவிரமாகப் பயன்படுத்தவில்லை என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள். <strong> இந்த செயலை செயல்தவிர்க்க முடியாது </strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"கிள்ளாக்கை நிரந்தரமாக நீக்கு\",\n    \"prefs_notifications_title\": \"அறிவிப்புகள்\",\n    \"prefs_notifications_sound_title\": \"அறிவிப்பு ஒலி\",\n    \"prefs_notifications_sound_description_none\": \"அறிவிப்புகள் வரும்போது எந்த ஒலியையும் இயக்காது\",\n    \"prefs_notifications_sound_description_some\": \"அறிவிப்புகள் வரும்போது {{sound}} ஒலியை இயக்குகின்றன\",\n    \"prefs_notifications_sound_no_sound\": \"ஒலி இல்லை\",\n    \"prefs_notifications_sound_play\": \"தேர்ந்தெடுக்கப்பட்ட ஒலி விளையாடுங்கள்\",\n    \"prefs_notifications_min_priority_title\": \"குறைந்தபட்ச முன்னுரிமை\",\n    \"prefs_notifications_min_priority_description_any\": \"முன்னுரிமையைப் பொருட்படுத்தாமல் அனைத்து அறிவிப்புகளையும் காட்டுகிறது\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"முன்னுரிமை {{number}} ({{name}}) அல்லது அதற்கு மேல் இருந்தால் அறிவிப்புகளைக் காட்டு\",\n    \"prefs_notifications_min_priority_description_max\": \"முன்னுரிமை 5 (அதிகபட்சம்) என்றால் அறிவிப்புகளைக் காட்டு\",\n    \"prefs_notifications_min_priority_any\": \"எந்த முன்னுரிமையும்\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"குறைந்த முன்னுரிமை மற்றும் அதிக\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"இயல்புநிலை முன்னுரிமை மற்றும் அதிக\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"அதிக முன்னுரிமை மற்றும் அதிக\",\n    \"prefs_notifications_min_priority_max_only\": \"அதிகபட்ச முன்னுரிமை மட்டுமே\",\n    \"prefs_notifications_delete_after_title\": \"அறிவிப்புகளை நீக்கு\",\n    \"prefs_notifications_delete_after_never\": \"ஒருபோதும்\",\n    \"prefs_notifications_delete_after_three_hours\": \"மூன்று மணி நேரம் கழித்து\",\n    \"prefs_notifications_delete_after_one_day\": \"ஒரு நாள் கழித்து\",\n    \"prefs_notifications_delete_after_one_week\": \"ஒரு வாரம் கழித்து\",\n    \"prefs_notifications_delete_after_one_month\": \"ஒரு மாதத்திற்குப் பிறகு\",\n    \"prefs_notifications_delete_after_never_description\": \"அறிவிப்புகள் ஒருபோதும் தானாக நீக்கப்படவில்லை\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"அறிவிப்புகள் மூன்று மணி நேரத்திற்குப் பிறகு தானாக நீக்கப்படும்\",\n    \"prefs_notifications_delete_after_one_day_description\": \"அறிவிப்புகள் ஒரு நாளுக்குப் பிறகு தானாக நீக்கப்படும்\",\n    \"prefs_notifications_delete_after_one_week_description\": \"அறிவிப்புகள் ஒரு வாரத்திற்குப் பிறகு தானாக நீக்கப்படும்\",\n    \"prefs_notifications_delete_after_one_month_description\": \"அறிவிப்புகள் ஒரு மாதத்திற்குப் பிறகு தானாக நீக்கப்படும்\",\n    \"prefs_notifications_web_push_title\": \"பின்னணி அறிவிப்புகள்\",\n    \"prefs_notifications_web_push_enabled_description\": \"வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)\",\n    \"prefs_notifications_web_push_disabled_description\": \"வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)\",\n    \"prefs_notifications_web_push_enabled\": \"{{server}} க்கு இயக்கப்பட்டது\",\n    \"prefs_notifications_web_push_disabled\": \"முடக்கப்பட்டது\",\n    \"prefs_users_title\": \"பயனர்களை நிர்வகிக்கவும்\",\n    \"prefs_users_description\": \"உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.\",\n    \"prefs_users_description_no_sync\": \"பயனர்கள் மற்றும் கடவுச்சொற்கள் உங்கள் கணக்கில் ஒத்திசைக்கப்படவில்லை.\",\n    \"prefs_users_table\": \"பயனர்கள் அட்டவணை\",\n    \"prefs_users_edit_button\": \"பயனரைத் திருத்து\",\n    \"prefs_users_delete_button\": \"பயனரை நீக்கு\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"உள்நுழைந்த பயனரை நீக்கவோ திருத்தவோ முடியாது\",\n    \"prefs_users_table_user_header\": \"பயனர்\",\n    \"prefs_users_table_base_url_header\": \"பணி முகவரி\",\n    \"prefs_users_dialog_title_add\": \"பயனரைச் சேர்க்கவும்\",\n    \"prefs_users_dialog_title_edit\": \"பயனரைத் திருத்து\",\n    \"prefs_users_dialog_base_url_label\": \"பணி முகவரி, எ.கா. https://ntfy.sh\",\n    \"prefs_users_dialog_username_label\": \"பயனர்பெயர், எ.கா. பில்\",\n    \"prefs_users_dialog_password_label\": \"கடவுச்சொல்\",\n    \"prefs_appearance_title\": \"தோற்றம்\",\n    \"prefs_appearance_language_title\": \"மொழி\",\n    \"prefs_appearance_theme_title\": \"கருப்பொருள்\",\n    \"prefs_appearance_theme_system\": \"கணினி (இயல்புநிலை)\",\n    \"prefs_appearance_theme_dark\": \"இருண்ட முறை\",\n    \"prefs_appearance_theme_light\": \"ஒளி பயன்முறை\",\n    \"prefs_reservations_title\": \"ஒதுக்கப்பட்ட தலைப்புகள்\",\n    \"prefs_reservations_description\": \"தனிப்பட்ட பயன்பாட்டிற்காக தலைப்பு பெயர்களை இங்கே முன்பதிவு செய்யலாம். ஒரு தலைப்பை முன்பதிவு செய்வது தலைப்பின் மீது உங்களுக்கு உரிமையை அளிக்கிறது, மேலும் தலைப்பில் பிற பயனர்களுக்கான அணுகல் அனுமதிகளை வரையறுக்க உங்களை அனுமதிக்கிறது.\",\n    \"prefs_reservations_limit_reached\": \"உங்கள் ஒதுக்கப்பட்ட தலைப்புகளின் வரம்பை நீங்கள் அடைந்தீர்கள்.\",\n    \"prefs_reservations_add_button\": \"ஒதுக்கப்பட்ட தலைப்பைச் சேர்க்கவும்\",\n    \"prefs_reservations_edit_button\": \"தலைப்பு அணுகலைத் திருத்தவும்\",\n    \"prefs_reservations_delete_button\": \"தலைப்பு அணுகலை மீட்டமைக்கவும்\",\n    \"prefs_reservations_table\": \"ஒதுக்கப்பட்ட தலைப்புகள் அட்டவணை\",\n    \"prefs_reservations_table_topic_header\": \"தலைப்பு\",\n    \"prefs_reservations_table_access_header\": \"அணுகல்\",\n    \"prefs_reservations_table_everyone_deny_all\": \"நான் மட்டுமே வெளியிட்டு குழுசேர முடியும்\",\n    \"prefs_reservations_table_everyone_read_only\": \"நான் வெளியிட்டு குழுசேரலாம், அனைவரும் குழுசேரலாம்\",\n    \"prefs_reservations_table_everyone_write_only\": \"நான் வெளியிட்டு குழுசேரலாம், எல்லோரும் வெளியிடலாம்\",\n    \"prefs_reservations_table_everyone_read_write\": \"எல்லோரும் வெளியிட்டு குழுசேரலாம்\",\n    \"prefs_reservations_table_not_subscribed\": \"குழுசேரவில்லை\",\n    \"prefs_reservations_table_click_to_subscribe\": \"குழுசேர சொடுக்கு செய்க\",\n    \"prefs_reservations_dialog_title_add\": \"இருப்பு தலைப்பு\",\n    \"prefs_reservations_dialog_title_edit\": \"ஒதுக்கப்பட்ட தலைப்பைத் திருத்து\",\n    \"prefs_reservations_dialog_title_delete\": \"தலைப்பு முன்பதிவை நீக்கு\",\n    \"prefs_reservations_dialog_description\": \"ஒரு தலைப்பை முன்பதிவு செய்வது தலைப்பின் மீது உங்களுக்கு உரிமையை அளிக்கிறது, மேலும் தலைப்பில் பிற பயனர்களுக்கான அணுகல் அனுமதிகளை வரையறுக்க உங்களை அனுமதிக்கிறது.\",\n    \"prefs_reservations_dialog_topic_label\": \"தலைப்பு\",\n    \"prefs_reservations_dialog_access_label\": \"அணுகல்\",\n    \"reservation_delete_dialog_description\": \"முன்பதிவை அகற்றுவது தலைப்பின் மீது உரிமையை அளிக்கிறது, மேலும் மற்றவர்கள் அதை முன்பதிவு செய்ய அனுமதிக்கிறது. ஏற்கனவே உள்ள செய்திகளையும் இணைப்புகளையும் வைத்திருக்கலாம் அல்லது நீக்கலாம்.\",\n    \"reservation_delete_dialog_action_keep_title\": \"தற்காலிக சேமிப்பு செய்திகள் மற்றும் இணைப்புகளை வைத்திருங்கள்\",\n    \"reservation_delete_dialog_action_keep_description\": \"சேவையகத்தில் தற்காலிக சேமிப்பில் உள்ள செய்திகள் மற்றும் இணைப்புகள் தலைப்புப் பெயரைப் பற்றிய அறிவுள்ளவர்களுக்கு பகிரங்கமாகத் தெரியும்.\",\n    \"reservation_delete_dialog_action_delete_title\": \"தற்காலிக சேமிப்பு செய்திகள் மற்றும் இணைப்புகளை நீக்கவும்\",\n    \"reservation_delete_dialog_submit_button\": \"முன்பதிவை நீக்கு\",\n    \"reservation_delete_dialog_action_delete_description\": \"தற்காலிக சேமிக்கப்பட்ட செய்திகள் மற்றும் இணைப்புகள் நிரந்தரமாக நீக்கப்படும். இந்த செயலை செயல்தவிர்க்க முடியாது.\",\n    \"priority_min\": \"மணித்துளி\",\n    \"priority_low\": \"குறைந்த\",\n    \"priority_high\": \"உயர்ந்த\",\n    \"priority_max\": \"அதிகபட்சம்\",\n    \"priority_default\": \"இயல்புநிலை\",\n    \"error_boundary_title\": \"ஓ, NTFY செயலிழந்தது\",\n    \"error_boundary_description\": \"இது நிச்சயமாக நடக்கக் கூடாது. இதுகுறித்து மிகவும் வருந்துகிறேன்.<br/>உங்களிடம் ஒரு நிமிடம் இருந்தால், தயவுசெய்து <githubLink>இதை GitHub இல் புகாரளிக்கவும்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> வழியாக எங்களுக்குத் தெரியப்படுத்தவும்.\",\n    \"error_boundary_button_copy_stack_trace\": \"அடுக்கு சுவடு நகலெடுக்கவும்\",\n    \"error_boundary_button_reload_ntfy\": \"Ntfy ஐ மீண்டும் ஏற்றவும்\",\n    \"error_boundary_stack_trace\": \"ச்டாக் சுவடு\",\n    \"error_boundary_gathering_info\": \"மேலும் தகவலை சேகரிக்கவும்…\",\n    \"error_boundary_unsupported_indexeddb_title\": \"தனியார் உலாவல் ஆதரிக்கப்படவில்லை\",\n    \"common_cancel\": \"ரத்துசெய்\",\n    \"common_save\": \"சேமி\",\n    \"common_add\": \"கூட்டு\",\n    \"common_back\": \"பின்\",\n    \"common_copy_to_clipboard\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n    \"signup_title\": \"ஒரு NTFY கணக்கை உருவாக்கவும்\",\n    \"signup_form_username\": \"பயனர்பெயர்\",\n    \"signup_form_password\": \"கடவுச்சொல்\",\n    \"signup_form_confirm_password\": \"கடவுச்சொல்லை உறுதிப்படுத்தவும்\",\n    \"signup_form_button_submit\": \"பதிவு செய்க\",\n    \"signup_form_toggle_password_visibility\": \"கடவுச்சொல் தெரிவுநிலையை மாற்றவும்\",\n    \"signup_already_have_account\": \"ஏற்கனவே ஒரு கணக்கு இருக்கிறதா? உள்நுழைக!\",\n    \"signup_disabled\": \"கையொப்பம் முடக்கப்பட்டுள்ளது\",\n    \"signup_error_username_taken\": \"பயனர்பெயர் {{username}} ஏற்கனவே எடுக்கப்பட்டுள்ளது\",\n    \"signup_error_creation_limit_reached\": \"கணக்கு உருவாக்கும் வரம்பு எட்டப்பட்டது\",\n    \"login_title\": \"உங்கள் NTFY கணக்கில் உள்நுழைக\",\n    \"login_form_button_submit\": \"விடுபதிகை\",\n    \"login_link_signup\": \"பதிவு செய்க\",\n    \"login_disabled\": \"உள்நுழைவு முடக்கப்பட்டுள்ளது\",\n    \"action_bar_reservation_edit\": \"முன்பதிவை மாற்றவும்\",\n    \"action_bar_reservation_delete\": \"முன்பதிவை அகற்று\",\n    \"action_bar_reservation_limit_reached\": \"வரம்பு எட்டப்பட்டது\",\n    \"action_bar_send_test_notification\": \"சோதனை அறிவிப்பை அனுப்பவும்\",\n    \"action_bar_clear_notifications\": \"எல்லா அறிவிப்புகளையும் அழிக்கவும்\",\n    \"action_bar_mute_notifications\": \"முடக்கு அறிவிப்புகள்\",\n    \"action_bar_unmute_notifications\": \"ஊடுருவல் அறிவிப்புகள்\",\n    \"action_bar_unsubscribe\": \"குழுவிலகவும்\",\n    \"action_bar_toggle_mute\": \"முடக்கு/அசைவது அறிவிப்புகள்\",\n    \"action_bar_toggle_action_menu\": \"செயல் மெனுவைத் திறக்க/மூடு\",\n    \"action_bar_profile_title\": \"சுயவிவரம்\",\n    \"action_bar_profile_settings\": \"அமைப்புகள்\",\n    \"action_bar_profile_logout\": \"வெளியேற்றம்\",\n    \"action_bar_sign_in\": \"விடுபதிகை\",\n    \"action_bar_sign_up\": \"பதிவு செய்க\",\n    \"message_bar_type_message\": \"இங்கே ஒரு செய்தியைத் தட்டச்சு செய்க\",\n    \"message_bar_error_publishing\": \"பிழை வெளியீட்டு அறிவிப்பு\",\n    \"message_bar_show_dialog\": \"வெளியீட்டு உரையாடலைக் காட்டு\",\n    \"nav_button_subscribe\": \"தலைப்புக்கு குழுசேரவும்\",\n    \"nav_button_muted\": \"அறிவிப்புகள் முடக்கப்பட்டன\",\n    \"nav_button_connecting\": \"இணைத்தல்\",\n    \"nav_upgrade_banner_label\": \"Ntfy Pro க்கு மேம்படுத்தவும்\",\n    \"nav_upgrade_banner_description\": \"தலைப்புகள், கூடுதல் செய்திகள் மற்றும் மின்னஞ்சல்கள் மற்றும் பெரிய இணைப்புகளை முன்பதிவு செய்யுங்கள்\",\n    \"alert_notification_permission_required_title\": \"அறிவிப்புகள் முடக்கப்பட்டுள்ளன\",\n    \"alert_notification_permission_required_description\": \"டெச்க்டாப் அறிவிப்புகளைக் காண்பிக்க உங்கள் உலாவி இசைவு வழங்கவும்\",\n    \"alert_notification_permission_required_button\": \"இப்போது வழங்கவும்\",\n    \"alert_notification_permission_denied_title\": \"அறிவிப்புகள் தடுக்கப்பட்டுள்ளன\",\n    \"alert_notification_permission_denied_description\": \"தயவுசெய்து அவற்றை உங்கள் உலாவியில் மீண்டும் இயக்கவும்\",\n    \"alert_notification_ios_install_required_title\": \"ஐஇமு நிறுவல் தேவை\",\n    \"alert_notification_ios_install_required_description\": \"ஐஇமு இல் அறிவிப்புகளை இயக்க பகிர்வு ஐகானைக் சொடுக்கு செய்து முகப்புத் திரையில் சேர்க்கவும்\",\n    \"alert_not_supported_title\": \"அறிவிப்புகள் ஆதரிக்கப்படவில்லை\",\n    \"notifications_new_indicator\": \"புதிய அறிவிப்பு\",\n    \"notifications_attachment_image\": \"இணைப்பு படம்\",\n    \"notifications_attachment_copy_url_title\": \"இணைப்பு முகவரி ஐ இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n    \"notifications_attachment_copy_url_button\": \"முகவரி ஐ நகலெடுக்கவும்\",\n    \"notifications_attachment_open_title\": \"{{url}} க்குச் செல்லவும்\",\n    \"notifications_attachment_open_button\": \"திறந்த இணைப்பு\",\n    \"notifications_attachment_link_expires\": \"இணைப்பு காலாவதியாகிறது {{date}}\",\n    \"notifications_attachment_link_expired\": \"இணைப்பு காலாவதியான பதிவிறக்க\",\n    \"notifications_attachment_file_image\": \"பட கோப்பு\",\n    \"notifications_attachment_file_video\": \"வீடியோ கோப்பு\",\n    \"notifications_attachment_file_audio\": \"ஆடியோ கோப்பு\",\n    \"notifications_attachment_file_app\": \"ஆண்ட்ராய்டு பயன்பாட்டு கோப்பு\",\n    \"notifications_attachment_file_document\": \"பிற ஆவணம்\",\n    \"notifications_click_copy_url_title\": \"இடைநிலைப்பலகைக்கு இணைப்பு முகவரி ஐ நகலெடுக்கவும்\",\n    \"notifications_click_copy_url_button\": \"இணைப்பை நகலெடுக்கவும்\",\n    \"notifications_click_open_button\": \"இணைப்பை திற\",\n    \"notifications_actions_open_url_title\": \"{{url}} க்குச் செல்லவும்\",\n    \"notifications_none_for_any_title\": \"உங்களுக்கு எந்த அறிவிப்புகளும் கிடைக்கவில்லை.\",\n    \"notifications_none_for_any_description\": \"ஒரு தலைப்புக்கு அறிவிப்புகளை அனுப்ப, தலைப்பு முகவரி க்கு வைக்கவும் அல்லது இடுகையிடவும். உங்கள் தலைப்புகளில் ஒன்றைப் பயன்படுத்தி இங்கே ஒரு எடுத்துக்காட்டு.\",\n    \"notifications_no_subscriptions_title\": \"உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.\",\n    \"notifications_no_subscriptions_description\": \"ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \\\"{{linktext}}\\\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.\",\n    \"notifications_example\": \"எடுத்துக்காட்டு\",\n    \"notifications_more_details\": \"மேலும் தகவலுக்கு, <websiteLink>வலைத்தளம் </websiteLink> அல்லது <docsLink> ஆவணங்கள் </docsLink> ஐப் பாருங்கள்.\",\n    \"display_name_dialog_title\": \"காட்சி பெயரை மாற்றவும்\",\n    \"display_name_dialog_description\": \"சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.\",\n    \"display_name_dialog_placeholder\": \"காட்சி பெயர்\",\n    \"reserve_dialog_checkbox_label\": \"தலைப்பை முன்பதிவு செய்து அணுகலை உள்ளமைக்கவும்\",\n    \"publish_dialog_button_cancel_sending\": \"அனுப்புவதை ரத்துசெய்\",\n    \"publish_dialog_button_cancel\": \"ரத்துசெய்\",\n    \"publish_dialog_button_send\": \"அனுப்பு\",\n    \"publish_dialog_checkbox_markdown\": \"மார்க் பேரூர் என வடிவம்\",\n    \"publish_dialog_checkbox_publish_another\": \"மற்றொன்றை வெளியிடுங்கள்\",\n    \"publish_dialog_attached_file_title\": \"இணைக்கப்பட்ட கோப்பு:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"இணைப்பு கோப்பு பெயர்\",\n    \"publish_dialog_attached_file_remove\": \"இணைக்கப்பட்ட கோப்பை அகற்று\",\n    \"publish_dialog_drop_file_here\": \"கோப்பை இங்கே விடுங்கள்\",\n    \"emoji_picker_search_placeholder\": \"ஈமோசியைத் தேடுங்கள்\",\n    \"emoji_picker_search_clear\": \"தேடலை அழி\",\n    \"subscribe_dialog_subscribe_title\": \"தலைப்புக்கு குழுசேரவும்\",\n    \"subscribe_dialog_subscribe_description\": \"தலைப்புகள் கடவுச்சொல் பாதுகாக்கப்பட்டதாக இருக்காது, எனவே யூகிக்க எளிதான பெயரைத் தேர்வுசெய்க. சந்தா செலுத்தியதும், நீங்கள் அறிவிப்புகளை வைக்கலாம்/இடுகையிடலாம்.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"தலைப்பு பெயர், எ.கா. phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"மற்றொரு சேவையகத்தைப் பயன்படுத்தவும்\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"பணி முகவரி\",\n    \"subscribe_dialog_login_description\": \"இந்த தலைப்பு கடவுச்சொல் பாதுகாக்கப்படுகிறது. குழுசேர பயனர்பெயர் மற்றும் கடவுச்சொல்லை உள்ளிடவும்.\",\n    \"subscribe_dialog_login_username_label\": \"பயனர்பெயர், எ.கா. பில்\",\n    \"subscribe_dialog_login_password_label\": \"கடவுச்சொல்\",\n    \"subscribe_dialog_login_button_login\": \"புகுபதிவு\",\n    \"subscribe_dialog_error_user_not_authorized\": \"பயனர் {{username}} அங்கீகரிக்கப்படவில்லை\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"தலைப்பு ஏற்கனவே ஒதுக்கப்பட்டுள்ளது\",\n    \"subscribe_dialog_error_user_anonymous\": \"அநாமதேய\",\n    \"account_basics_title\": \"கணக்கு\",\n    \"account_basics_username_title\": \"பயனர்பெயர்\",\n    \"account_basics_username_description\": \"ஏய், அது நீங்கள் தான்\",\n    \"account_basics_username_admin_tooltip\": \"நீங்கள் நிர்வாகி\",\n    \"account_basics_password_title\": \"கடவுச்சொல்\",\n    \"account_basics_password_description\": \"உங்கள் கணக்கு கடவுச்சொல்லை மாற்றவும்\",\n    \"account_basics_password_dialog_title\": \"கடவுச்சொல்லை மாற்றவும்\",\n    \"account_basics_password_dialog_current_password_label\": \"தற்போதைய கடவுச்சொல்\",\n    \"account_basics_password_dialog_new_password_label\": \"புதிய கடவுச்சொல்\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"தொலைபேசி எண்\",\n    \"account_delete_dialog_description\": \"இது சேவையகத்தில் சேமிக்கப்பட்டுள்ள அனைத்து தரவுகளும் உட்பட உங்கள் கணக்கை நிரந்தரமாக நீக்கும். நீக்கப்பட்ட பிறகு, உங்கள் பயனர்பெயர் 7 நாட்களுக்கு கிடைக்காது. நீங்கள் உண்மையிலேயே தொடர விரும்பினால், கீழே உள்ள பெட்டியில் உங்கள் கடவுச்சொல்லை உறுதிப்படுத்தவும்.\",\n    \"account_delete_dialog_label\": \"கடவுச்சொல்\",\n    \"account_delete_dialog_button_cancel\": \"ரத்துசெய்\",\n    \"account_delete_dialog_button_submit\": \"கணக்கை நிரந்தரமாக நீக்கு\",\n    \"account_delete_dialog_billing_warning\": \"உங்கள் கணக்கை நீக்குவது உடனடியாக உங்கள் பட்டியலிடல் சந்தாவை ரத்து செய்கிறது. உங்களுக்கு இனி பட்டியலிடல் டாச்போர்டுக்கு அணுகல் இருக்காது.\",\n    \"account_upgrade_dialog_title\": \"கணக்கு அடுக்கை மாற்றவும்\",\n    \"account_upgrade_dialog_interval_monthly\": \"மாதாந்திர\",\n    \"account_upgrade_dialog_interval_yearly\": \"ஆண்டுதோறும்\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"{{discount}}% சேமிக்கவும்\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"{{discount}}% வரை சேமிக்கவும்\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு\",\n    \"prefs_users_add_button\": \"பயனரைச் சேர்க்கவும்\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy வலை பயன்பாடு செயல்பட IndexedDB தேவை, மேலும் உங்கள் உலாவித் தனிப்பட்ட உலாவல் பயன்முறையில் IndexedDB ஐ ஆதரிக்காது.<br/><br/>இது துரதிர்ஷ்டவசமானது என்றாலும், ntfy வலை பயன்பாட்டைத் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படுத்துவது உண்மையில் அர்த்தமற்றது, ஏனெனில் அனைத்தும் உலாவிச் சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இதைப் பற்றி நீங்கள் <githubLink>இந்த GitHub சிக்கலில் மேலும் படிக்கலாம்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> இல் எங்களுடன் பேசலாம்.\",\n    \"web_push_subscription_expiring_title\": \"அறிவிப்புகள் இடைநிறுத்தப்படும்\",\n    \"web_push_subscription_expiring_body\": \"தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்\",\n    \"web_push_unknown_notification_title\": \"சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது\",\n    \"web_push_unknown_notification_body\": \"வலை பயன்பாட்டைத் திறப்பதன் மூலம் நீங்கள் NTFY ஐ புதுப்பிக்க வேண்டியிருக்கலாம்\"\n}\n"
  },
  {
    "path": "web/public/static/langs/th.json",
    "content": "{\n    \"common_cancel\": \"ยกเลิก\",\n    \"common_save\": \"บันทึก\",\n    \"common_add\": \"เพิ่ม\",\n    \"common_back\": \"กลับ\",\n    \"common_copy_to_clipboard\": \"คัดลอกไปยังคลิปบอร์ด\",\n    \"signup_title\": \"สร้างบัญชี ntfy\",\n    \"signup_form_username\": \"ชื่อผู้ใช้\",\n    \"signup_form_password\": \"รหัสผ่าน\",\n    \"signup_form_confirm_password\": \"ยืนยันรหัสผ่าน\",\n    \"signup_form_button_submit\": \"สมัครสมาชิก\",\n    \"signup_form_toggle_password_visibility\": \"สลับการมองเห็นรหัสผ่าน\",\n    \"signup_already_have_account\": \"มีบัญชีอยู่แล้วใช่ไหม? เข้าสู่ระบบ!\",\n    \"signup_disabled\": \"การลงทะเบียนถูกปิดใช้งาน\",\n    \"signup_error_username_taken\": \"ชื่อผู้ใช้ {{username}} ถูกใช้ไปแล้ว\",\n    \"signup_error_creation_limit_reached\": \"ถึงขีดจำกัดการสร้างบัญชีแล้ว\",\n    \"login_title\": \"ลงชื่อเข้าใช้บัญชี ntfy ของคุณ\",\n    \"login_form_button_submit\": \"ลงชื่อเข้าใช้\",\n    \"login_link_signup\": \"สมัครสมาชิก\",\n    \"login_disabled\": \"การเข้าสู่ระบบถูกปิดใช้งาน\",\n    \"action_bar_show_menu\": \"แสดงเมนู\",\n    \"action_bar_logo_alt\": \"โลโก้ ntfy\",\n    \"action_bar_settings\": \"การตั้งค่า\",\n    \"action_bar_account\": \"บัญชี\",\n    \"action_bar_change_display_name\": \"เปลี่ยนชื่อที่แสดง\",\n    \"action_bar_reservation_add\": \"หัวข้อที่สงวนไว้\",\n    \"action_bar_reservation_edit\": \"เปลี่ยนแปลงการจอง\",\n    \"action_bar_reservation_delete\": \"ลบการจอง\",\n    \"action_bar_reservation_limit_reached\": \"ถึงขีดจำกัดแล้ว\",\n    \"action_bar_send_test_notification\": \"ทดสอบการส่งการแจ้งเตือน\",\n    \"action_bar_clear_notifications\": \"ล้างการแจ้งเตือนทั้งหมด\",\n    \"action_bar_mute_notifications\": \"ปิดเสียงการแจ้งเตือนชั่วคราว\",\n    \"action_bar_unmute_notifications\": \"เปิดเสียงการแจ้งเตือน\",\n    \"action_bar_unsubscribe\": \"ยกเลิกการสมัครรับ\",\n    \"action_bar_toggle_mute\": \"ปิดเสียง/เปิดเสียงการแจ้งเตือน\",\n    \"action_bar_toggle_action_menu\": \"เปิด/ปิดเมนูการดำเนินการ\",\n    \"action_bar_profile_title\": \"โปรไฟล์\",\n    \"action_bar_profile_settings\": \"การตั้งค่า\",\n    \"action_bar_profile_logout\": \"ออกจากระบบ\",\n    \"action_bar_sign_in\": \"ลงชื่อเข้าใช้\",\n    \"action_bar_sign_up\": \"สมัครสมาชิก\",\n    \"message_bar_type_message\": \"พิมพ์ข้อความที่นี่\",\n    \"message_bar_publish\": \"เผยแพร่ข้อความ\",\n    \"nav_topics_title\": \"หัวข้อที่สมัครรับข้อมูล\",\n    \"nav_button_all_notifications\": \"การแจ้งเตือนทั้งหมด\",\n    \"nav_button_account\": \"บัญชี\",\n    \"nav_button_settings\": \"การตั้งค่า\",\n    \"nav_button_documentation\": \"เอกสารประกอบ\",\n    \"nav_button_publish_message\": \"เผยแพร่การแจ้งเตือน\",\n    \"message_bar_error_publishing\": \"เกิดข้อผิดพลาดในการเผยแพร่การแจ้งเตือน\",\n    \"message_bar_show_dialog\": \"แสดงกล่องโต้ตอบการเผยแพร่\",\n    \"nav_button_subscribe\": \"สมัครรับหัวข้อ\",\n    \"nav_button_muted\": \"ปิดการแจ้งเตือน\",\n    \"nav_button_connecting\": \"การเชื่อมต่อ\",\n    \"nav_upgrade_banner_label\": \"อัพเกรดเป็น ntfy Pro\"\n}\n"
  },
  {
    "path": "web/public/static/langs/tr.json",
    "content": "{\n    \"nav_button_subscribe\": \"Konuya abone ol\",\n    \"nav_button_settings\": \"Ayarlar\",\n    \"action_bar_send_test_notification\": \"Test bildirimi gönder\",\n    \"message_bar_type_message\": \"Buraya bir mesaj yazın\",\n    \"action_bar_clear_notifications\": \"Tüm bildirimleri temizle\",\n    \"action_bar_unsubscribe\": \"Abonelikten çık\",\n    \"action_bar_settings\": \"Ayarlar\",\n    \"message_bar_error_publishing\": \"Bildirim yayınlanırken hata oluştu\",\n    \"nav_topics_title\": \"Abone olunan konular\",\n    \"nav_button_all_notifications\": \"Tüm bildirimler\",\n    \"publish_dialog_tags_placeholder\": \"Virgülle ayrılmış etiket listesi, örn. uyarı, srv1-yedekleme\",\n    \"publish_dialog_priority_label\": \"Öncelik\",\n    \"publish_dialog_click_label\": \"Tıklama URL'si\",\n    \"publish_dialog_click_placeholder\": \"Bildirim tıklandığında açılan URL\",\n    \"publish_dialog_email_label\": \"E-posta adresi\",\n    \"publish_dialog_email_placeholder\": \"Bildirimin iletileceği adres, örn. phil@example.com\",\n    \"publish_dialog_attach_label\": \"Ek URL'si\",\n    \"publish_dialog_filename_label\": \"Dosya adı\",\n    \"publish_dialog_filename_placeholder\": \"Ek dosya adı\",\n    \"publish_dialog_delay_label\": \"Gecikme\",\n    \"publish_dialog_button_cancel\": \"İptal\",\n    \"publish_dialog_button_send\": \"Gönder\",\n    \"publish_dialog_checkbox_publish_another\": \"Başka bir tane yayınla\",\n    \"publish_dialog_attached_file_title\": \"Ekli dosya:\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Ek dosya adı\",\n    \"subscribe_dialog_subscribe_title\": \"Konuya abone ol\",\n    \"subscribe_dialog_subscribe_description\": \"Konular parola korumalı olmayabilir, bu nedenle tahmin edilmesi kolay olmayan bir ad seçin. Abone olduktan sonra PUT/POST bildirimleri yapabilirsiniz.\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Konu adı, örn. benim_uyarilarim\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Başka bir sunucu kullan\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"İptal\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Abone ol\",\n    \"subscribe_dialog_login_title\": \"Oturum açma gerekli\",\n    \"subscribe_dialog_login_description\": \"Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.\",\n    \"subscribe_dialog_login_username_label\": \"Kullanıcı adı, örn. phil\",\n    \"subscribe_dialog_login_password_label\": \"Parola\",\n    \"common_back\": \"Geri\",\n    \"subscribe_dialog_login_button_login\": \"Oturum aç\",\n    \"subscribe_dialog_error_user_not_authorized\": \"{{username}} kullanıcısı yetkili değil\",\n    \"subscribe_dialog_error_user_anonymous\": \"anonim\",\n    \"prefs_notifications_title\": \"Bildirimler\",\n    \"prefs_notifications_sound_title\": \"Bildirim sesi\",\n    \"prefs_notifications_sound_no_sound\": \"Ses yok\",\n    \"prefs_notifications_min_priority_title\": \"En düşük öncelik\",\n    \"prefs_notifications_min_priority_any\": \"Herhangi bir öncelik\",\n    \"publish_dialog_topic_placeholder\": \"Konu adı, örn. benim_uyarilarim\",\n    \"alert_notification_permission_required_button\": \"Şimdi ver\",\n    \"alert_not_supported_title\": \"Bildirimler desteklenmiyor\",\n    \"notifications_attachment_link_expires\": \"bağlantının süresi {{date}} tarihinde doluyor\",\n    \"notifications_click_copy_url_title\": \"Bağlantı URL'sini panoya kopyala\",\n    \"notifications_loading\": \"Bildirimler yükleniyor…\",\n    \"publish_dialog_progress_uploading\": \"Karşıya yükleniyor…\",\n    \"publish_dialog_attachment_limits_file_reached\": \"{{fileSizeLimit}} dosya sınırını aşıyor\",\n    \"publish_dialog_priority_default\": \"Öntanımlı öncelik\",\n    \"publish_dialog_chip_click_label\": \"Tıklama URL'si\",\n    \"publish_dialog_attach_placeholder\": \"URL ile dosya ekle, örn. https://f-droid.org/F-Droid.apk\",\n    \"prefs_notifications_delete_after_never\": \"Hiçbir zaman\",\n    \"notifications_attachment_copy_url_button\": \"URL'yi kopyala\",\n    \"notifications_attachment_open_button\": \"Eki aç\",\n    \"nav_button_documentation\": \"Dokümantasyon\",\n    \"nav_button_publish_message\": \"Bildirim yayınla\",\n    \"alert_notification_permission_required_title\": \"Bildirimler devre dışı\",\n    \"alert_notification_permission_required_description\": \"Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin\",\n    \"alert_not_supported_description\": \"Tarayıcınızda bildirimler desteklenmiyor\",\n    \"notifications_copied_to_clipboard\": \"Panoya kopyalandı\",\n    \"notifications_tags\": \"Etiketler\",\n    \"notifications_attachment_copy_url_title\": \"Ek URL'sini panoya kopyala\",\n    \"notifications_attachment_open_title\": \"{{url}} adresine git\",\n    \"notifications_none_for_topic_title\": \"Bu konu için henüz herhangi bir bildirim almadınız.\",\n    \"notifications_none_for_topic_description\": \"Bu konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir.\",\n    \"notifications_none_for_any_title\": \"Herhangi bir bildirim almadınız.\",\n    \"notifications_attachment_link_expired\": \"indirme bağlantısının süresi doldu\",\n    \"notifications_click_copy_url_button\": \"Bağlantıyı kopyala\",\n    \"notifications_actions_open_url_title\": \"{{url}} adresine git\",\n    \"notifications_click_open_button\": \"Bağlantıyı aç\",\n    \"notifications_no_subscriptions_description\": \"Bir konu oluşturmak veya bir konuya abone olmak için \\\"{{linktext}}\\\" bağlantısına tıklayın. Bundan sonra PUT veya POST yoluyla mesaj gönderebilirsiniz ve buradan bildirimler alırsınız.\",\n    \"notifications_example\": \"Örnek\",\n    \"notifications_more_details\": \"Daha fazla bilgi için <websiteLink>web sitesini</websiteLink> veya <docsLink>dokümantasyonu</docsLink> inceleyin.\",\n    \"publish_dialog_chip_attach_url_label\": \"URL ile dosya ekle\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Varsayılan öncelik ve üstü\",\n    \"prefs_notifications_delete_after_three_hours\": \"Üç saat sonra\",\n    \"notifications_none_for_any_description\": \"Bir konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir. İşte konularınızdan birini kullanan bir örnek.\",\n    \"notifications_no_subscriptions_title\": \"Henüz aboneliğiniz yok gibi görünüyor.\",\n    \"publish_dialog_title_topic\": \"{{topic}} konusuna yayınla\",\n    \"publish_dialog_title_no_topic\": \"Bildirim yayınla\",\n    \"publish_dialog_progress_uploading_detail\": \"Karşıya yükleniyor: {{loaded}}/{{total}} ({{percent}}%)…\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"{{fileSizeLimit}} dosya sınırını ve kotasını aşıyor, kalan {{remainingBytes}}\",\n    \"publish_dialog_priority_min\": \"En düşük öncelik\",\n    \"publish_dialog_priority_low\": \"Düşük öncelik\",\n    \"publish_dialog_base_url_label\": \"Hizmet URL'si\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"kotayı aşıyor, kalan {{remainingBytes}}\",\n    \"publish_dialog_message_published\": \"Bildirim yayınlandı\",\n    \"publish_dialog_title_label\": \"Başlık\",\n    \"publish_dialog_priority_high\": \"Yüksek öncelik\",\n    \"publish_dialog_priority_max\": \"En yüksek öncelik\",\n    \"publish_dialog_message_label\": \"Mesaj\",\n    \"publish_dialog_other_features\": \"Diğer özellikler:\",\n    \"publish_dialog_chip_email_label\": \"E-posta adresine ilet\",\n    \"publish_dialog_topic_label\": \"Konu adı\",\n    \"publish_dialog_base_url_placeholder\": \"Hizmet URL'si, örn. https://example.com\",\n    \"publish_dialog_title_placeholder\": \"Bildirim başlığı, örn. Disk alanı uyarısı\",\n    \"publish_dialog_message_placeholder\": \"Buraya bir mesaj yazın\",\n    \"publish_dialog_tags_label\": \"Etiketler\",\n    \"publish_dialog_delay_placeholder\": \"Teslimat gecikmesi, örn. {{unixTimestamp}}, {{relativeTime}} veya \\\"{{naturalLanguage}}\\\"\",\n    \"publish_dialog_chip_attach_file_label\": \"Yerel dosya ekle\",\n    \"publish_dialog_chip_delay_label\": \"Teslimat gecikmesi\",\n    \"publish_dialog_chip_topic_label\": \"Konuyu değiştir\",\n    \"publish_dialog_button_cancel_sending\": \"Göndermeyi iptal et\",\n    \"prefs_notifications_delete_after_one_week\": \"Bir hafta sonra\",\n    \"prefs_notifications_delete_after_one_month\": \"Bir ay sonra\",\n    \"publish_dialog_details_examples_description\": \"Tüm gönderme özelliklerinin örnekleri ve ayrıntılı açıklamaları için lütfen <docsLink>dokümantasyona</docsLink> bakın.\",\n    \"emoji_picker_search_placeholder\": \"Emoji ara\",\n    \"prefs_notifications_delete_after_title\": \"Bildirimleri sil\",\n    \"prefs_notifications_delete_after_one_day\": \"Bir gün sonra\",\n    \"publish_dialog_drop_file_here\": \"Dosyayı buraya bırakın\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Düşük öncelik ve üstü\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Yüksek öncelik ve üstü\",\n    \"prefs_notifications_min_priority_max_only\": \"Yalnızca en yüksek öncelik\",\n    \"prefs_users_title\": \"Kullanıcıları yönet\",\n    \"prefs_users_dialog_title_edit\": \"Kullanıcıyı düzenle\",\n    \"prefs_users_dialog_base_url_label\": \"Hizmet URL'si, örn. https://ntfy.sh\",\n    \"prefs_users_description\": \"Burada korunan konularınız için kullanıcı ekleyin/kaldırın. Lütfen kullanıcı adı ve parolanın tarayıcının yerel deposunda saklandığını unutmayın.\",\n    \"prefs_users_add_button\": \"Kullanıcı ekle\",\n    \"prefs_users_table_base_url_header\": \"Hizmet URL'si\",\n    \"prefs_users_dialog_title_add\": \"Kullanıcı ekle\",\n    \"prefs_users_dialog_username_label\": \"Kullanıcı adı, örn. phil\",\n    \"prefs_users_table_user_header\": \"Kullanıcı\",\n    \"prefs_users_dialog_password_label\": \"Parola\",\n    \"common_add\": \"Ekle\",\n    \"common_cancel\": \"İptal\",\n    \"common_save\": \"Kaydet\",\n    \"prefs_appearance_title\": \"Görünüm\",\n    \"prefs_appearance_language_title\": \"Dil\",\n    \"error_boundary_title\": \"Olamaz, ntfy çöktü\",\n    \"error_boundary_gathering_info\": \"Daha fazla bilgi topla…\",\n    \"error_boundary_description\": \"Bunun olmaması gerekiyordu. Çok üzgünüm.<br/>Bir dakikanız varsa, lütfen <githubLink>bunu GitHub üzerinden bildirin</githubLink> ya da <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> aracılığıyla bize iletin.\",\n    \"error_boundary_button_copy_stack_trace\": \"Yığın izlemeyi kopyala\",\n    \"error_boundary_stack_trace\": \"Yığın izleme\",\n    \"prefs_notifications_sound_description_none\": \"Bildirimler geldiğinde herhangi bir ses çalmaz\",\n    \"prefs_notifications_sound_description_some\": \"Bildirimler geldiğinde {{sound}} sesini çalar\",\n    \"prefs_notifications_min_priority_description_any\": \"Öncelikten bağımsız olarak tüm bildirimleri göster\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Öncelik {{number}} ({{name}}) veya üstüyse bildirimleri göster\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Bildirimler bir ay sonra otomatik olarak silinir\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Bildirimler üç saat sonra otomatik olarak silinir\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Bildirimler bir hafta sonra otomatik olarak silinir\",\n    \"priority_min\": \"en düşük\",\n    \"priority_low\": \"düşük\",\n    \"priority_max\": \"en yüksek\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Bildirimler bir gün sonra otomatik olarak silinir\",\n    \"priority_default\": \"öntanımlı\",\n    \"prefs_notifications_min_priority_description_max\": \"Öncelik 5 (en fazla) ise bildirimleri göster\",\n    \"prefs_notifications_delete_after_never_description\": \"Bildirimler asla otomatik olarak silinmez\",\n    \"priority_high\": \"yüksek\",\n    \"notifications_actions_not_supported\": \"Eylem, web uygulamasında desteklenmiyor\",\n    \"notifications_actions_http_request_title\": \"{{url}} adresine HTTP {{method}} gönder\",\n    \"action_bar_show_menu\": \"Menüyü göster\",\n    \"action_bar_logo_alt\": \"ntfy logosu\",\n    \"action_bar_toggle_action_menu\": \"Eylem menüsünü aç/kapat\",\n    \"message_bar_show_dialog\": \"Yayınla iletişim kutusunu göster\",\n    \"message_bar_publish\": \"Mesaj yayınla\",\n    \"nav_button_connecting\": \"bağlanıyor\",\n    \"notifications_list\": \"Bildirimler listesi\",\n    \"notifications_list_item\": \"Bildirim\",\n    \"notifications_delete\": \"Sil\",\n    \"notifications_attachment_image\": \"Ek resmi\",\n    \"notifications_attachment_file_image\": \"resim dosyası\",\n    \"notifications_attachment_file_video\": \"video dosyası\",\n    \"notifications_attachment_file_audio\": \"ses dosyası\",\n    \"notifications_attachment_file_app\": \"Android uygulama dosyası\",\n    \"notifications_attachment_file_document\": \"diğer belge\",\n    \"publish_dialog_emoji_picker_show\": \"Emoji seç\",\n    \"publish_dialog_topic_reset\": \"Konuyu sıfırla\",\n    \"publish_dialog_attach_reset\": \"Ek URL'sini kaldır\",\n    \"publish_dialog_delay_reset\": \"Gecikmeli teslimatı kaldır\",\n    \"publish_dialog_attached_file_remove\": \"Ekli dosyayı kaldır\",\n    \"emoji_picker_search_clear\": \"Aramayı temizle\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"Hizmet URL'si\",\n    \"prefs_notifications_sound_play\": \"Seçilen sesi çal\",\n    \"error_boundary_unsupported_indexeddb_description\": \"ntfy web uygulamasının çalışması için IndexedDB'ye ihtiyacı var ve tarayıcınız gizli tarama modunda IndexedDB'yi desteklemiyor.<br/><br/>Bu talihsiz olsa da, ntfy web uygulamasını gizli tarama modunda kullanmak pek mantıklı değildir, çünkü her şey tarayıcı deposunda saklanır. <githubLink>Bu GitHub sorununda</githubLink> bununla ilgili daha fazla bilgi edinebilir veya <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> üzerinden bizimle konuşabilirsiniz.\",\n    \"notifications_new_indicator\": \"Yeni bildirim\",\n    \"action_bar_toggle_mute\": \"Bildirimleri sesini kapat/aç\",\n    \"publish_dialog_click_reset\": \"Tıklama URL'sini kaldır\",\n    \"prefs_users_table\": \"Kullanıcılar tablosu\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Gizli tarama desteklenmiyor\",\n    \"nav_button_muted\": \"Bildirimler sessize alındı\",\n    \"notifications_mark_read\": \"Okundu olarak işaretle\",\n    \"notifications_priority_x\": \"Öncelik {{priority}}\",\n    \"publish_dialog_email_reset\": \"E-posta yönlendirmesini kaldır\",\n    \"prefs_users_edit_button\": \"Kullanıcıyı düzenle\",\n    \"prefs_users_delete_button\": \"Kullanıcı sil\",\n    \"signup_form_confirm_password\": \"Parolayı doğrula\",\n    \"signup_form_button_submit\": \"Kaydol\",\n    \"signup_form_toggle_password_visibility\": \"Parola görünürlüğünü değiştir\",\n    \"signup_already_have_account\": \"Zaten hesabınız var mı? Oturum açın!\",\n    \"signup_disabled\": \"Kayıt devre dışı bırakıldı\",\n    \"signup_error_username_taken\": \"{{username}} kullanıcı adı zaten alındı\",\n    \"signup_error_creation_limit_reached\": \"Hesap oluşturma sınırına ulaşıldı\",\n    \"login_title\": \"ntfy hesabınızda oturum açın\",\n    \"login_form_button_submit\": \"Oturum aç\",\n    \"login_link_signup\": \"Kaydol\",\n    \"login_disabled\": \"Oturum açma devre dışı bırakıldı\",\n    \"action_bar_account\": \"Hesap\",\n    \"action_bar_change_display_name\": \"Görünen adı değiştir\",\n    \"action_bar_reservation_add\": \"Konuyu ayırt\",\n    \"action_bar_reservation_edit\": \"Ayırtmayı değiştir\",\n    \"action_bar_reservation_delete\": \"Ayırtmayı kaldır\",\n    \"action_bar_reservation_limit_reached\": \"Sınıra ulaşıldı\",\n    \"action_bar_sign_in\": \"Oturum aç\",\n    \"action_bar_sign_up\": \"Kaydol\",\n    \"nav_button_account\": \"Hesap\",\n    \"nav_upgrade_banner_label\": \"ntfy Pro'ya yükselt\",\n    \"alert_not_supported_context_description\": \"Bildirimler yalnızca HTTPS üzerinden desteklenir. Bu, <mdnLink>Bildirim API'sinin</mdnLink> bir sınırlamasıdır.\",\n    \"display_name_dialog_description\": \"Abonelik listesinde görüntülenen bir konu için farklı bir ad belirleyin. Bu, karmaşık adlara sahip konuların daha kolay tanınmasına yardımcı olur.\",\n    \"display_name_dialog_placeholder\": \"Görünen ad\",\n    \"reserve_dialog_checkbox_label\": \"Konuyu ayırt ve erişimi yapılandır\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Konu zaten ayırtıldı\",\n    \"account_basics_title\": \"Hesap\",\n    \"account_basics_username_title\": \"Kullanıcı adı\",\n    \"account_basics_username_description\": \"Hey, bu sizsiniz ❤\",\n    \"account_basics_username_admin_tooltip\": \"Siz Yöneticisiniz\",\n    \"account_basics_password_title\": \"Parola\",\n    \"account_basics_password_description\": \"Hesap parolanızı değiştirin\",\n    \"account_basics_password_dialog_current_password_label\": \"Geçerli parola\",\n    \"account_basics_password_dialog_title\": \"Parolayı değiştir\",\n    \"account_basics_password_dialog_button_submit\": \"Parolayı değiştir\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Parola yanlış\",\n    \"account_usage_title\": \"Kullanım\",\n    \"account_usage_of_limit\": \"/ {{limit}}\",\n    \"account_usage_unlimited\": \"Sınırsız\",\n    \"account_usage_limits_reset_daily\": \"Kullanım sınırları her gün gece yarısında (UTC) sıfırlanır\",\n    \"account_basics_tier_title\": \"Hesap türü\",\n    \"account_basics_tier_description\": \"Hesabınızın güç seviyesi\",\n    \"account_basics_tier_admin\": \"Yönetici\",\n    \"account_basics_tier_basic\": \"Temel\",\n    \"account_basics_tier_free\": \"Ücretsiz\",\n    \"account_basics_tier_upgrade_button\": \"Pro'ya yükselt\",\n    \"account_basics_tier_change_button\": \"Değiştir\",\n    \"account_basics_tier_paid_until\": \"Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"({{tier}} seviyesiyle)\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(seviye yok)\",\n    \"account_basics_tier_manage_billing_button\": \"Faturalandırmayı yönet\",\n    \"account_usage_reservations_title\": \"Ayırtılan konular\",\n    \"account_usage_reservations_none\": \"Bu hesap için ayırtılan konu yok\",\n    \"account_usage_attachment_storage_title\": \"Ek depolama\",\n    \"account_usage_attachment_storage_description\": \"Dosya başına {{filesize}}, {{expiry}} sonrasında silinir\",\n    \"account_usage_cannot_create_portal_session\": \"Faturalandırma sayfası açılamıyor\",\n    \"account_delete_title\": \"Hesabı sil\",\n    \"account_delete_description\": \"Hesabınızı kalıcı olarak silin\",\n    \"account_delete_dialog_description\": \"Bu işlem, sunucuda depolanan tüm veriler dahil olmak üzere hesabınızı kalıcı olarak silecektir. Silme işleminden sonra kullanıcı adınız 7 gün boyunca kullanılamayacaktır. Gerçekten devam etmek istiyorsanız, lütfen aşağıdaki kutuya parolanızı yazarak onaylayın.\",\n    \"account_delete_dialog_button_cancel\": \"İptal\",\n    \"account_delete_dialog_button_submit\": \"Hesabı kalıcı olarak sil\",\n    \"account_delete_dialog_billing_warning\": \"Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.\",\n    \"account_upgrade_dialog_title\": \"Hesap seviyesini değiştir\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Fiyatlandırma</strong>: Ücretli planlar arasında yükseltme yaparken, fiyat farkı <strong>hemen tahsil edilecektir</strong>. Daha düşük bir seviyeye inildiğinde, bakiye gelecek faturalandırma dönemleri için ödeme yapmak üzere kullanılacaktır.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce <strong>lütfen en az {{count}} ayırtmayı silin</strong>. Ayırtmaları <Link>Ayarlar</Link> sayfasından kaldırabilirsiniz.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} konu ayırtıldı\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} günlük mesaj\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} günlük e-posta\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"dosya başına {{filesize}}\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} toplam depolama\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Seçilen\",\n    \"account_upgrade_dialog_tier_current_label\": \"Geçerli\",\n    \"account_upgrade_dialog_button_cancel\": \"İptal\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Şimdi kaydol\",\n    \"account_upgrade_dialog_button_pay_now\": \"Şimdi öde ve abone ol\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Aboneliği iptal et\",\n    \"account_tokens_title\": \"Erişim belirteçleri\",\n    \"account_tokens_table_token_header\": \"Belirteç\",\n    \"account_tokens_table_label_header\": \"Etiket\",\n    \"account_tokens_table_current_session\": \"Geçerli tarayıcı oturumu\",\n    \"common_copy_to_clipboard\": \"Panoya kopyala\",\n    \"account_tokens_table_copied_to_clipboard\": \"Erişim belirteci kopyalandı\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Geçerli oturum belirteci düzenlenemez veya silinemez\",\n    \"account_tokens_table_create_token_button\": \"Erişim belirteci oluştur\",\n    \"account_tokens_table_last_origin_tooltip\": \"{{ip}} IP adresinden, aramak için tıklayın\",\n    \"account_tokens_dialog_title_edit\": \"Erişim belirtecini düzenle\",\n    \"account_tokens_table_expires_header\": \"Süre dolumu\",\n    \"account_tokens_table_never_expires\": \"Asla süresi dolmaz\",\n    \"account_tokens_dialog_title_delete\": \"Erişim belirtecini sil\",\n    \"account_tokens_dialog_label\": \"Etiket, örn. Radarr bildirimleri\",\n    \"account_tokens_dialog_button_create\": \"Belirteç oluştur\",\n    \"account_tokens_dialog_button_update\": \"Belirteci güncelle\",\n    \"account_tokens_dialog_button_cancel\": \"İptal\",\n    \"account_tokens_dialog_expires_label\": \"Erişim belirtecinin süre dolumu\",\n    \"account_tokens_dialog_expires_unchanged\": \"Süre dolumu tarihini değiştirmeden bırak\",\n    \"account_tokens_dialog_expires_x_hours\": \"Belirtecin süresi {{hours}} saat içinde dolacak\",\n    \"account_tokens_dialog_expires_x_days\": \"Belirtecin süresi {{days}} gün içinde dolacak\",\n    \"account_tokens_dialog_expires_never\": \"Belirtecin süresi asla dolmaz\",\n    \"account_tokens_delete_dialog_title\": \"Erişim belirtecini sil\",\n    \"account_tokens_delete_dialog_description\": \"Bir erişim belirtecini silmeden önce, hiçbir uygulamanın veya betiğin onu etkin olarak kullanmadığından emin olun. <strong>Bu işlem geri alınamaz</strong>.\",\n    \"account_tokens_delete_dialog_submit_button\": \"Belirteci kalıcı olarak sil\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Oturum açan kullanıcı silinemez veya düzenlenemez\",\n    \"prefs_reservations_title\": \"Ayırtılan konular\",\n    \"prefs_reservations_description\": \"Konu adlarını burada kişisel kullanım için ayırtabilirsiniz. Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.\",\n    \"prefs_reservations_limit_reached\": \"Ayırtılan konu sınırınıza ulaştınız.\",\n    \"prefs_reservations_edit_button\": \"Konu erişimini düzenle\",\n    \"prefs_reservations_table\": \"Ayırtılan konular tablosu\",\n    \"prefs_reservations_table_topic_header\": \"Konu\",\n    \"prefs_reservations_table_access_header\": \"Erişim\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Yalnızca ben yayınlayabilir ve abone olabilirim\",\n    \"prefs_reservations_table_everyone_write_only\": \"Ben yayınlayabilir ve abone olabilirim, herkes yayınlayabilir\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Abone olmak için tıklayın\",\n    \"prefs_reservations_dialog_title_add\": \"Konuyu ayırt\",\n    \"prefs_reservations_dialog_title_edit\": \"Ayırtılan konuyu düzenle\",\n    \"prefs_reservations_dialog_title_delete\": \"Konu ayırtmasını sil\",\n    \"prefs_reservations_dialog_description\": \"Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.\",\n    \"prefs_reservations_dialog_topic_label\": \"Konu\",\n    \"prefs_reservations_dialog_access_label\": \"Erişim\",\n    \"reservation_delete_dialog_action_keep_title\": \"Önbelleğe alınan mesajları ve ekleri sakla\",\n    \"reservation_delete_dialog_action_keep_description\": \"Sunucuda önbelleğe alınan mesajlar ve ekler, konu adını bilen kişiler için görülebilir hale gelecektir.\",\n    \"reservation_delete_dialog_action_delete_title\": \"Önbelleğe alınan mesajları ve ekleri sil\",\n    \"reservation_delete_dialog_action_delete_description\": \"Önbelleğe alınan mesajlar ve ekler kalıcı olarak silinecektir. Bu işlem geri alınamaz.\",\n    \"reservation_delete_dialog_submit_button\": \"Ayırtmayı sil\",\n    \"signup_title\": \"ntfy hesabı oluştur\",\n    \"signup_form_username\": \"Kullanıcı adı\",\n    \"signup_form_password\": \"Parola\",\n    \"action_bar_profile_title\": \"Profil\",\n    \"action_bar_profile_logout\": \"Oturumu kapat\",\n    \"action_bar_profile_settings\": \"Ayarlar\",\n    \"nav_upgrade_banner_description\": \"Konuları ayırtma, daha fazla mesaj ve e-posta, daha büyük ekler\",\n    \"display_name_dialog_title\": \"Görünen adı değiştir\",\n    \"account_basics_password_dialog_new_password_label\": \"Yeni parola\",\n    \"account_usage_basis_ip_description\": \"Bu hesabın kullanım istatistikleri ve sınırları IP adresinize dayalıdır, bu nedenle diğer kullanıcılarla paylaşılabilir. Yukarıda gösterilen sınırlar, mevcut hız sınırlarına dayalı olarak yaklaşık değerlerdir.\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Ad oluştur\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Parolayı doğrula\",\n    \"account_basics_tier_payment_overdue\": \"Ödemenizin vadesi geçti. Lütfen ödeme yönteminizi güncelleyin, aksi takdirde hesabınızın seviyesi yakında düşürülecektir.\",\n    \"account_usage_messages_title\": \"Yayınlanan mesajlar\",\n    \"account_basics_tier_canceled_subscription\": \"Aboneliğiniz iptal edildi ve {{date}} tarihinde ücretsiz hesap seviyesine düşürülecek.\",\n    \"account_usage_emails_title\": \"Gönderilen e-postalar\",\n    \"account_upgrade_dialog_cancel_warning\": \"Bu, {{date}} tarihinde <strong>aboneliğinizi iptal edecek</strong> ve hesabınızın seviyesini düşürecektir. Bu tarihte, sunucuda önbelleğe alınan mesajlar ve ayırtılan konular <strong>silinecektir</strong>.\",\n    \"account_delete_dialog_label\": \"Parola\",\n    \"prefs_users_description_no_sync\": \"Kullanıcılar ve parolalar hesabınızla eşzamanlanmıyor.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce <strong>lütfen en az bir ayırtmayı silin</strong>. Ayırtmaları <Link>Ayarlar</Link> sayfasından kaldırabilirsiniz.\",\n    \"account_tokens_dialog_title_create\": \"Erişim belirteci oluştur\",\n    \"account_tokens_description\": \"ntfy API aracılığıyla yayınlarken ve abone olurken erişim belirteçlerini kullanın, böylece hesap kimlik bilgilerinizi göndermek zorunda kalmazsınız. Daha fazla bilgi edinmek için <Link>belgelere</Link> bakın.\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Aboneliği güncelle\",\n    \"account_tokens_table_last_access_header\": \"Son erişim\",\n    \"prefs_reservations_add_button\": \"Ayırtılan konu ekle\",\n    \"prefs_reservations_delete_button\": \"Konu erişimini sıfırla\",\n    \"prefs_reservations_table_everyone_read_only\": \"Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir\",\n    \"prefs_reservations_table_not_subscribed\": \"Abone olunmadı\",\n    \"prefs_reservations_table_everyone_read_write\": \"Herkes yayınlayabilir ve abone olabilir\",\n    \"reservation_delete_dialog_description\": \"Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz.\",\n    \"account_basics_tier_interval_yearly\": \"yıllık\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Ayırtılan konu yok\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"Yıllık {{price}}. Aylık faturalandırılır.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} yıllık olarak faturalandırılır. {{save}} tasarruf edin.\",\n    \"account_upgrade_dialog_interval_yearly\": \"Yıllık\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"%{{discount}} tasarruf edin\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"ay\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Faturalama ile ilgili sorularınız için lütfen doğrudan <Link>bizimle iletişime geçin</Link>.\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"%{{discount}} kadar tasarruf edin\",\n    \"account_upgrade_dialog_interval_monthly\": \"Aylık\",\n    \"account_basics_tier_interval_monthly\": \"aylık\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Faturalama ile ilgili sorularınız için lütfen <Link>web sitemizi ziyaret edin</Link>.\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} ayırtılan konu\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} günlük e-posta\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} günlük mesaj\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} günlük telefon araması\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} günlük telefon araması\",\n    \"publish_dialog_call_label\": \"Telefon araması\",\n    \"publish_dialog_call_reset\": \"Telefon aramasını kaldır\",\n    \"publish_dialog_chip_call_label\": \"Telefon araması\",\n    \"account_basics_phone_numbers_title\": \"Telefon numaraları\",\n    \"account_basics_phone_numbers_dialog_description\": \"Arama bildirimi özelliğini kullanmak için en az bir telefon numarası eklemeniz ve doğrulamanız gerekir. Doğrulama SMS veya telefon araması yoluyla yapılabilir.\",\n    \"account_basics_phone_numbers_description\": \"Telefon araması bildirimleri için\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Henüz telefon numarası yok\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Telefon numarası panoya kopyalandı\",\n    \"account_basics_phone_numbers_dialog_title\": \"Telefon numarası ekle\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Telefon numarası\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Kodu doğrula\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Ara\",\n    \"account_usage_calls_none\": \"Bu hesapla telefon araması yapılamaz\",\n    \"publish_dialog_call_item\": \"{{number}} telefon numarasını ara\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Doğrulanan telefon numarası yok\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"örn. +905554443322\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"SMS gönder\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Beni ara\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Doğrulama kodu\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"örn. 123456\",\n    \"account_usage_calls_title\": \"Yapılan telefon aramaları\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Telefon araması yok\",\n    \"action_bar_mute_notifications\": \"Bildirimleri sessize al\",\n    \"action_bar_unmute_notifications\": \"Bildirimlerin sesini aç\",\n    \"alert_notification_permission_denied_title\": \"Bildirimler engellendi\",\n    \"alert_notification_permission_denied_description\": \"Lütfen tarayıcınızda yeniden etkinleştirin\",\n    \"alert_notification_ios_install_required_title\": \"iOS kurulumu gerekli\",\n    \"alert_notification_ios_install_required_description\": \"iOS'ta bildirimleri etkinleştirmek için Paylaş simgesine ve Ana Ekrana Ekle'ye tıklayın\",\n    \"notifications_actions_failed_notification\": \"Başarısız eylem\",\n    \"publish_dialog_checkbox_markdown\": \"Markdown olarak biçimlendir\",\n    \"prefs_notifications_web_push_title\": \"Arka plan bildirimleri\",\n    \"prefs_notifications_web_push_enabled_description\": \"Web uygulaması çalışmadığında bile bildirimler alınır (Web Push aracılığıyla)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Web uygulaması çalışırken bildirim alınır (WebSocket aracılığıyla)\",\n    \"prefs_notifications_web_push_enabled\": \"{{server}} için etkinleştirildi\",\n    \"prefs_notifications_web_push_disabled\": \"Devre dışı\",\n    \"prefs_appearance_theme_title\": \"Tema\",\n    \"prefs_appearance_theme_system\": \"Sistem (öntanımlı)\",\n    \"prefs_appearance_theme_dark\": \"Koyu mod\",\n    \"prefs_appearance_theme_light\": \"Açık mod\",\n    \"error_boundary_button_reload_ntfy\": \"ntfy'yi yeniden yükle\",\n    \"web_push_subscription_expiring_title\": \"Bildirimler duraklatılacak\",\n    \"web_push_subscription_expiring_body\": \"Bildirimleri almaya devam etmek için ntfy'yi açın\",\n    \"web_push_unknown_notification_title\": \"Sunucudan bilinmeyen bildirim alındı\",\n    \"web_push_unknown_notification_body\": \"Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"Sağlanmış belirteci düzenleyemez veya silemezsiniz\"\n}\n"
  },
  {
    "path": "web/public/static/langs/uk.json",
    "content": "{\n    \"action_bar_logo_alt\": \"логотип ntfy\",\n    \"action_bar_settings\": \"Налаштування\",\n    \"message_bar_type_message\": \"Введіть повідомлення тут\",\n    \"message_bar_error_publishing\": \"Помилка публікації сповіщення\",\n    \"message_bar_show_dialog\": \"Показати діалогове вікно публікації\",\n    \"nav_topics_title\": \"Підписки на теми\",\n    \"nav_button_settings\": \"Налаштування\",\n    \"nav_button_documentation\": \"Документація\",\n    \"nav_button_subscribe\": \"Підписатися на тему\",\n    \"nav_button_muted\": \"Сповіщення вимкнено\",\n    \"nav_button_connecting\": \"підключення\",\n    \"alert_notification_permission_required_title\": \"Сповіщення вимкнено\",\n    \"alert_notification_permission_required_description\": \"Дозвольте браузеру показувати сповіщення.\",\n    \"alert_notification_permission_required_button\": \"Дозволити\",\n    \"alert_not_supported_title\": \"Сповіщення не підтримуються\",\n    \"notifications_list_item\": \"Сповіщення\",\n    \"notifications_attachment_image\": \"Прикріплене зображення\",\n    \"notifications_attachment_open_title\": \"Перейти на {{url}}\",\n    \"notifications_attachment_open_button\": \"Відкрити вкладення\",\n    \"notifications_attachment_link_expires\": \"термін дії посилання закінчується {{date}}\",\n    \"notifications_actions_http_request_title\": \"Надіслати HTTP {{method}} на {{url}}\",\n    \"notifications_none_for_any_title\": \"Ви не отримали жодних сповіщень.\",\n    \"notifications_no_subscriptions_description\": \"Натисніть \\\"{{linktext}}\\\" посилання, щоб створити або підписатися на тему. Після цього ви зможете надсилати повідомлення за допомогою PUT або POST, і ви отримуватимете тут повідомлення.\",\n    \"notifications_more_details\": \"Додаткову інформацію можна знайти на <websiteLink>сайті</websiteLink> або в <docsLink>документації</docsLink>.\",\n    \"notifications_loading\": \"Завантаження сповіщень…\",\n    \"publish_dialog_title_topic\": \"Опублікувати в {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"Опублікувати сповіщення\",\n    \"publish_dialog_progress_uploading\": \"Завантаження…\",\n    \"publish_dialog_message_published\": \"Сповіщення опубліковано\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"перевищує квоту, залишилося {{remainingBytes}}\",\n    \"publish_dialog_priority_low\": \"Низький пріоритет\",\n    \"publish_dialog_topic_label\": \"Назва теми\",\n    \"publish_dialog_topic_placeholder\": \"Назва теми, наприклад phil_alerts\",\n    \"publish_dialog_topic_reset\": \"Скинути тему\",\n    \"publish_dialog_title_label\": \"Заголовок\",\n    \"publish_dialog_title_placeholder\": \"Заголовок сповіщення, наприклад Сповіщення про дисковий простір\",\n    \"publish_dialog_message_label\": \"Повідомлення\",\n    \"publish_dialog_message_placeholder\": \"Введіть повідомлення\",\n    \"publish_dialog_tags_label\": \"Теги\",\n    \"publish_dialog_tags_placeholder\": \"Список тегів розділений комою, наприклад warning, srv1-backup\",\n    \"publish_dialog_click_placeholder\": \"URL-адреса, яка відкривається після натискання сповіщення\",\n    \"publish_dialog_email_label\": \"Електронна пошта\",\n    \"publish_dialog_attach_placeholder\": \"Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"Видалити URL вкладення\",\n    \"publish_dialog_filename_placeholder\": \"Ім'я файлу вкладення\",\n    \"publish_dialog_delay_reset\": \"Видалити затримку доставлення\",\n    \"publish_dialog_chip_click_label\": \"Адреса\",\n    \"publish_dialog_chip_email_label\": \"Переслати на електронну пошту\",\n    \"publish_dialog_chip_topic_label\": \"Змінити тему\",\n    \"publish_dialog_attached_file_remove\": \"Видалити прикріплений файл\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"Назва теми, наприклад phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"Використовувати інший сервер\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"URL служби\",\n    \"subscribe_dialog_login_password_label\": \"Пароль\",\n    \"common_back\": \"Назад\",\n    \"subscribe_dialog_error_user_not_authorized\": \"{{username}} користувач не авторизований\",\n    \"prefs_notifications_sound_description_none\": \"Сповіщення не відтворюють жодного звуку при надходженні\",\n    \"prefs_notifications_sound_description_some\": \"Сповіщення відтворюють звук {{sound}}\",\n    \"prefs_notifications_min_priority_description_any\": \"Показати всі сповіщень, незалежно від пріоритету\",\n    \"prefs_notifications_min_priority_any\": \"Будь-який пріоритет\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"Пріоритет за замовчуванням та високий\",\n    \"prefs_notifications_delete_after_title\": \"Видалити сповіщення\",\n    \"prefs_notifications_delete_after_never\": \"Ніколи\",\n    \"prefs_notifications_delete_after_one_day\": \"Через день\",\n    \"prefs_notifications_delete_after_one_week\": \"Через тиждень\",\n    \"prefs_notifications_delete_after_one_month\": \"Через місяць\",\n    \"prefs_notifications_delete_after_never_description\": \"Сповіщення ніколи не видаляються автоматично\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"Сповіщення автоматично видаляються через три години\",\n    \"prefs_notifications_delete_after_one_day_description\": \"Сповіщення автоматично видаляються через один день\",\n    \"prefs_notifications_delete_after_one_week_description\": \"Сповіщення автоматично видаляються через тиждень\",\n    \"prefs_notifications_delete_after_one_month_description\": \"Сповіщення автоматично видаляються через місяць\",\n    \"prefs_users_title\": \"Керувати користувачами\",\n    \"prefs_users_table\": \"Таблиця користувачів\",\n    \"prefs_users_edit_button\": \"Редагувати користувача\",\n    \"common_save\": \"Зберегти\",\n    \"prefs_appearance_title\": \"Зовнішній вигляд\",\n    \"priority_default\": \"за замовчуванням\",\n    \"priority_high\": \"високий\",\n    \"priority_max\": \"макс\",\n    \"error_boundary_title\": \"Ой, ntfy впав\",\n    \"error_boundary_button_copy_stack_trace\": \"Копіювати трасування стека\",\n    \"action_bar_show_menu\": \"Показати меню\",\n    \"action_bar_toggle_action_menu\": \"Відкрити/закрити меню\",\n    \"action_bar_send_test_notification\": \"Надіслати тестове сповіщення\",\n    \"action_bar_clear_notifications\": \"Очистити всі сповіщення\",\n    \"action_bar_toggle_mute\": \"Вимкнути/увімкнути сповіщення\",\n    \"action_bar_unsubscribe\": \"Відписатися\",\n    \"message_bar_publish\": \"Опублікувати повідомлення\",\n    \"nav_button_all_notifications\": \"Усі сповіщення\",\n    \"alert_not_supported_description\": \"Ваш браузер не підтримує сповіщення\",\n    \"notifications_list\": \"Список сповіщень\",\n    \"notifications_mark_read\": \"Позначити як прочитане\",\n    \"notifications_delete\": \"Видалити\",\n    \"notifications_tags\": \"Теги\",\n    \"nav_button_publish_message\": \"Опублікувати сповіщення\",\n    \"notifications_attachment_copy_url_title\": \"Копіювати URL-адресу вкладення\",\n    \"notifications_attachment_link_expired\": \"термін дії посилання для завантаження закінчився\",\n    \"publish_dialog_progress_uploading_detail\": \"Завантажується {{loaded}}/{{total}} ({{percent}}%) …\",\n    \"notifications_priority_x\": \"Пріоритет {{priority}}\",\n    \"notifications_attachment_copy_url_button\": \"Копіювати URL-адресу\",\n    \"notifications_copied_to_clipboard\": \"Скопійовано в буфер обміну\",\n    \"notifications_attachment_file_video\": \"відео файл\",\n    \"notifications_attachment_file_audio\": \"звуковий файл\",\n    \"publish_dialog_emoji_picker_show\": \"Виберіть емодзі\",\n    \"notifications_new_indicator\": \"Нове сповіщення\",\n    \"notifications_attachment_file_image\": \"файл зображення\",\n    \"notifications_attachment_file_document\": \"інший документ\",\n    \"notifications_click_copy_url_title\": \"Копіювати URL-адресу посилання\",\n    \"notifications_click_copy_url_button\": \"Копіювати посилання\",\n    \"notifications_actions_not_supported\": \"Дія не підтримується у браузері\",\n    \"notifications_attachment_file_app\": \"Файл програми Android\",\n    \"notifications_click_open_button\": \"Відкрити посилання\",\n    \"notifications_actions_open_url_title\": \"Перейти на {{url}}\",\n    \"notifications_none_for_topic_description\": \"Щоб надіслати сповіщення до цієї теми, просто надішліть PUT або POST на URL-адресу цієї теми.\",\n    \"notifications_no_subscriptions_title\": \"Схоже, у вас ще немає жодної підписки.\",\n    \"publish_dialog_drop_file_here\": \"Перетягніть файл сюди\",\n    \"notifications_none_for_topic_title\": \"Ви ще не отримували сповіщення на цю тему.\",\n    \"notifications_example\": \"Приклад\",\n    \"notifications_none_for_any_description\": \"Щоб надіслати сповіщення до теми, просто надішліть PUT або POST на URL-адресу теми. Ось приклад, використовуючи одну з ваших тем.\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"перевищує {{fileSizeLimit}} розмір файлу, {{remainingBytes}} залишилося\",\n    \"publish_dialog_priority_default\": \"Пріоритет за замовчуванням\",\n    \"publish_dialog_attachment_limits_file_reached\": \"перевищує {{fileSizeLimit}} розмір файлу\",\n    \"publish_dialog_priority_min\": \"Мін. пріоритет\",\n    \"publish_dialog_priority_high\": \"Високий пріоритет\",\n    \"publish_dialog_priority_max\": \"Макс. пріоритет\",\n    \"publish_dialog_base_url_placeholder\": \"URL-адреса сервісу, наприклад https://example.com\",\n    \"publish_dialog_base_url_label\": \"URL служби\",\n    \"publish_dialog_other_features\": \"Інші можливості:\",\n    \"publish_dialog_chip_attach_file_label\": \"Прикріпити локальний файл\",\n    \"publish_dialog_priority_label\": \"Пріоритет\",\n    \"publish_dialog_click_label\": \"Натисніть URL\",\n    \"publish_dialog_click_reset\": \"Видалити URL-адресу для натискання\",\n    \"publish_dialog_email_placeholder\": \"Адреса для пересилання сповіщення, наприклад phil@example.com\",\n    \"publish_dialog_attach_label\": \"URL-адреса вкладення\",\n    \"publish_dialog_filename_label\": \"Ім'я файлу\",\n    \"publish_dialog_delay_label\": \"Затримка\",\n    \"publish_dialog_email_reset\": \"Видалити пересилання електронної пошти\",\n    \"publish_dialog_chip_attach_url_label\": \"Прикріпити файл за URL\",\n    \"publish_dialog_details_examples_description\": \"Приклади та докладний опис усіх функцій, зверніться до <docsLink>документації</docsLink>.\",\n    \"publish_dialog_button_cancel_sending\": \"Скасувати відправку\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"Ім'я прикріпленого файлу\",\n    \"publish_dialog_delay_placeholder\": \"Затримка доставлення, наприклад {{unixTimestamp}}, {{relativeTime}} або \\\"{{naturalLanguage}}\\\" (лише англійською)\",\n    \"publish_dialog_button_send\": \"Надіслати\",\n    \"publish_dialog_checkbox_publish_another\": \"Опублікувати ще\",\n    \"publish_dialog_chip_delay_label\": \"Затримка доставлення\",\n    \"publish_dialog_button_cancel\": \"Скасувати\",\n    \"publish_dialog_attached_file_title\": \"Прикріплений файл:\",\n    \"subscribe_dialog_subscribe_description\": \"Теми можуть не бути захищені паролем, тому виберіть назву, яку нелегко вгадати. Після підписки ви можете PUT/POST сповіщення.\",\n    \"emoji_picker_search_placeholder\": \"Пошук емодзі\",\n    \"emoji_picker_search_clear\": \"Очистити пошук\",\n    \"subscribe_dialog_subscribe_title\": \"Підпишіться на тему\",\n    \"subscribe_dialog_login_username_label\": \"Ім'я користувача, наприклад phil\",\n    \"prefs_notifications_title\": \"Сповіщення\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"Скасувати\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"Підписатися\",\n    \"subscribe_dialog_error_user_anonymous\": \"анонімний\",\n    \"subscribe_dialog_login_title\": \"Потрібна авторизація\",\n    \"subscribe_dialog_login_description\": \"Ця тема захищена паролем. Будь ласка, введіть ім'я користувача та пароль, щоб підписатися.\",\n    \"prefs_notifications_sound_title\": \"Звук сповіщення\",\n    \"subscribe_dialog_login_button_login\": \"Логін\",\n    \"prefs_notifications_sound_no_sound\": \"Без звука\",\n    \"prefs_notifications_sound_play\": \"Відтворення вибраного звуку\",\n    \"prefs_users_description\": \"Додайте/видаляйте користувачів для захищених тем. Зверніть увагу, що ім'я користувача та пароль зберігаються у локальному сховищі браузера.\",\n    \"prefs_notifications_min_priority_title\": \"Мінімальний пріоритет\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"Високий пріоритет і вище\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"Показувати сповіщення, якщо пріоритет {{number}} ({{name}}) або вище\",\n    \"prefs_notifications_min_priority_description_max\": \"Показувати сповіщення, якщо пріоритет 5 (макс.)\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"Низький та високий пріоритет\",\n    \"prefs_notifications_min_priority_max_only\": \"Тільки максимальний пріоритет\",\n    \"prefs_users_table_base_url_header\": \"URL служби\",\n    \"prefs_users_dialog_password_label\": \"Пароль\",\n    \"prefs_notifications_delete_after_three_hours\": \"Через три години\",\n    \"prefs_users_add_button\": \"Додати користувача\",\n    \"prefs_users_dialog_title_edit\": \"Редагувати користувача\",\n    \"prefs_users_dialog_base_url_label\": \"URL-адреса служби, наприклад https://ntfy.sh\",\n    \"prefs_users_delete_button\": \"Видалити користувача\",\n    \"prefs_users_table_user_header\": \"Користувач\",\n    \"prefs_users_dialog_title_add\": \"Додати користувача\",\n    \"prefs_users_dialog_username_label\": \"Ім'я користувача, наприклад phil\",\n    \"common_cancel\": \"Скасувати\",\n    \"common_add\": \"Додати\",\n    \"prefs_appearance_language_title\": \"Мова\",\n    \"error_boundary_gathering_info\": \"Зберіть більше інформації…\",\n    \"priority_min\": \"мін\",\n    \"error_boundary_description\": \"Очевидно, цього не повинно статися. Дуже шкода.<br/>Якщо у вас є хвилина, <githubLink>повідомте про це на GitHub</githubLink> або повідомте нам через <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink> .\",\n    \"priority_low\": \"низький\",\n    \"error_boundary_stack_trace\": \"Трасування стека\",\n    \"error_boundary_unsupported_indexeddb_title\": \"Приватний перегляд не підтримується\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.<br/><br/>На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це <githubLink>у цьому випуску GitHub</githubLink> або поспілкуватися з нами на <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink>.\",\n    \"signup_title\": \"Створення облікового запису ntfy\",\n    \"signup_form_username\": \"Ім'я користувача\",\n    \"signup_form_password\": \"Пароль\",\n    \"signup_form_confirm_password\": \"Підтвердіть пароль\",\n    \"signup_form_button_submit\": \"Зареєструватися\",\n    \"signup_form_toggle_password_visibility\": \"Перемкнути видимість пароля\",\n    \"signup_already_have_account\": \"Вже маєте обліковий запис? Увійдіть!\",\n    \"signup_disabled\": \"Реєстрацію вимкнено\",\n    \"signup_error_username_taken\": \"Ім'я користувача {{username}} вже зайнято\",\n    \"signup_error_creation_limit_reached\": \"Досягнуто обмеження на створення облікового запису\",\n    \"login_title\": \"Увійдіть до свого облікового запису ntfy\",\n    \"login_form_button_submit\": \"Увійти\",\n    \"login_link_signup\": \"Зареєструватися\",\n    \"login_disabled\": \"Вхід вимкнено\",\n    \"action_bar_account\": \"Обліковий запис\",\n    \"action_bar_reservation_add\": \"Зарезервувати тему\",\n    \"action_bar_reservation_edit\": \"Змінити резервування\",\n    \"action_bar_reservation_delete\": \"Видалити резервування\",\n    \"action_bar_reservation_limit_reached\": \"Досягнуто ліміту\",\n    \"action_bar_change_display_name\": \"Змінити відображувану назву\",\n    \"action_bar_profile_title\": \"Профіль\",\n    \"action_bar_profile_settings\": \"Налаштування\",\n    \"action_bar_sign_up\": \"Зареєструватися\",\n    \"nav_button_account\": \"Обліковий запис\",\n    \"nav_upgrade_banner_description\": \"Резервування тем, більше повідомлень та імейлів, більші вкладення\",\n    \"alert_not_supported_context_description\": \"Сповіщення підтримуються лише через HTTPS. Це обмеження <mdnLink>Notifications API</mdnLink>.\",\n    \"display_name_dialog_title\": \"Змінити відображувану назву\",\n    \"reserve_dialog_checkbox_label\": \"Зарезервувати тему та налаштувати доступ\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"Згенерувати назву\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"Тема вже зарезервована\",\n    \"account_basics_title\": \"Обліковий запис\",\n    \"account_basics_username_title\": \"Ім'я користувача\",\n    \"account_basics_username_description\": \"Привіт, це ти ❤\",\n    \"account_basics_password_dialog_title\": \"Змінити пароль\",\n    \"account_basics_password_dialog_current_password_label\": \"Поточний пароль\",\n    \"account_basics_password_dialog_new_password_label\": \"Новий пароль\",\n    \"account_basics_password_dialog_confirm_password_label\": \"Підтвердіть пароль\",\n    \"account_basics_password_dialog_button_submit\": \"Змінити пароль\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"Неправильний пароль\",\n    \"account_usage_title\": \"Використання\",\n    \"account_usage_limits_reset_daily\": \"Ліміти використання скидаються щодня опівночі (UTC)\",\n    \"account_basics_tier_title\": \"Тип облікового запису\",\n    \"account_basics_tier_admin\": \"Адміністратор\",\n    \"action_bar_sign_in\": \"Увійти\",\n    \"action_bar_profile_logout\": \"Вийти\",\n    \"nav_upgrade_banner_label\": \"Оновлення до ntfy Pro\",\n    \"display_name_dialog_description\": \"Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.\",\n    \"display_name_dialog_placeholder\": \"Відображуване ім'я\",\n    \"account_basics_password_title\": \"Пароль\",\n    \"account_basics_username_admin_tooltip\": \"Ви адміністратор\",\n    \"account_basics_tier_interval_monthly\": \"щомісяця\",\n    \"common_copy_to_clipboard\": \"Скопіювати в буфер обміну\",\n    \"account_basics_phone_numbers_title\": \"Номери телефонів\",\n    \"account_basics_phone_numbers_description\": \"Для сповіщень через телефонні дзвінки\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"Поки що немає номерів телефонів\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"Номер телефону скопійовано в буфер обміну\",\n    \"account_basics_phone_numbers_dialog_title\": \"Додати номер телефону\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"Номер телефону\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"наприклад, +1222333444\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"Надіслати SMS\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"Зателефонуйте мені\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"Код підтвердження\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"наприклад, 123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"Підтвердити код\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"SMS\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"Дзвінок\",\n    \"account_basics_tier_interval_yearly\": \"щороку\",\n    \"account_usage_calls_title\": \"Здійснені телефонні дзвінки\",\n    \"account_usage_calls_none\": \"З цього облікового запису не можна здійснювати телефонні дзвінки\",\n    \"account_usage_attachment_storage_title\": \"Зберігання вкладень\",\n    \"account_usage_attachment_storage_description\": \"{{filesize}} на файл, видаляється після {{expiry}}\",\n    \"account_usage_basis_ip_description\": \"Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.\",\n    \"account_usage_cannot_create_portal_session\": \"Не вдається відкрити білінговий портал\",\n    \"account_delete_title\": \"Видалення облікового запису\",\n    \"account_delete_description\": \"Назавжди видалити свій обліковий запис\",\n    \"account_delete_dialog_label\": \"Пароль\",\n    \"account_delete_dialog_button_cancel\": \"Скасувати\",\n    \"account_delete_dialog_button_submit\": \"Видалити обліковий запис назавжди\",\n    \"account_delete_dialog_billing_warning\": \"Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.\",\n    \"account_upgrade_dialog_title\": \"Зміна рівня облікового запису\",\n    \"account_upgrade_dialog_interval_monthly\": \"Щомісяця\",\n    \"account_upgrade_dialog_interval_yearly\": \"Щорічно\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"економія {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"економія до {{discount}}%\",\n    \"publish_dialog_call_label\": \"Телефонний дзвінок\",\n    \"publish_dialog_call_placeholder\": \"Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \\\"yes\\\"\",\n    \"publish_dialog_chip_call_label\": \"Телефонний дзвінок\",\n    \"publish_dialog_call_reset\": \"Видалити телефонний дзвінок\",\n    \"account_basics_phone_numbers_dialog_description\": \"Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.\",\n    \"account_delete_dialog_description\": \"Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.\",\n    \"account_basics_tier_upgrade_button\": \"Оновлення до Pro\",\n    \"account_basics_password_description\": \"Зміна пароля облікового запису\",\n    \"account_usage_of_limit\": \"з {{limit}}\",\n    \"account_usage_unlimited\": \"Без обмежень\",\n    \"account_basics_tier_description\": \"Рівень потужності вашого облікового запису\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"(з рівнем {{tier}})\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"(без рівня)\",\n    \"account_basics_tier_basic\": \"Базовий\",\n    \"account_basics_tier_free\": \"Безкоштовний\",\n    \"account_basics_tier_change_button\": \"Змінити\",\n    \"account_basics_tier_paid_until\": \"Підписка оплачена до {{date}} і буде автоматично поновлюватися\",\n    \"account_basics_tier_payment_overdue\": \"Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.\",\n    \"account_basics_tier_canceled_subscription\": \"Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.\",\n    \"account_basics_tier_manage_billing_button\": \"Керувати рахунками\",\n    \"account_usage_messages_title\": \"Опубліковані повідомлення\",\n    \"account_usage_emails_title\": \"Надіслані електронні листи\",\n    \"account_usage_reservations_title\": \"Зарезервовані теми\",\n    \"account_usage_reservations_none\": \"Для цього облікового запису немає зарезервованих тем\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} на файл\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} загальне сховище\",\n    \"account_upgrade_dialog_tier_current_label\": \"Поточний\",\n    \"account_upgrade_dialog_tier_selected_label\": \"Вибране\",\n    \"account_upgrade_dialog_cancel_warning\": \"Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} зарезервовані теми\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"Немає зарезервованих тем\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} повідомлень в день\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"{{emails}} електронний лист в день\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} електронних листів в день\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"{{calls}} телефонний дзвінок в день\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{дзвінки}} телефонних дзвінків в день\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"Без телефонних дзвінків\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"місяць\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} на рік. Рахунок виставляється щомісяця.\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{price}} виставляється щорічно. Збережіть {{save}}.\",\n    \"account_upgrade_dialog_billing_contact_email\": \"Якщо у вас виникли запитання щодо оплати, <Link>зв’яжіться з нами</Link> безпосередньо.\",\n    \"account_upgrade_dialog_billing_contact_website\": \"Якщо у вас виникли запитання щодо оплати, відвідайте наш <Link>веб-сайт</Link>.\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"Скасувати підписку\",\n    \"account_upgrade_dialog_button_update_subscription\": \"Оновити підписку\",\n    \"account_tokens_title\": \"Токени доступу\",\n    \"account_tokens_table_expires_header\": \"Термін дії закінчується\",\n    \"account_tokens_description\": \"Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з <Link>документацією</Link>, щоб дізнатися більше.\",\n    \"account_tokens_table_token_header\": \"Токен\",\n    \"account_tokens_table_never_expires\": \"Ніколи не закінчується\",\n    \"account_tokens_table_label_header\": \"Мітка\",\n    \"account_tokens_table_current_session\": \"Поточний сеанс браузера\",\n    \"account_tokens_table_last_access_header\": \"Останній доступ\",\n    \"account_tokens_table_copied_to_clipboard\": \"Токен доступу скопійовано\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"Неможливо редагувати або видалити токен поточного сеансу\",\n    \"account_tokens_table_create_token_button\": \"Створити токен доступу\",\n    \"account_tokens_table_last_origin_tooltip\": \"З IP-адреси {{ip}} натисніть для пошуку\",\n    \"account_tokens_dialog_title_create\": \"Створити токен доступу\",\n    \"account_tokens_dialog_button_cancel\": \"Скасувати\",\n    \"account_tokens_dialog_title_edit\": \"Редагувати токен доступу\",\n    \"account_tokens_dialog_title_delete\": \"Видалити токен доступу\",\n    \"account_tokens_dialog_label\": \"Мітка, наприклад, сповіщення Radarr\",\n    \"account_tokens_dialog_button_create\": \"Створити токен\",\n    \"account_tokens_dialog_button_update\": \"Оновити токен\",\n    \"account_tokens_dialog_expires_label\": \"Термін дії токену доступу закінчується через\",\n    \"account_tokens_dialog_expires_x_hours\": \"Термін дії токена закінчується через {{hours}} годин\",\n    \"account_tokens_dialog_expires_x_days\": \"Термін дії токена закінчується через {{days}} днів\",\n    \"account_tokens_delete_dialog_description\": \"Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. <strong>Ця дія не може бути скасована</strong>.\",\n    \"prefs_users_description_no_sync\": \"Користувачі та паролі не синхронізуються з вашим акаунтом.\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"Неможливо видалити або відредагувати користувача, який увійшов у систему\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"{{reservations}} зарезервована тема\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"{{messages}} повідомлення в день\",\n    \"account_tokens_dialog_expires_unchanged\": \"Залишити термін придатності без змін\",\n    \"account_tokens_dialog_expires_never\": \"Термін дії токена ніколи не закінчується\",\n    \"account_tokens_delete_dialog_title\": \"Видалити токен доступу\",\n    \"account_tokens_delete_dialog_submit_button\": \"Видалити токен назавжди\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>Пропорція</strong>: При переході з одного тарифного плану на інший різниця в ціні буде <strong>списана негайно</strong>. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні одне резервування</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.\",\n    \"account_upgrade_dialog_button_cancel\": \"Скасувати\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"Зареєструватися зараз\",\n    \"account_upgrade_dialog_button_pay_now\": \"Оплатити зараз і підписатися\",\n    \"prefs_reservations_add_button\": \"Додати зарезервовану тему\",\n    \"prefs_reservations_edit_button\": \"Редагувати доступ до теми\",\n    \"prefs_reservations_limit_reached\": \"Ви досягли ліміту зарезервованих тем.\",\n    \"prefs_reservations_table_click_to_subscribe\": \"Натисніть, щоб підписатися\",\n    \"prefs_reservations_table_topic_header\": \"Тема\",\n    \"prefs_reservations_description\": \"Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.\",\n    \"prefs_reservations_table\": \"Таблиця зарезервованих тем\",\n    \"prefs_reservations_table_access_header\": \"Доступ\",\n    \"prefs_reservations_table_everyone_deny_all\": \"Тільки я можу публікувати та підписуватись\",\n    \"prefs_reservations_table_everyone_read_only\": \"Я можу публікувати та підписуватись, кожен може підписатися\",\n    \"prefs_reservations_table_everyone_write_only\": \"Я можу публікувати і підписуватися, кожен може публікувати\",\n    \"prefs_reservations_table_everyone_read_write\": \"Кожен може публікувати та підписуватися\",\n    \"prefs_reservations_table_not_subscribed\": \"Не підписаний\",\n    \"prefs_reservations_dialog_title_add\": \"Зарезервувати тему\",\n    \"prefs_reservations_dialog_title_edit\": \"Редагувати зарезервовану тему\",\n    \"prefs_reservations_title\": \"Зарезервовані теми\",\n    \"prefs_reservations_delete_button\": \"Скинути доступ до теми\",\n    \"prefs_reservations_dialog_description\": \"Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.\",\n    \"prefs_reservations_dialog_topic_label\": \"Тема\",\n    \"prefs_reservations_dialog_access_label\": \"Доступ\",\n    \"reservation_delete_dialog_description\": \"Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.\",\n    \"reservation_delete_dialog_submit_button\": \"Видалити резервування\",\n    \"publish_dialog_call_item\": \"Телефонувати за номером {{номер}}\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"Немає підтверджених номерів телефонів\",\n    \"prefs_reservations_dialog_title_delete\": \"Видалити резервування теми\",\n    \"reservation_delete_dialog_action_delete_title\": \"Видалення кешованих повідомлень і вкладень\",\n    \"reservation_delete_dialog_action_keep_title\": \"Збереження кешованих повідомлень і вкладень\",\n    \"reservation_delete_dialog_action_keep_description\": \"Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.\",\n    \"reservation_delete_dialog_action_delete_description\": \"Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована.\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"Сповіщення з інших серверів не надходитимуть, якщо вебзастосунок не відкрито\",\n    \"publish_dialog_checkbox_markdown\": \"Форматувати як Markdown\",\n    \"alert_notification_ios_install_required_description\": \"Натисніть піктограму \\\"Поділитися\\\" та \\\"Додати на головний екран\\\", щоб увімкнути сповіщення на iOS\",\n    \"prefs_appearance_theme_dark\": \"Темний режим\",\n    \"web_push_unknown_notification_title\": \"Отримано невідоме сповіщення від сервера\",\n    \"action_bar_mute_notifications\": \"Вимкнути сповіщення\",\n    \"action_bar_unmute_notifications\": \"Увімкнути сповіщення\",\n    \"alert_notification_permission_denied_title\": \"Сповіщення заблоковано\",\n    \"alert_notification_permission_denied_description\": \"Будь ласка, увімкніть їх повторно у своєму браузері\",\n    \"notifications_actions_failed_notification\": \"Невдала дія\",\n    \"prefs_notifications_web_push_title\": \"Фонові сповіщення\",\n    \"prefs_notifications_web_push_enabled_description\": \"Сповіщення надходитимуть навіть якщо вебзастосунок не запущений (за допомоги Web Push)\",\n    \"prefs_notifications_web_push_disabled_description\": \"Сповіщення надходитимуть якщо вебзастосунок запущений (за допомоги WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"Увімкнено для {{server}}\",\n    \"prefs_notifications_web_push_disabled\": \"Вимкнено\",\n    \"prefs_appearance_theme_title\": \"Тема\",\n    \"prefs_appearance_theme_system\": \"Система (за замовчуванням)\",\n    \"prefs_appearance_theme_light\": \"Світлий режим\",\n    \"error_boundary_button_reload_ntfy\": \"Перезавантажити ntfy\",\n    \"web_push_subscription_expiring_title\": \"Сповіщення буде призупинено\",\n    \"web_push_subscription_expiring_body\": \"Відкрийте ntfy, щоб продовжити отримувати сповіщення\",\n    \"web_push_unknown_notification_body\": \"Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку\",\n    \"alert_notification_ios_install_required_title\": \"потрібно встановити на iOS\"\n}\n"
  },
  {
    "path": "web/public/static/langs/uz.json",
    "content": "{\n    \"signup_title\": \"ntfy hisobini yaratish\",\n    \"signup_form_password\": \"Parol\",\n    \"signup_form_confirm_password\": \"Parolni tasdiqlang\",\n    \"signup_error_username_taken\": \"Foydalanuvchi nomi {{username}} allaqachon foydalanilmoqda\",\n    \"signup_error_creation_limit_reached\": \"Boshqa hisob raqam ocha olmaysiz\",\n    \"login_title\": \"Ntfy hisobingizga kiring\",\n    \"login_form_button_submit\": \"Kirish\",\n    \"login_link_signup\": \"Ro'yxatdan o'tish\",\n    \"login_disabled\": \"Kirish o'chirilgan\",\n    \"action_bar_show_menu\": \"Menyuni ko'rsatish\",\n    \"action_bar_logo_alt\": \"ntfy logotipi\",\n    \"action_bar_settings\": \"Sozlamalar\",\n    \"action_bar_change_display_name\": \"Ko'rsatilgan nomni o'zgartiring\",\n    \"action_bar_reservation_add\": \"Zaxira mavzusi\",\n    \"common_cancel\": \"Bekor qilish\",\n    \"common_save\": \"Saqlash\",\n    \"common_add\": \"Qo‘shish\",\n    \"common_back\": \"Orqaga\",\n    \"common_copy_to_clipboard\": \"Xotiraga nusxalash\",\n    \"signup_form_username\": \"Foydalanuvchi nomi\",\n    \"signup_form_button_submit\": \"Ro‘yxatdan o‘tish\",\n    \"signup_form_toggle_password_visibility\": \"Parol ko‘rinishini o‘zgartirish\",\n    \"signup_already_have_account\": \"Hisobingiz bormi? Tizimga kiring!\",\n    \"signup_disabled\": \"Ro‘yxatdan o‘tish o‘chirilgan\",\n    \"action_bar_account\": \"Hisob\"\n}\n"
  },
  {
    "path": "web/public/static/langs/vi.json",
    "content": "{\n    \"common_add\": \"Thêm\",\n    \"common_back\": \"Quay lại\",\n    \"signup_title\": \"Tạo tài khoản ntfy\",\n    \"signup_form_toggle_password_visibility\": \"Hiện mật khẩu\",\n    \"login_form_button_submit\": \"Đăng nhập\",\n    \"common_copy_to_clipboard\": \"Lưu vào clipboard\",\n    \"signup_form_username\": \"Tên đăng nhập\",\n    \"signup_already_have_account\": \"Đã có tài khoản? Đăng nhập!\",\n    \"signup_disabled\": \"Đăng kí bị khoá\",\n    \"signup_error_username_taken\": \"Tên đăng nhập {{username}} đã được sử dụng\",\n    \"signup_error_creation_limit_reached\": \"Đã đạt giới hạn tạo tài khoản\",\n    \"login_title\": \"Đăng nhập vào tài khoản ntfy\",\n    \"login_link_signup\": \"Đăng kí\",\n    \"login_disabled\": \"Đăng nhập bị vô hiệu hóa\",\n    \"action_bar_show_menu\": \"Hiện menu\",\n    \"signup_form_password\": \"Mật khẩu\",\n    \"action_bar_settings\": \"Cài đặt\",\n    \"signup_form_confirm_password\": \"Xác nhận mật khẩu\",\n    \"signup_form_button_submit\": \"Đăng kí\",\n    \"action_bar_change_display_name\": \"Đổi tên hiển thị\",\n    \"action_bar_send_test_notification\": \"Gửi thông báo thử\",\n    \"action_bar_clear_notifications\": \"Xóa tất cả thông báo\",\n    \"action_bar_logo_alt\": \"Logo ntfy\",\n    \"action_bar_account\": \"Tài khoản\",\n    \"action_bar_reservation_limit_reached\": \"Đã đạt giới hạn\",\n    \"action_bar_unsubscribe\": \"Hủy đăng kí\",\n    \"action_bar_unmute_notifications\": \"Bật thông báo\",\n    \"action_bar_toggle_mute\": \"Bật/tắt thông báo\",\n    \"action_bar_mute_notifications\": \"Tắt thông báo\",\n    \"common_save\": \"Lưu\",\n    \"common_cancel\": \"Hủy\",\n    \"nav_button_all_notifications\": \"Tất cả thông báo\",\n    \"nav_button_connecting\": \"đang kết nối\",\n    \"nav_upgrade_banner_label\": \"Nâng cấp tài khoản ntfy Pro\",\n    \"alert_not_supported_title\": \"Thông báo không được hỗ trợ\",\n    \"alert_not_supported_description\": \"Thông báo không được hỗ trợ trên trình duyệt của bạn\",\n    \"notifications_list\": \"Danh sách thông báo\",\n    \"notifications_list_item\": \"Thông báo\",\n    \"notifications_mark_read\": \"Đánh dấu đã đọc\",\n    \"notifications_delete\": \"Xoá\",\n    \"notifications_attachment_copy_url_title\": \"Sao chép URL đính kèm vào clipboard\",\n    \"notifications_attachment_copy_url_button\": \"Sao chép URL\",\n    \"notifications_attachment_open_title\": \"Truy cập {{url}}\",\n    \"notifications_click_copy_url_button\": \"Sao chép liên kết\",\n    \"notifications_click_open_button\": \"Mở liên kết\",\n    \"notifications_actions_not_supported\": \"Không được hỗ trợ trên nên tảng web\",\n    \"notifications_actions_http_request_title\": \"Gởi HTTP {{method}} tới {{url}}\",\n    \"action_bar_profile_settings\": \"Cài đặt\",\n    \"message_bar_type_message\": \"Gõ nội dung tại đây\",\n    \"nav_button_account\": \"Tài khoản\",\n    \"nav_button_settings\": \"Cài đặt\",\n    \"nav_button_documentation\": \"Tài liệu\",\n    \"alert_notification_permission_required_title\": \"Thông báo đã bị khoá\",\n    \"alert_notification_permission_required_button\": \"Cấp quyền ngay\",\n    \"alert_notification_permission_denied_title\": \"Thông báo đã bị chặn\",\n    \"alert_notification_ios_install_required_title\": \"Yêu cầu cài đặt iOS\",\n    \"alert_notification_ios_install_required_description\": \"Nhấn vào biểu tượng Chia sẻ và Thêm vào màn hình chính để kích hoạt thông báo trên iOS\",\n    \"alert_notification_permission_required_description\": \"Cấp quyền để trình duyệt hiển thị thông báo trên màn hình\",\n    \"alert_notification_permission_denied_description\": \"Hãy kích hoạt lại trên trình duyệt của bạn\",\n    \"notifications_copied_to_clipboard\": \"Đã lưu vào clipboard\",\n    \"notifications_attachment_file_video\": \"tập tin video\",\n    \"notifications_attachment_file_audio\": \"tập tin âm thanh\",\n    \"notifications_actions_failed_notification\": \"Thực thi thất bại\",\n    \"notifications_new_indicator\": \"Thông báo mới\",\n    \"notifications_click_copy_url_title\": \"Sao liên kết URL vào clipboard\",\n    \"notifications_actions_open_url_title\": \"Truy cập {{url}}\",\n    \"notifications_priority_x\": \"Độ ưu tiên {{priority}}\",\n    \"notifications_attachment_link_expired\": \"liên kết tải đã hết hạn\",\n    \"notifications_attachment_file_image\": \"tập tin hình ảnh\",\n    \"notifications_tags\": \"Thẻ\",\n    \"notifications_attachment_file_document\": \"tập tin khác\",\n    \"action_bar_sign_in\": \"Đăng nhập\",\n    \"notifications_attachment_image\": \"Hình ảnh đính kèm\",\n    \"action_bar_sign_up\": \"Đăng ký\",\n    \"action_bar_profile_title\": \"Hồ sơ\",\n    \"action_bar_toggle_action_menu\": \"Mở/Đóng bảng điều khiển\",\n    \"action_bar_profile_logout\": \"Đăng xuất\",\n    \"notifications_attachment_file_app\": \"tập tin Android\",\n    \"notifications_attachment_link_expires\": \"liên kết đã hết hạn {{date}}\",\n    \"alert_not_supported_context_description\": \"Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.\",\n    \"notifications_attachment_open_button\": \"Mở đính kèm\",\n    \"message_bar_error_publishing\": \"Lỗi khi gửi thông báo\",\n    \"message_bar_show_dialog\": \"Hiện hộp thoại gửi thông báo\",\n    \"message_bar_publish\": \"Gửi thông báo\",\n    \"nav_topics_title\": \"Các topic đã đăng ký\",\n    \"nav_button_publish_message\": \"Gửi thông báo\",\n    \"nav_button_subscribe\": \"Đăng ký topic\",\n    \"nav_button_muted\": \"Đã tắt thông báo\",\n    \"nav_upgrade_banner_description\": \"Đặt trước topic, nhiều thông báo & email hơn, và tệp đính kèm dung lượng lớn hơn\",\n    \"action_bar_reservation_add\": \"Đặt trước topic\",\n    \"action_bar_reservation_edit\": \"Thay đổi thông tin đặt trước\",\n    \"action_bar_reservation_delete\": \"Huỷ đặt trước\",\n    \"notifications_none_for_topic_title\": \"Bạn chưa nhận được thông báo nào cho topic này.\",\n    \"notifications_none_for_topic_description\": \"Để gửi thông báo đến topic này, chỉ cần dùng PUT hoặc POST đến URL của topic.\",\n    \"notifications_none_for_any_title\": \"Bạn chưa nhận được thông báo nào.\",\n    \"notifications_none_for_any_description\": \"Để gửi thông báo đến một topic, bạn chỉ cần dùng PUT hoặc POST đến URL của topic. Dưới đây là một ví dụ với một trong các topic của bạn.\",\n    \"notifications_no_subscriptions_title\": \"Có vẻ như bạn chưa đăng ký topic nào.\",\n    \"notifications_no_subscriptions_description\": \"Bấm vào liên kết \\\"{{linktext}}\\\" để tạo hoặc đăng ký một chủ đề. Sau đó, bạn có thể gửi tin nhắn qua PUT hoặc POST và sẽ nhận thông báo tại đây.\",\n    \"notifications_example\": \"Ví dụ\"\n}\n"
  },
  {
    "path": "web/public/static/langs/zh_Hans.json",
    "content": "{\n    \"action_bar_show_menu\": \"显示菜单\",\n    \"action_bar_logo_alt\": \"ntfy图标\",\n    \"action_bar_mute_notifications\": \"静音\",\n    \"action_bar_settings\": \"设置\",\n    \"action_bar_send_test_notification\": \"发送测试通知\",\n    \"action_bar_clear_notifications\": \"清除所有通知\",\n    \"action_bar_unsubscribe\": \"取消订阅\",\n    \"action_bar_toggle_action_menu\": \"开启或关闭操作菜单\",\n    \"action_bar_unmute_notifications\": \"取消静音\",\n    \"message_bar_type_message\": \"在此处输入消息\",\n    \"message_bar_show_dialog\": \"显示发布对话框\",\n    \"message_bar_publish\": \"发布消息\",\n    \"nav_topics_title\": \"订阅主题\",\n    \"nav_button_all_notifications\": \"全部通知\",\n    \"nav_button_documentation\": \"文档\",\n    \"nav_button_publish_message\": \"发布通知\",\n    \"nav_button_subscribe\": \"订阅主题\",\n    \"nav_button_connecting\": \"正在连接\",\n    \"alert_notification_permission_required_title\": \"已禁用通知\",\n    \"alert_notification_permission_required_description\": \"授予浏览器显示桌面通知的权限。\",\n    \"alert_notification_permission_required_button\": \"现在授予\",\n    \"alert_not_supported_title\": \"不支持通知\",\n    \"alert_not_supported_description\": \"您的浏览器不支持通知。\",\n    \"alert_notification_ios_install_required_description\": \"要接收通知，请在iOS上点击分享图标，然后添加到主屏幕。\",\n    \"alert_notification_ios_install_required_title\": \"需要安装iOS应用程序\",\n    \"alert_notification_permission_denied_description\": \"你已禁用通知。要重新启用通知，请在浏览器设置中启用通知。\",\n    \"alert_notification_permission_denied_title\": \"已禁用通知\",\n    \"notifications_list\": \"通知列表\",\n    \"notifications_list_item\": \"通知\",\n    \"notifications_mark_read\": \"标记为已读\",\n    \"notifications_copied_to_clipboard\": \"复制到剪贴板\",\n    \"notifications_tags\": \"标记\",\n    \"notifications_priority_x\": \"优先级 {{priority}}\",\n    \"notifications_new_indicator\": \"新通知\",\n    \"notifications_attachment_open_button\": \"打开附件\",\n    \"notifications_attachment_link_expires\": \"链接过期 {{date}}\",\n    \"notifications_attachment_link_expired\": \"下载链接已过期\",\n    \"notifications_attachment_file_image\": \"图片文件\",\n    \"notifications_attachment_image\": \"附件图片\",\n    \"notifications_attachment_file_video\": \"视频文件\",\n    \"notifications_attachment_file_audio\": \"音频文件\",\n    \"notifications_attachment_file_app\": \"安卓应用文件\",\n    \"notifications_attachment_file_document\": \"其他文件\",\n    \"notifications_click_copy_url_title\": \"复制链接地址到剪贴板\",\n    \"notifications_click_copy_url_button\": \"复制链接\",\n    \"notifications_click_open_button\": \"打开链接\",\n    \"action_bar_toggle_mute\": \"暂停或恢复通知\",\n    \"nav_button_muted\": \"已暂停通知\",\n    \"notifications_actions_not_supported\": \"网页应用程序不支持操作\",\n    \"notifications_none_for_topic_title\": \"您尚未收到有关此主题的任何通知。\",\n    \"notifications_none_for_any_title\": \"您尚未收到任何通知。\",\n    \"notifications_none_for_any_description\": \"要向此主题发送通知，只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。\",\n    \"notifications_no_subscriptions_title\": \"看起来你还没有任何订阅。\",\n    \"notifications_example\": \"示例\",\n    \"notifications_more_details\": \"有关更多信息，请查看<websiteLink>网站</websiteLink>或<docsLink>文档</docsLink>。\",\n    \"notifications_loading\": \"正在加载通知……\",\n    \"publish_dialog_title_topic\": \"发布到 {{topic}}\",\n    \"publish_dialog_title_no_topic\": \"发布通知\",\n    \"publish_dialog_progress_uploading\": \"正在上传……\",\n    \"publish_dialog_progress_uploading_detail\": \"正在上传 {{loaded}}/{{total}} ({{percent}}%) ……\",\n    \"publish_dialog_message_published\": \"已发布通知\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"超过 {{fileSizeLimit}} 文件限制和配额，剩余 {{remainingBytes}}\",\n    \"publish_dialog_emoji_picker_show\": \"选择表情符号\",\n    \"publish_dialog_priority_min\": \"最低优先级\",\n    \"publish_dialog_priority_low\": \"低优先级\",\n    \"publish_dialog_priority_default\": \"默认优先级\",\n    \"publish_dialog_priority_high\": \"高优先级\",\n    \"publish_dialog_priority_max\": \"最高优先级\",\n    \"publish_dialog_topic_label\": \"主题名称\",\n    \"publish_dialog_topic_placeholder\": \"主题名称，例如 phil_alerts\",\n    \"publish_dialog_topic_reset\": \"重置主题\",\n    \"publish_dialog_title_label\": \"主题\",\n    \"publish_dialog_message_label\": \"消息\",\n    \"publish_dialog_message_placeholder\": \"在此输入消息\",\n    \"publish_dialog_tags_label\": \"标记\",\n    \"publish_dialog_priority_label\": \"优先级\",\n    \"publish_dialog_base_url_label\": \"服务链接地址\",\n    \"publish_dialog_base_url_placeholder\": \"服务链接地址，例如 https://example.com\",\n    \"publish_dialog_click_label\": \"点击链接地址\",\n    \"publish_dialog_click_placeholder\": \"点击通知时打开链接地址\",\n    \"publish_dialog_email_placeholder\": \"将通知转发到的地址，例如 phil@example.com\",\n    \"publish_dialog_email_reset\": \"移除电子邮件转发\",\n    \"publish_dialog_filename_label\": \"文件名\",\n    \"publish_dialog_filename_placeholder\": \"附件文件名\",\n    \"publish_dialog_delay_label\": \"延期\",\n    \"publish_dialog_other_features\": \"其它功能：\",\n    \"publish_dialog_attach_placeholder\": \"使用链接地址附加文件，例如 https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_delay_reset\": \"删除延期投递\",\n    \"publish_dialog_attach_reset\": \"移除附件链接地址\",\n    \"publish_dialog_chip_click_label\": \"点击链接地址\",\n    \"publish_dialog_chip_email_label\": \"转发邮件\",\n    \"publish_dialog_chip_attach_file_label\": \"本地文件附件\",\n    \"publish_dialog_chip_topic_label\": \"变更主题\",\n    \"publish_dialog_button_cancel_sending\": \"取消发送\",\n    \"publish_dialog_checkbox_publish_another\": \"发布另一个\",\n    \"publish_dialog_attached_file_title\": \"附件文件：\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"附件文件名\",\n    \"publish_dialog_attached_file_remove\": \"删除附件文件\",\n    \"publish_dialog_drop_file_here\": \"将文件拖拽至此\",\n    \"emoji_picker_search_placeholder\": \"查找表情符号\",\n    \"emoji_picker_search_clear\": \"清除搜索\",\n    \"subscribe_dialog_subscribe_title\": \"订阅主题\",\n    \"publish_dialog_chip_delay_label\": \"延期投递\",\n    \"publish_dialog_chip_attach_url_label\": \"链接附件地址\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"使用其他服务器\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"订阅\",\n    \"subscribe_dialog_login_title\": \"请登录\",\n    \"subscribe_dialog_login_description\": \"本主题受密码保护，请输入用户名和密码进行订阅。\",\n    \"subscribe_dialog_login_username_label\": \"用户名，例如 phil\",\n    \"subscribe_dialog_login_password_label\": \"密码\",\n    \"common_back\": \"返回\",\n    \"subscribe_dialog_login_button_login\": \"登录\",\n    \"subscribe_dialog_error_user_not_authorized\": \"未授权 {{username}} 用户\",\n    \"subscribe_dialog_error_user_anonymous\": \"匿名\",\n    \"prefs_notifications_title\": \"通知\",\n    \"prefs_notifications_sound_title\": \"通知提示音\",\n    \"prefs_notifications_sound_description_none\": \"收到通知时不播放任何声音\",\n    \"prefs_notifications_sound_description_some\": \"收到通知时播放 {{sound}} 声音\",\n    \"prefs_notifications_sound_no_sound\": \"静音\",\n    \"prefs_notifications_sound_play\": \"播放选中声音\",\n    \"prefs_notifications_min_priority_title\": \"最低优先级\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"仅显示优先级为{{number}}（{{name}}）或以上的通知\",\n    \"prefs_notifications_min_priority_description_max\": \"仅显示最高优先级的通知\",\n    \"prefs_notifications_min_priority_any\": \"任意优先级\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"低优先级或更高\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"默认优先级或更高\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"高优先级或更高\",\n    \"prefs_notifications_min_priority_max_only\": \"仅最高优先级\",\n    \"prefs_notifications_delete_after_never\": \"从不\",\n    \"prefs_notifications_delete_after_one_month\": \"一月后\",\n    \"prefs_notifications_delete_after_one_week\": \"一周后\",\n    \"prefs_notifications_delete_after_never_description\": \"永不自动删除通知\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"三小时后自动删除通知\",\n    \"prefs_notifications_delete_after_one_day_description\": \"一天后自动删除通知\",\n    \"prefs_notifications_delete_after_one_week_description\": \"一周后自动删除通知\",\n    \"prefs_notifications_delete_after_one_month_description\": \"一月后后自动删除通知\",\n    \"prefs_notifications_web_push_disabled\": \"已暂用\",\n    \"prefs_notifications_web_push_disabled_description\": \"当网页程序在运行时将会收到通知 (透过 WebSocket)\",\n    \"prefs_notifications_web_push_enabled\": \"已为 {{server}} 启用\",\n    \"prefs_notifications_web_push_enabled_description\": \"即使网页程序未有运行亦会收到通知 (via Web Push)\",\n    \"prefs_notifications_web_push_title\": \"背景通知\",\n    \"prefs_users_title\": \"管理用户\",\n    \"prefs_users_description\": \"在此处添加/删除受保护主题的用户。请注意，用户名和密码存储在浏览器的本地存储中。\",\n    \"prefs_users_add_button\": \"添加用户\",\n    \"prefs_users_dialog_title_add\": \"添加用户\",\n    \"prefs_users_dialog_title_edit\": \"编辑用户\",\n    \"prefs_users_dialog_username_label\": \"用户名，例如 phil\",\n    \"prefs_users_dialog_password_label\": \"密码\",\n    \"common_cancel\": \"取消\",\n    \"common_save\": \"保存\",\n    \"prefs_appearance_title\": \"外观\",\n    \"prefs_appearance_language_title\": \"语言\",\n    \"prefs_appearance_theme_title\": \"主題\",\n    \"prefs_appearance_theme_system\": \"系統 (預設)\",\n    \"prefs_appearance_theme_dark\": \"黑暗模式\",\n    \"prefs_appearance_theme_light\": \"光亮模式\",\n    \"priority_min\": \"最低\",\n    \"priority_low\": \"低\",\n    \"priority_default\": \"默认\",\n    \"priority_high\": \"高\",\n    \"priority_max\": \"最高\",\n    \"error_boundary_title\": \"天啊，ntfy 崩溃了\",\n    \"prefs_users_table_base_url_header\": \"服务链接地址\",\n    \"prefs_users_dialog_base_url_label\": \"服务链接地址，例如 https://ntfy.sh\",\n    \"error_boundary_button_copy_stack_trace\": \"复制堆栈跟踪\",\n    \"error_boundary_button_reload_ntfy\": \"重新加载 ntfy\",\n    \"error_boundary_stack_trace\": \"堆栈跟踪\",\n    \"error_boundary_gathering_info\": \"收集更多信息……\",\n    \"error_boundary_unsupported_indexeddb_title\": \"不支持隐私浏览\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Ntfy Web应用程序需要IndexedDB才能运行，并且您的浏览器在私隐私浏览模式下不支持IndexedDB。<br/><br/>虽然这很不幸，但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义，因为所有东西都存储在浏览器存储中。您可以在<githubLink>本GitHub问题</githubLink>中阅读有关它的更多信息，或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上与我们交谈。\",\n    \"message_bar_error_publishing\": \"发布通知时出错\",\n    \"nav_button_settings\": \"设置\",\n    \"notifications_delete\": \"删除\",\n    \"notifications_attachment_copy_url_title\": \"将附件中链接地址复制到剪贴板\",\n    \"notifications_attachment_copy_url_button\": \"复制链接地址\",\n    \"notifications_attachment_open_title\": \"转到 {{url}}\",\n    \"notifications_actions_http_request_title\": \"发送 HTTP {{method}} 到 {{url}}\",\n    \"notifications_actions_failed_notification\": \"通知失败\",\n    \"notifications_actions_open_url_title\": \"转到 {{url}}\",\n    \"notifications_none_for_topic_description\": \"要向此主题发送通知，只需使用 PUT 或 POST 到主题链接即可。\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"主题名，例如 phil_alerts\",\n    \"notifications_no_subscriptions_description\": \"单击 \\\"{{linktext}}\\\" 链接以创建或订阅主题。之后，您可以使用 PUT 或 POST 发送消息，您将在这里收到通知。\",\n    \"publish_dialog_attachment_limits_file_reached\": \"超过 {{fileSizeLimit}} 文件限制\",\n    \"publish_dialog_title_placeholder\": \"通知标题，如磁盘空间告警\",\n    \"publish_dialog_email_label\": \"电子邮件\",\n    \"publish_dialog_button_send\": \"发送\",\n    \"publish_dialog_checkbox_markdown\": \"格式化为 Markdown\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"超过配额，剩余 {{remainingBytes}}\",\n    \"publish_dialog_attach_label\": \"附件链接地址\",\n    \"publish_dialog_click_reset\": \"移除点击连接地址\",\n    \"publish_dialog_button_cancel\": \"取消\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"取消\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"服务地址地址\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"当网页程序未开启， 将不会收到来自其他服务器的通知\",\n    \"prefs_notifications_min_priority_description_any\": \"显示所有通知，无论优先级如何\",\n    \"prefs_notifications_delete_after_title\": \"删除通知\",\n    \"prefs_notifications_delete_after_three_hours\": \"三小时后\",\n    \"prefs_users_delete_button\": \"删除用户\",\n    \"prefs_users_table_user_header\": \"用户\",\n    \"common_add\": \"添加\",\n    \"prefs_notifications_delete_after_one_day\": \"一天后\",\n    \"error_boundary_description\": \"这显然不应该发生。对此非常抱歉。<br/>如果您有时间，请<githubLink>在GitHub</githubLink>上报告，或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。\",\n    \"prefs_users_table\": \"用户表\",\n    \"prefs_users_edit_button\": \"编辑用户\",\n    \"publish_dialog_tags_placeholder\": \"英文逗号分隔的标签列表，例如 warning, srv1-backup\",\n    \"publish_dialog_details_examples_description\": \"有关所有发送功能的示例和详细说明，请参阅<docsLink>文档</docsLink>。\",\n    \"subscribe_dialog_subscribe_description\": \"主题可能不受密码保护，因此请选择一个不容易被猜中的名字。订阅后，您可以使用 PUT/POST 通知。\",\n    \"publish_dialog_delay_placeholder\": \"延期投递，例如 {{unixTimestamp}}、{{relativeTime}} 或 {{naturalLanguage}} （仅限英语）\",\n    \"account_usage_basis_ip_description\": \"此账户的使用统计信息和限制基于您的 IP 地址，因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。\",\n    \"account_usage_cannot_create_portal_session\": \"无法打开计费门户\",\n    \"account_delete_title\": \"删除账户\",\n    \"account_delete_description\": \"永久删除您的账户\",\n    \"signup_error_username_taken\": \"用户名 {{username}} 已被占用\",\n    \"signup_error_creation_limit_reached\": \"已达到账户创建限制\",\n    \"login_title\": \"请登录你的 ntfy 账户\",\n    \"action_bar_change_display_name\": \"更改显示名称\",\n    \"action_bar_reservation_add\": \"保留主题\",\n    \"action_bar_reservation_delete\": \"移除保留\",\n    \"action_bar_reservation_limit_reached\": \"达到限制\",\n    \"action_bar_profile_title\": \"个人资料\",\n    \"action_bar_profile_settings\": \"设置\",\n    \"action_bar_profile_logout\": \"登出\",\n    \"action_bar_sign_in\": \"登录\",\n    \"action_bar_sign_up\": \"注册\",\n    \"nav_button_account\": \"账户\",\n    \"nav_upgrade_banner_label\": \"升级到 ntfy Pro\",\n    \"nav_upgrade_banner_description\": \"保留主题，更多消息和邮件，以及更大的附件\",\n    \"alert_not_supported_context_description\": \"通知仅支持 HTTPS。这是 <mdnLink>Notifications API</mdnLink> 的限制。\",\n    \"display_name_dialog_title\": \"更改显示名称\",\n    \"display_name_dialog_description\": \"为订阅列表中显示的主题设置一个替代名称。这有助于更轻松地识别名称复杂的主题。\",\n    \"display_name_dialog_placeholder\": \"显示名称\",\n    \"reserve_dialog_checkbox_label\": \"保留主题并配置访问\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"生成名称\",\n    \"account_basics_username_description\": \"嘿，那是你 ❤\",\n    \"account_basics_password_description\": \"更改您的账户密码\",\n    \"account_basics_password_dialog_title\": \"更改密码\",\n    \"account_basics_password_dialog_current_password_label\": \"当前密码\",\n    \"account_basics_password_dialog_new_password_label\": \"新密码\",\n    \"account_basics_password_dialog_confirm_password_label\": \"确认密码\",\n    \"account_basics_password_dialog_button_submit\": \"更改密码\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"密码错误\",\n    \"account_usage_title\": \"使用量\",\n    \"account_usage_of_limit\": \"{{limit}} 的\",\n    \"account_usage_unlimited\": \"无限\",\n    \"account_usage_limits_reset_daily\": \"使用限制每天午夜 (UTC) 重置\",\n    \"account_basics_tier_title\": \"账户类型\",\n    \"account_basics_tier_description\": \"您账户的权限级别\",\n    \"account_basics_tier_admin\": \"管理员\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"（有 {{tier}} 等级）\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"（无等级）\",\n    \"account_basics_tier_basic\": \"基础版\",\n    \"account_basics_tier_free\": \"免费\",\n    \"account_basics_tier_upgrade_button\": \"升级到专业版\",\n    \"account_basics_tier_change_button\": \"改变\",\n    \"account_basics_tier_paid_until\": \"订阅已支付至 {{date}}，并将自动续订\",\n    \"account_basics_tier_manage_billing_button\": \"管理计费\",\n    \"account_usage_messages_title\": \"已发布消息\",\n    \"account_usage_emails_title\": \"已发送电子邮件\",\n    \"account_usage_reservations_title\": \"保留主题\",\n    \"account_usage_reservations_none\": \"此账户没有保留主题\",\n    \"account_usage_attachment_storage_title\": \"附件存储\",\n    \"account_usage_attachment_storage_description\": \"每个文件 {{filesize}}，在 {{expiry}} 后删除\",\n    \"account_upgrade_dialog_button_pay_now\": \"立即付款并订阅\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"取消订阅\",\n    \"account_upgrade_dialog_button_update_subscription\": \"更新订阅\",\n    \"account_tokens_dialog_title_create\": \"创建访问令牌\",\n    \"account_tokens_dialog_title_edit\": \"编辑访问令牌\",\n    \"account_tokens_dialog_title_delete\": \"删除访问令牌\",\n    \"account_tokens_dialog_button_cancel\": \"取消\",\n    \"account_tokens_dialog_expires_label\": \"访问令牌过期于\",\n    \"account_tokens_dialog_expires_unchanged\": \"保持过期日期不变\",\n    \"account_tokens_dialog_expires_x_hours\": \"令牌在 {{hours}} 小时后过期\",\n    \"account_tokens_dialog_expires_x_days\": \"令牌在 {{days}} 天后过期\",\n    \"account_tokens_dialog_expires_never\": \"令牌永不过期\",\n    \"account_tokens_delete_dialog_title\": \"删除访问令牌\",\n    \"account_tokens_delete_dialog_description\": \"在删除访问令牌之前，请确保没有应用程序或脚本正在活跃使用它。 <strong>此操作无法撤消</strong>。\",\n    \"account_tokens_delete_dialog_submit_button\": \"永久删除令牌\",\n    \"prefs_users_description_no_sync\": \"用户和密码不会同步到您的账户。\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"无法删除或编辑已登录用户\",\n    \"prefs_reservations_title\": \"保留主题\",\n    \"prefs_reservations_description\": \"您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权，并允许您为其他用户定义对该主题的访问权限。\",\n    \"prefs_reservations_limit_reached\": \"您已达到保留主题限制。\",\n    \"prefs_reservations_add_button\": \"添加保留主题\",\n    \"prefs_reservations_edit_button\": \"编辑主题访问\",\n    \"prefs_reservations_delete_button\": \"重置主题访问\",\n    \"prefs_reservations_table\": \"保留主题表格\",\n    \"prefs_reservations_table_topic_header\": \"主题\",\n    \"prefs_reservations_table_access_header\": \"访问\",\n    \"prefs_reservations_table_everyone_deny_all\": \"只有我可以发布和订阅\",\n    \"prefs_reservations_table_everyone_read_only\": \"我可以发布和订阅，每个人都可以订阅\",\n    \"prefs_reservations_table_everyone_write_only\": \"我可以发布和订阅，每个人都可以发布\",\n    \"prefs_reservations_table_everyone_read_write\": \"每个人都可以发布和订阅\",\n    \"prefs_reservations_table_not_subscribed\": \"未订阅\",\n    \"prefs_reservations_table_click_to_subscribe\": \"点击以订阅\",\n    \"prefs_reservations_dialog_title_add\": \"保留主题\",\n    \"prefs_reservations_dialog_title_edit\": \"编辑保留主题\",\n    \"prefs_reservations_dialog_title_delete\": \"删除主题保留\",\n    \"prefs_reservations_dialog_description\": \"保留主题使您拥有该主题的所有权，并允许您为其他用户定义对该主题的访问权限。\",\n    \"prefs_reservations_dialog_topic_label\": \"主题\",\n    \"prefs_reservations_dialog_access_label\": \"访问\",\n    \"reservation_delete_dialog_description\": \"删除保留会放弃对该主题的所有权，并允许其他人保留它。您可以保留或删除现有邮件和附件。\",\n    \"reservation_delete_dialog_action_keep_title\": \"保留缓存的邮件和附件\",\n    \"reservation_delete_dialog_action_keep_description\": \"缓存在服务器上的消息和附件将对知道主题名称的人公开可见。\",\n    \"reservation_delete_dialog_action_delete_title\": \"删除缓存的邮件和附件\",\n    \"reservation_delete_dialog_action_delete_description\": \"缓存的邮件和附件将被永久删除。此操作无法撤消。\",\n    \"reservation_delete_dialog_submit_button\": \"删除保留\",\n    \"account_delete_dialog_description\": \"这将永久删除您的账户，包括存储在服务器上的所有数据。删除后，您的用户名将在 7 天内不可用。如果您真的想继续，请在下面的框中使用您的密码进行确认。\",\n    \"account_delete_dialog_label\": \"密码\",\n    \"account_delete_dialog_button_cancel\": \"取消\",\n    \"account_delete_dialog_button_submit\": \"永久删除账户\",\n    \"account_delete_dialog_billing_warning\": \"删除您的账户也会立即取消您的计费订阅。您将无法再访问计费仪表板。\",\n    \"account_upgrade_dialog_title\": \"更改账户等级\",\n    \"account_upgrade_dialog_cancel_warning\": \"这将<strong>取消您的订阅</strong>，并在 {{date}} 降级您的账户。在那一天，主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>按比例分配</strong>：在付费计划之间升级时，差价将被<strong>立刻收取</strong>。在降级到较低级别时，余额将被用于支付未来的账单周期。\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"所选等级允许的保留主题少于当前等级。在更改您的等级之前，<strong>请至少删除 1 项保留</strong>。您可以在<Link>设置</Link>中删除保留。\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"所选等级允许的保留主题少于当前等级。在更改您的等级之前，<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"{{reservations}} 条保留主题\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"{{messages}} 条每日消息\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"{{emails}} 条每日邮件\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"{{filesize}} 每个文件\",\n    \"signup_form_confirm_password\": \"确认密码\",\n    \"signup_form_button_submit\": \"注册\",\n    \"signup_form_toggle_password_visibility\": \"切换密码可见性\",\n    \"signup_title\": \"创建一个 ntfy 账户\",\n    \"signup_form_username\": \"用户名\",\n    \"signup_form_password\": \"密码\",\n    \"signup_already_have_account\": \"已有账户？登录！\",\n    \"signup_disabled\": \"注册已禁用\",\n    \"login_form_button_submit\": \"登录\",\n    \"login_link_signup\": \"注册\",\n    \"login_disabled\": \"登录已禁用\",\n    \"action_bar_account\": \"账户\",\n    \"action_bar_reservation_edit\": \"更改保留\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"主题已保留\",\n    \"account_basics_title\": \"账户\",\n    \"account_basics_username_title\": \"用户名\",\n    \"account_basics_username_admin_tooltip\": \"你是管理员\",\n    \"account_basics_password_title\": \"密码\",\n    \"account_basics_tier_payment_overdue\": \"您的付款已逾期。请更新您的付款方式，否则您的账户将很快被降级。\",\n    \"account_basics_tier_canceled_subscription\": \"您的订阅已取消，并将在 {{date}} 降级为免费账户。\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} 总存储空间\",\n    \"account_upgrade_dialog_tier_selected_label\": \"已选\",\n    \"account_upgrade_dialog_tier_current_label\": \"当前\",\n    \"account_upgrade_dialog_button_cancel\": \"取消\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"立即注册\",\n    \"account_tokens_title\": \"访问令牌\",\n    \"account_tokens_description\": \"通过 ntfy API 发布和订阅时使用访问令牌，因此您不必发送您的账户凭据。查看<Link>文档</Link>以了解更多信息。\",\n    \"account_tokens_table_token_header\": \"令牌\",\n    \"account_tokens_table_label_header\": \"标签\",\n    \"account_tokens_table_last_access_header\": \"最后访问\",\n    \"account_tokens_table_expires_header\": \"过期\",\n    \"account_tokens_table_never_expires\": \"永不过期\",\n    \"account_tokens_table_current_session\": \"当前浏览器会话\",\n    \"common_copy_to_clipboard\": \"复制到剪贴板\",\n    \"account_tokens_table_copied_to_clipboard\": \"已复制访问令牌\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"无法编辑或删除当前会话令牌\",\n    \"account_tokens_table_create_token_button\": \"创建访问令牌\",\n    \"account_tokens_table_last_origin_tooltip\": \"于IP地址 {{ip}}，点击查找\",\n    \"account_tokens_dialog_label\": \"标签，例如：Radarr 通知\",\n    \"account_tokens_dialog_button_create\": \"创建令牌\",\n    \"account_tokens_dialog_button_update\": \"更新令牌\",\n    \"account_basics_tier_interval_monthly\": \"每月\",\n    \"account_basics_tier_interval_yearly\": \"每年\",\n    \"account_upgrade_dialog_interval_monthly\": \"每月\",\n    \"account_upgrade_dialog_interval_yearly\": \"每年\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"节省 {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"节省高达 {{discount}}%\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"无保留主题\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"月\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} 每年。按月计费。\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{价格}} 按年计费。节省 {{save}}。\",\n    \"account_upgrade_dialog_billing_contact_email\": \"有关账单问题，请直接<Link>联系我们 </Link>。\",\n    \"account_upgrade_dialog_billing_contact_website\": \"有关账单问题，请参考我们的<Link>网站 </Link>。\",\n    \"publish_dialog_call_item\": \"拨打电话 {{number}}\",\n    \"publish_dialog_call_label\": \"拨号\",\n    \"publish_dialog_chip_call_label\": \"拨号\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"未验证的手机号\",\n    \"account_basics_phone_numbers_title\": \"电话号码\",\n    \"account_basics_phone_numbers_description\": \"电话通知\",\n    \"account_basics_phone_numbers_dialog_description\": \"要使用来电通知功能，您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"验证码\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"例如：123456\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"确认码\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"短信\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"拨打\",\n    \"publish_dialog_call_reset\": \"清空拨号\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"无可执行的电话号码\",\n    \"account_basics_phone_numbers_dialog_title\": \"添加电话号码\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"电话号码已复制到剪贴板\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"电话号码\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"例如：+1222333444\",\n    \"account_usage_calls_title\": \"已拨打电话\",\n    \"account_usage_calls_none\": \"此帐号无法拨打电话\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"一条保留主题\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"一封每日邮件\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"一通每日电话\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"发送信息\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"拨打电话\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"一条每日消息\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"{{calls}} 通每日电话\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"无电话呼叫\",\n    \"web_push_subscription_expiring_title\": \"通知将被暂停\",\n    \"web_push_subscription_expiring_body\": \"打开ntfy以继续接收通知\",\n    \"web_push_unknown_notification_title\": \"接收到未知通知\",\n    \"web_push_unknown_notification_body\": \"你可能需要打开网页来更新ntfy\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"已设置的用户无法被编辑或删除\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"无法编辑或删除已设置的令牌\"\n}\n"
  },
  {
    "path": "web/public/static/langs/zh_Hant.json",
    "content": "{\n    \"account_basics_password_description\": \"更改你的帳戶密碼\",\n    \"account_basics_password_dialog_button_submit\": \"更改密碼\",\n    \"account_basics_password_dialog_confirm_password_label\": \"確認密碼\",\n    \"account_basics_password_dialog_current_password_incorrect\": \"密碼錯誤\",\n    \"account_basics_password_dialog_current_password_label\": \"當前密碼\",\n    \"account_basics_password_dialog_new_password_label\": \"新密碼\",\n    \"account_basics_password_dialog_title\": \"更改密碼\",\n    \"account_basics_password_title\": \"密碼\",\n    \"account_basics_phone_numbers_copied_to_clipboard\": \"電話號碼已複製到剪貼板\",\n    \"account_basics_phone_numbers_description\": \"電話通知\",\n    \"account_basics_phone_numbers_dialog_channel_call\": \"撥打\",\n    \"account_basics_phone_numbers_dialog_channel_sms\": \"短信\",\n    \"account_basics_phone_numbers_dialog_check_verification_button\": \"確認碼\",\n    \"account_basics_phone_numbers_dialog_code_label\": \"驗證碼\",\n    \"account_basics_phone_numbers_dialog_code_placeholder\": \"例如：123456\",\n    \"account_basics_phone_numbers_dialog_description\": \"要使用來電通知功能，你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。\",\n    \"account_basics_phone_numbers_dialog_number_label\": \"電話號碼\",\n    \"account_basics_phone_numbers_dialog_number_placeholder\": \"例如：+1222333444\",\n    \"account_basics_phone_numbers_dialog_title\": \"新增電話號碼\",\n    \"account_basics_phone_numbers_dialog_verify_button_call\": \"撥打電話\",\n    \"account_basics_phone_numbers_dialog_verify_button_sms\": \"發送資訊\",\n    \"account_basics_phone_numbers_no_phone_numbers_yet\": \"無可執行的電話號碼\",\n    \"account_basics_phone_numbers_title\": \"電話號碼\",\n    \"account_basics_tier_admin_suffix_no_tier\": \"（無等級）\",\n    \"account_basics_tier_admin_suffix_with_tier\": \"（有 {{tier}} 等級）\",\n    \"account_basics_tier_admin\": \"管理員\",\n    \"account_basics_tier_basic\": \"基礎版\",\n    \"account_basics_tier_canceled_subscription\": \"你的訂閱已取消，並將在 {{date}} 降級為免費帳戶。\",\n    \"account_basics_tier_change_button\": \"改變\",\n    \"account_basics_tier_description\": \"你帳戶的權限級別\",\n    \"account_basics_tier_free\": \"免費\",\n    \"account_basics_tier_interval_monthly\": \"每月\",\n    \"account_basics_tier_interval_yearly\": \"每年\",\n    \"account_basics_tier_manage_billing_button\": \"管理計費\",\n    \"account_basics_tier_paid_until\": \"訂閱已支付至 {{date}}，並將自動續訂\",\n    \"account_basics_tier_payment_overdue\": \"你的付款已逾期。請更新你的付款方式，否則你的帳戶將很快被降級。\",\n    \"account_basics_tier_title\": \"帳戶類型\",\n    \"account_basics_tier_upgrade_button\": \"升級到專業版\",\n    \"account_basics_title\": \"帳戶\",\n    \"account_basics_username_admin_tooltip\": \"你是管理員\",\n    \"account_basics_username_description\": \"嘿，那是你 ❤\",\n    \"account_basics_username_title\": \"用戶名\",\n    \"account_delete_description\": \"永久刪除你的帳戶\",\n    \"account_delete_dialog_billing_warning\": \"刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。\",\n    \"account_delete_dialog_button_cancel\": \"取消\",\n    \"account_delete_dialog_button_submit\": \"永久刪除帳戶\",\n    \"account_delete_dialog_description\": \"這將永久刪除你的帳戶，包括存儲在伺服器上的所有數據。刪除後，你的用戶名將在 7 天內不可用。如果你真的想繼續，請在下面的框中使用你的密碼作確認。\",\n    \"account_delete_dialog_label\": \"密碼\",\n    \"account_delete_title\": \"刪除帳戶\",\n    \"account_tokens_delete_dialog_description\": \"在刪除訪問令牌之前，請確保沒有應用程序或腳本正在活躍使用它。 <strong>此操作無法撤銷</strong>。\",\n    \"account_tokens_delete_dialog_submit_button\": \"永久删除令牌\",\n    \"account_tokens_delete_dialog_title\": \"刪除訪問令牌\",\n    \"account_tokens_description\": \"通過 ntfy API 發布和訂閱時使用訪問令牌，因此你不必發送你的帳戶憑證。查看<Link>文檔</Link>以了解更多資訊。\",\n    \"account_tokens_dialog_button_cancel\": \"取消\",\n    \"account_tokens_dialog_button_create\": \"創建令牌\",\n    \"account_tokens_dialog_button_update\": \"更新令牌\",\n    \"account_tokens_dialog_expires_label\": \"訪問令牌過期於\",\n    \"account_tokens_dialog_expires_never\": \"令牌永不過期\",\n    \"account_tokens_dialog_expires_unchanged\": \"保持過期日期不變\",\n    \"account_tokens_dialog_expires_x_days\": \"令牌在 {{days}} 天後過期\",\n    \"account_tokens_dialog_expires_x_hours\": \"令牌在 {{hours}} 小時後過期\",\n    \"account_tokens_dialog_label\": \"標籤，例如：Radarr 通知\",\n    \"account_tokens_dialog_title_create\": \"創建訪問令牌\",\n    \"account_tokens_dialog_title_delete\": \"刪除訪問令牌\",\n    \"account_tokens_dialog_title_edit\": \"編輯訪問令牌\",\n    \"account_tokens_table_cannot_delete_or_edit\": \"無法編輯或刪除當前會話令牌\",\n    \"account_tokens_table_copied_to_clipboard\": \"已複製訪問令牌\",\n    \"account_tokens_table_create_token_button\": \"創建訪問令牌\",\n    \"account_tokens_table_current_session\": \"當前瀏覽器會話\",\n    \"account_tokens_table_expires_header\": \"過期\",\n    \"account_tokens_table_label_header\": \"標籤\",\n    \"account_tokens_table_last_access_header\": \"最後訪問\",\n    \"account_tokens_table_last_origin_tooltip\": \"於IP地址 {{ip}}，點擊查找\",\n    \"account_tokens_table_never_expires\": \"永不過期\",\n    \"account_tokens_table_token_header\": \"令牌\",\n    \"account_tokens_title\": \"訪問令牌\",\n    \"account_upgrade_dialog_billing_contact_email\": \"有關賬單問題，請直接<Link>聯繫我們 </Link>。\",\n    \"account_upgrade_dialog_billing_contact_website\": \"有關賬單問題，請參考我們的<Link>網站 </Link>。\",\n    \"account_upgrade_dialog_button_cancel_subscription\": \"取消訂閱\",\n    \"account_upgrade_dialog_button_cancel\": \"取消\",\n    \"account_upgrade_dialog_button_pay_now\": \"立即付款並訂閱\",\n    \"account_upgrade_dialog_button_redirect_signup\": \"立即註冊\",\n    \"account_upgrade_dialog_button_update_subscription\": \"更新訂閱\",\n    \"account_upgrade_dialog_cancel_warning\": \"這將<strong>取消你的訂閱</strong>，並在 {{date}} 降級你的帳戶。在那一天，主題保留以及緩存在伺服器上的訊息<strong>將被刪除</strong>。\",\n    \"account_upgrade_dialog_interval_monthly\": \"每月\",\n    \"account_upgrade_dialog_interval_yearly_discount_save_up_to\": \"節省高達 {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly_discount_save\": \"節省 {{discount}}%\",\n    \"account_upgrade_dialog_interval_yearly\": \"每年\",\n    \"account_upgrade_dialog_proration_info\": \"<strong>按比例分配</strong>：在付費計劃之間升級時，差價將被<strong>立刻收取</strong>。在降級到較低級別時，餘額將被用於支付未來的賬單周期。\",\n    \"account_upgrade_dialog_reservations_warning_one\": \"所選等級允許的保留主題少於當前等級。在更改你的等級之前，<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。\",\n    \"account_upgrade_dialog_reservations_warning_other\": \"所選等級允許的保留主題少於當前等級。在更改你的等級之前，<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。\",\n    \"account_upgrade_dialog_tier_current_label\": \"當前\",\n    \"account_upgrade_dialog_tier_features_attachment_file_size\": \"每個文件 {{filesize}}\",\n    \"account_upgrade_dialog_tier_features_attachment_total_size\": \"{{totalsize}} 總存儲空間\",\n    \"account_upgrade_dialog_tier_features_calls_one\": \"每日一通電話\",\n    \"account_upgrade_dialog_tier_features_calls_other\": \"每日{{calls}} 通電話\",\n    \"account_upgrade_dialog_tier_features_emails_one\": \"每日一封郵件\",\n    \"account_upgrade_dialog_tier_features_emails_other\": \"每日 {{emails}} 條郵件\",\n    \"account_upgrade_dialog_tier_features_messages_one\": \"每日一條訊息\",\n    \"account_upgrade_dialog_tier_features_messages_other\": \"每日 {{messages}} 條訊息\",\n    \"account_upgrade_dialog_tier_features_no_calls\": \"沒有電話\",\n    \"account_upgrade_dialog_tier_features_no_reservations\": \"無保留主題\",\n    \"account_upgrade_dialog_tier_features_reservations_one\": \"保留一條主題\",\n    \"account_upgrade_dialog_tier_features_reservations_other\": \"保留 {{reservations}} 條主題\",\n    \"account_upgrade_dialog_tier_price_billed_monthly\": \"{{price}} 每年。按月計費。\",\n    \"account_upgrade_dialog_tier_price_billed_yearly\": \"{{價格}} 按年計費。節省 {{save}}。\",\n    \"account_upgrade_dialog_tier_price_per_month\": \"月\",\n    \"account_upgrade_dialog_tier_selected_label\": \"已選\",\n    \"account_upgrade_dialog_title\": \"更改帳戶等級\",\n    \"account_usage_attachment_storage_description\": \"每個文件 {{filesize}}，在 {{expiry}} 後刪除\",\n    \"account_usage_attachment_storage_title\": \"附件存儲\",\n    \"account_usage_basis_ip_description\": \"此帳戶的使用統計資訊和限制基於你的 IP 地址，因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。\",\n    \"account_usage_calls_none\": \"此帳號無法撥打電話\",\n    \"account_usage_calls_title\": \"已撥打電話\",\n    \"account_usage_cannot_create_portal_session\": \"無法打開計費門戶\",\n    \"account_usage_emails_title\": \"已發送電子郵件\",\n    \"account_usage_limits_reset_daily\": \"使用限制每天午夜 (UTC) 重置\",\n    \"account_usage_messages_title\": \"已發布訊息\",\n    \"account_usage_of_limit\": \"{{limit}} 的\",\n    \"account_usage_reservations_none\": \"此帳戶沒有保留主題\",\n    \"account_usage_reservations_title\": \"保留主題\",\n    \"account_usage_title\": \"使用量\",\n    \"account_usage_unlimited\": \"無限\",\n    \"action_bar_account\": \"帳戶\",\n    \"action_bar_change_display_name\": \"更改顯示名稱\",\n    \"action_bar_clear_notifications\": \"清除所有通知\",\n    \"action_bar_logo_alt\": \"ntfy 標識\",\n    \"action_bar_mute_notifications\": \"靜音\",\n    \"action_bar_profile_logout\": \"登出\",\n    \"action_bar_profile_settings\": \"設定\",\n    \"action_bar_profile_title\": \"個人資料\",\n    \"action_bar_reservation_add\": \"保留主題\",\n    \"action_bar_reservation_delete\": \"移除保留\",\n    \"action_bar_reservation_edit\": \"更改保留\",\n    \"action_bar_reservation_limit_reached\": \"達到限制\",\n    \"action_bar_send_test_notification\": \"發送測試通知\",\n    \"action_bar_settings\": \"設定\",\n    \"action_bar_show_menu\": \"顯示選單\",\n    \"action_bar_sign_in\": \"登錄\",\n    \"action_bar_sign_up\": \"註冊\",\n    \"action_bar_toggle_action_menu\": \"開啟或關閉操作選單\",\n    \"action_bar_toggle_mute\": \"通知靜音/解除通知靜音\",\n    \"action_bar_unmute_notifications\": \"取消靜音\",\n    \"action_bar_unsubscribe\": \"取消訂閱\",\n    \"alert_notification_ios_install_required_description\": \"要接收通知，請在 iOS 上點擊共享，然後添加到主屏幕\",\n    \"alert_notification_ios_install_required_title\": \"需要安裝 iOS 應用程式\",\n    \"alert_notification_permission_denied_description\": \"你已禁用通知。要重新啟用通知，請在瀏覽器設置中啟用通知\",\n    \"alert_notification_permission_denied_title\": \"已禁用通知\",\n    \"alert_notification_permission_required_button\": \"現在授予\",\n    \"alert_notification_permission_required_description\": \"授予瀏覽器顯示桌面通知的權限\",\n    \"alert_notification_permission_required_title\": \"已禁用通知\",\n    \"alert_not_supported_context_description\": \"通知僅支援 HTTPS。這是 <mdnLink>Notifications API</mdnLink> 的限制。\",\n    \"alert_not_supported_description\": \"你的瀏覽器不支援通知\",\n    \"alert_not_supported_title\": \"不支援通知\",\n    \"common_add\": \"新增\",\n    \"common_back\": \"返回\",\n    \"common_cancel\": \"取消\",\n    \"common_copy_to_clipboard\": \"複製到剪貼板\",\n    \"common_save\": \"保存\",\n    \"display_name_dialog_description\": \"為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。\",\n    \"display_name_dialog_placeholder\": \"顯示名稱\",\n    \"display_name_dialog_title\": \"更改顯示名稱\",\n    \"emoji_picker_search_clear\": \"清除搜索\",\n    \"emoji_picker_search_placeholder\": \"查找表情符號\",\n    \"error_boundary_button_copy_stack_trace\": \"複製堆疊追踪\",\n    \"error_boundary_button_reload_ntfy\": \"重新加載 ntfy\",\n    \"error_boundary_description\": \"這顯然不應該發生。對此非常抱歉。<br/>如果你有時間，請<githubLink>在GitHub</githubLink>上報告，或通過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告訴我們。\",\n    \"error_boundary_gathering_info\": \"收集更多資訊……\",\n    \"error_boundary_stack_trace\": \"堆疊追踪\",\n    \"error_boundary_title\": \"天啊，ntfy 崩潰了\",\n    \"error_boundary_unsupported_indexeddb_description\": \"Ntfy Web應用程式需要IndexedDB才能運行，且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。<br/><br/>儘管這很不幸，但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義，因為所有東西都存儲在瀏覽器存儲中。你可以在<githubLink>本GitHub問題</githubLink>中閱讀有關它的更多資訊，或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上與我們交談。\",\n    \"error_boundary_unsupported_indexeddb_title\": \"不支援隱私瀏覽\",\n    \"login_disabled\": \"登錄已禁用\",\n    \"login_form_button_submit\": \"登錄\",\n    \"login_link_signup\": \"註冊\",\n    \"login_title\": \"請登錄你的 ntfy 帳戶\",\n    \"message_bar_error_publishing\": \"發佈通知時出錯\",\n    \"message_bar_publish\": \"發布訊息\",\n    \"message_bar_show_dialog\": \"顯示發布對話框\",\n    \"message_bar_type_message\": \"在此處輸入訊息\",\n    \"nav_button_account\": \"帳戶\",\n    \"nav_button_all_notifications\": \"全部通知\",\n    \"nav_button_connecting\": \"正在連接\",\n    \"nav_button_documentation\": \"文檔\",\n    \"nav_button_muted\": \"已暫停通知\",\n    \"nav_button_publish_message\": \"發布通知\",\n    \"nav_button_settings\": \"設定\",\n    \"nav_button_subscribe\": \"訂閱主題\",\n    \"nav_topics_title\": \"訂閱主題\",\n    \"nav_upgrade_banner_description\": \"保留主題，更多訊息和郵件，以及更大的附件\",\n    \"nav_upgrade_banner_label\": \"升級到 ntfy Pro\",\n    \"notifications_actions_failed_notification\": \"通知失敗\",\n    \"notifications_actions_http_request_title\": \"發送 HTTP {{method}} 到 {{url}}\",\n    \"notifications_actions_not_supported\": \"網頁應用程序不支援此操作\",\n    \"notifications_actions_open_url_title\": \"轉到 {{url}}\",\n    \"notifications_attachment_copy_url_button\": \"複製連結地址\",\n    \"notifications_attachment_copy_url_title\": \"將附件中連結地址複製到剪貼板\",\n    \"notifications_attachment_file_app\": \"安卓應用程式\",\n    \"notifications_attachment_file_audio\": \"聲音文件\",\n    \"notifications_attachment_file_document\": \"其他文件\",\n    \"notifications_attachment_file_image\": \"圖片文件\",\n    \"notifications_attachment_file_video\": \"影片文件\",\n    \"notifications_attachment_image\": \"附件圖片\",\n    \"notifications_attachment_link_expired\": \"下載連結已過期\",\n    \"notifications_attachment_link_expires\": \"連結在 {{date}} 過期\",\n    \"notifications_attachment_open_button\": \"打開附件\",\n    \"notifications_attachment_open_title\": \"轉到 {{url}}\",\n    \"notifications_click_copy_url_button\": \"複製鏈結\",\n    \"notifications_click_copy_url_title\": \"複製鏈結地址到剪貼板\",\n    \"notifications_click_open_button\": \"打開鏈結\",\n    \"notifications_copied_to_clipboard\": \"複製到剪貼板\",\n    \"notifications_delete\": \"刪除\",\n    \"notifications_example\": \"示例\",\n    \"notifications_list_item\": \"通知\",\n    \"notifications_list\": \"通知列表\",\n    \"notifications_loading\": \"正在加載通知……\",\n    \"notifications_mark_read\": \"標記為已讀\",\n    \"notifications_more_details\": \"有關更多資訊，請查看<websiteLink>網站</websiteLink>或<docsLink>文檔</docsLink>。\",\n    \"notifications_new_indicator\": \"新通知\",\n    \"notifications_none_for_any_description\": \"要向此主題發送通知，只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。\",\n    \"notifications_none_for_any_title\": \"你尚未收到任何通知。\",\n    \"notifications_none_for_topic_description\": \"要向此主題發送通知，只需使用 PUT 或 POST 到主題連結即可。\",\n    \"notifications_none_for_topic_title\": \"你尚未收到有關此主題的任何通知。\",\n    \"notifications_no_subscriptions_description\": \"點擊 \\\"{{linktext}}\\\" 連結以建立或訂閱主題。之後，你可以使用 PUT 或 POST 發送訊息，你將在這裡收到通知。\",\n    \"notifications_no_subscriptions_title\": \"看起來你還未有任何訂閱。\",\n    \"notifications_priority_x\": \"優先級 {{priority}}\",\n    \"notifications_tags\": \"標記\",\n    \"prefs_appearance_language_title\": \"語言\",\n    \"prefs_appearance_theme_dark\": \"黑暗模式\",\n    \"prefs_appearance_theme_light\": \"光亮模式\",\n    \"prefs_appearance_theme_system\": \"系統 (預設)\",\n    \"prefs_appearance_theme_title\": \"主題\",\n    \"prefs_appearance_title\": \"外觀\",\n    \"prefs_notifications_delete_after_never_description\": \"永不自動刪除通知\",\n    \"prefs_notifications_delete_after_never\": \"從不\",\n    \"prefs_notifications_delete_after_one_day_description\": \"一天後自動刪除通知\",\n    \"prefs_notifications_delete_after_one_day\": \"一天後\",\n    \"prefs_notifications_delete_after_one_month_description\": \"一個月後自動刪除通知\",\n    \"prefs_notifications_delete_after_one_month\": \"一個月後\",\n    \"prefs_notifications_delete_after_one_week_description\": \"一周後自動刪除通知\",\n    \"prefs_notifications_delete_after_one_week\": \"一周後\",\n    \"prefs_notifications_delete_after_three_hours_description\": \"三小時後自動刪除通知\",\n    \"prefs_notifications_delete_after_three_hours\": \"三小時後\",\n    \"prefs_notifications_delete_after_title\": \"刪除通知\",\n    \"prefs_notifications_min_priority_any\": \"任意優先級\",\n    \"prefs_notifications_min_priority_default_and_higher\": \"默認優先級或更高\",\n    \"prefs_notifications_min_priority_description_any\": \"顯示所有通知，無論優先級如何\",\n    \"prefs_notifications_min_priority_description_max\": \"僅顯示最高優先級的通知\",\n    \"prefs_notifications_min_priority_description_x_or_higher\": \"僅顯示優先級為{{number}}（{{name}}）或以上的通知\",\n    \"prefs_notifications_min_priority_high_and_higher\": \"高優先級或更高\",\n    \"prefs_notifications_min_priority_low_and_higher\": \"低優先級或更高\",\n    \"prefs_notifications_min_priority_max_only\": \"僅最高優先級\",\n    \"prefs_notifications_min_priority_title\": \"最低優先級\",\n    \"prefs_notifications_sound_description_none\": \"收到通知時不播放任何聲音\",\n    \"prefs_notifications_sound_description_some\": \"收到通知時播放 {{sound}} 聲音\",\n    \"prefs_notifications_sound_no_sound\": \"靜音\",\n    \"prefs_notifications_sound_play\": \"播放選中聲音\",\n    \"prefs_notifications_sound_title\": \"通知提示音\",\n    \"prefs_notifications_title\": \"通知\",\n    \"prefs_notifications_web_push_disabled_description\": \"當網頁程式在運行時將會收到通知 (透過 WebSocket)\",\n    \"prefs_notifications_web_push_disabled\": \"己暫用\",\n    \"prefs_notifications_web_push_enabled_description\": \"即使網頁程式未有運街亦會收到通知 (via Web Push)\",\n    \"prefs_notifications_web_push_enabled\": \"己為 {{server}} 啟用\",\n    \"prefs_notifications_web_push_title\": \"背景通知\",\n    \"prefs_reservations_add_button\": \"新增保留主題\",\n    \"prefs_reservations_delete_button\": \"重置主題訪問\",\n    \"prefs_reservations_description\": \"你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權，並允許你為其他用戶定義對該主題的訪問權限。\",\n    \"prefs_reservations_dialog_access_label\": \"訪問\",\n    \"prefs_reservations_dialog_description\": \"保留主題使你擁有該主題的所有權，並允許你為其他用戶定義對該主題的訪問權限。\",\n    \"prefs_reservations_dialog_title_add\": \"保留主題\",\n    \"prefs_reservations_dialog_title_delete\": \"刪除主題保留\",\n    \"prefs_reservations_dialog_title_edit\": \"編輯保留主題\",\n    \"prefs_reservations_dialog_topic_label\": \"主題\",\n    \"prefs_reservations_edit_button\": \"編輯主題訪問\",\n    \"prefs_reservations_limit_reached\": \"你已達到保留主題限制。\",\n    \"prefs_reservations_table_access_header\": \"訪問\",\n    \"prefs_reservations_table_click_to_subscribe\": \"點擊以訂閱\",\n    \"prefs_reservations_table_everyone_deny_all\": \"只有我可以發佈和訂閱\",\n    \"prefs_reservations_table_everyone_read_only\": \"我可以發佈和訂閱，每個人都可以訂閱\",\n    \"prefs_reservations_table_everyone_read_write\": \"每個人都可以發佈和訂閱\",\n    \"prefs_reservations_table_everyone_write_only\": \"我可以發佈和訂閱，每個人都可以發佈\",\n    \"prefs_reservations_table_not_subscribed\": \"未訂閱\",\n    \"prefs_reservations_table_topic_header\": \"主題\",\n    \"prefs_reservations_table\": \"保留主題表格\",\n    \"prefs_reservations_title\": \"保留主題\",\n    \"prefs_users_add_button\": \"新增使用者\",\n    \"prefs_users_delete_button\": \"刪除用戶\",\n    \"prefs_users_description_no_sync\": \"用戶和密碼不會同步到你的賬戶。\",\n    \"prefs_users_description\": \"在此處新增/刪除受保護主題的使用者。請注意，使用者名和密碼將存儲在瀏覽器的本地存儲中。\",\n    \"prefs_users_dialog_base_url_label\": \"服務連結地址，例如 https://ntfy.sh\",\n    \"prefs_users_dialog_password_label\": \"密碼\",\n    \"prefs_users_dialog_title_add\": \"新增使用者\",\n    \"prefs_users_dialog_title_edit\": \"編輯使用者\",\n    \"prefs_users_dialog_username_label\": \"使用者名，例如 phil\",\n    \"prefs_users_edit_button\": \"編輯用戶\",\n    \"prefs_users_table_base_url_header\": \"服務連結地址\",\n    \"prefs_users_table_cannot_delete_or_edit\": \"無法刪除或編輯已登錄用戶\",\n    \"prefs_users_table_user_header\": \"用戶\",\n    \"prefs_users_table\": \"用戶表\",\n    \"prefs_users_title\": \"管理使用者\",\n    \"priority_default\": \"預設\",\n    \"priority_high\": \"高\",\n    \"priority_low\": \"低\",\n    \"priority_max\": \"最高\",\n    \"priority_min\": \"最低\",\n    \"publish_dialog_attached_file_filename_placeholder\": \"附件文件名\",\n    \"publish_dialog_attached_file_remove\": \"刪除附件文件\",\n    \"publish_dialog_attached_file_title\": \"附件文件：\",\n    \"publish_dialog_attach_label\": \"附件連結地址\",\n    \"publish_dialog_attachment_limits_file_and_quota_reached\": \"超過 {{fileSizeLimit}} 文件限制和配額，剩餘 {{remainingBytes}}\",\n    \"publish_dialog_attachment_limits_file_reached\": \"超過 {{fileSizeLimit}} 文件限制\",\n    \"publish_dialog_attachment_limits_quota_reached\": \"超過配額，剩餘 {{remainingBytes}}\",\n    \"publish_dialog_attach_placeholder\": \"使用鏈結地址附加文件，例如 https://f-droid.org/F-Droid.apk\",\n    \"publish_dialog_attach_reset\": \"移除附件鏈結地址\",\n    \"publish_dialog_base_url_label\": \"服務鏈結地址\",\n    \"publish_dialog_base_url_placeholder\": \"服務鏈結地址，例如 https://example.com\",\n    \"publish_dialog_button_cancel_sending\": \"取消發送\",\n    \"publish_dialog_button_cancel\": \"取消\",\n    \"publish_dialog_button_send\": \"發送\",\n    \"publish_dialog_call_item\": \"撥打電話 {{number}}\",\n    \"publish_dialog_call_label\": \"撥號\",\n    \"publish_dialog_call_reset\": \"清空撥號\",\n    \"publish_dialog_checkbox_markdown\": \"格式化為 Markdown\",\n    \"publish_dialog_checkbox_publish_another\": \"發布另一個\",\n    \"publish_dialog_chip_attach_file_label\": \"本地文件附件\",\n    \"publish_dialog_chip_attach_url_label\": \"鏈結附件地址\",\n    \"publish_dialog_chip_call_label\": \"撥號\",\n    \"publish_dialog_chip_call_no_verified_numbers_tooltip\": \"未驗證的電話號碼\",\n    \"publish_dialog_chip_click_label\": \"點擊鏈結地址\",\n    \"publish_dialog_chip_delay_label\": \"延期投遞\",\n    \"publish_dialog_chip_email_label\": \"轉發郵件\",\n    \"publish_dialog_chip_topic_label\": \"變更主題\",\n    \"publish_dialog_click_label\": \"點擊鏈結地址\",\n    \"publish_dialog_click_placeholder\": \"點擊通知時打開鏈結地址\",\n    \"publish_dialog_click_reset\": \"移除點擊連結地址\",\n    \"publish_dialog_delay_label\": \"延期\",\n    \"publish_dialog_delay_placeholder\": \"延期投遞，例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」（僅限英語）\",\n    \"publish_dialog_delay_reset\": \"刪除延期投遞\",\n    \"publish_dialog_details_examples_description\": \"有關所有發送功能的範例和詳細說明，請參閱<docsLink>文檔</docsLink>。\",\n    \"publish_dialog_drop_file_here\": \"將文件拖拽至此\",\n    \"publish_dialog_email_label\": \"電子郵件\",\n    \"publish_dialog_email_placeholder\": \"將通知轉發到的地址，例如 phil@example.com\",\n    \"publish_dialog_email_reset\": \"移除電子郵件轉發\",\n    \"publish_dialog_emoji_picker_show\": \"選擇表情符號\",\n    \"publish_dialog_filename_label\": \"文件名\",\n    \"publish_dialog_filename_placeholder\": \"附件文件名\",\n    \"publish_dialog_message_label\": \"訊息\",\n    \"publish_dialog_message_placeholder\": \"在此輸入訊息\",\n    \"publish_dialog_message_published\": \"已發布通知\",\n    \"publish_dialog_other_features\": \"其它功能：\",\n    \"publish_dialog_priority_default\": \"默認優先級\",\n    \"publish_dialog_priority_high\": \"高優先級\",\n    \"publish_dialog_priority_label\": \"優先級\",\n    \"publish_dialog_priority_low\": \"低優先級\",\n    \"publish_dialog_priority_max\": \"最高優先級\",\n    \"publish_dialog_priority_min\": \"最低優先級\",\n    \"publish_dialog_progress_uploading_detail\": \"正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……\",\n    \"publish_dialog_progress_uploading\": \"正在上傳……\",\n    \"publish_dialog_tags_label\": \"標記\",\n    \"publish_dialog_tags_placeholder\": \"英文逗號分隔標記列表，例如 warning, srv1-backup\",\n    \"publish_dialog_title_label\": \"主題\",\n    \"publish_dialog_title_no_topic\": \"發布通知\",\n    \"publish_dialog_title_placeholder\": \"主題標題，例如：磁碟空間警告\",\n    \"publish_dialog_title_topic\": \"發布到 {{topic}}\",\n    \"publish_dialog_topic_label\": \"主題名稱\",\n    \"publish_dialog_topic_placeholder\": \"主題名稱，例如 phil_alerts\",\n    \"publish_dialog_topic_reset\": \"重置主題\",\n    \"reservation_delete_dialog_action_delete_description\": \"緩存的郵件和附件將被永久刪除。此操作無法撤銷。\",\n    \"reservation_delete_dialog_action_delete_title\": \"刪除緩存的郵件和附件\",\n    \"reservation_delete_dialog_action_keep_description\": \"緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。\",\n    \"reservation_delete_dialog_action_keep_title\": \"保留緩存的郵件和附件\",\n    \"reservation_delete_dialog_description\": \"刪除保留會放棄對該主題的所有權，並允許其他人保留它。你可以保留或刪除現有郵件和附件。\",\n    \"reservation_delete_dialog_submit_button\": \"刪除保留\",\n    \"reserve_dialog_checkbox_label\": \"保留主題並配置訪問\",\n    \"signup_already_have_account\": \"已有帳戶？登錄！\",\n    \"signup_disabled\": \"註冊已禁用\",\n    \"signup_error_creation_limit_reached\": \"已達到帳戶創建限制\",\n    \"signup_error_username_taken\": \"用戶名 {{username}} 已被取用\",\n    \"signup_form_button_submit\": \"註冊\",\n    \"signup_form_confirm_password\": \"確認密碼\",\n    \"signup_form_password\": \"密碼\",\n    \"signup_form_toggle_password_visibility\": \"切換密碼可見性\",\n    \"signup_form_username\": \"用戶名\",\n    \"signup_title\": \"創建一個 ntfy 帳戶\",\n    \"subscribe_dialog_error_topic_already_reserved\": \"主題已保留\",\n    \"subscribe_dialog_error_user_anonymous\": \"匿名\",\n    \"subscribe_dialog_error_user_not_authorized\": \"未授權 {{username}} 使用者\",\n    \"subscribe_dialog_login_button_login\": \"登入\",\n    \"subscribe_dialog_login_description\": \"本主題受密碼保護，請輸入用戶名和密碼以訂閱。\",\n    \"subscribe_dialog_login_password_label\": \"密碼\",\n    \"subscribe_dialog_login_title\": \"請登錄\",\n    \"subscribe_dialog_login_username_label\": \"用戶名，例如 phil\",\n    \"subscribe_dialog_subscribe_base_url_label\": \"服務地址地址\",\n    \"subscribe_dialog_subscribe_button_cancel\": \"取消\",\n    \"subscribe_dialog_subscribe_button_generate_topic_name\": \"生成名稱\",\n    \"subscribe_dialog_subscribe_button_subscribe\": \"訂閱\",\n    \"subscribe_dialog_subscribe_description\": \"主題可能不受密碼保護，因此請選擇一個不容易被猜中的名字。訂閱後，你可以使用 PUT/POST 通知。\",\n    \"subscribe_dialog_subscribe_title\": \"訂閱主題\",\n    \"subscribe_dialog_subscribe_topic_placeholder\": \"主題名，例如 phil_alerts\",\n    \"subscribe_dialog_subscribe_use_another_background_info\": \"當網頁程式未開啟， 將不會收到來自其他伺服器的通知\",\n    \"subscribe_dialog_subscribe_use_another_label\": \"使用其他伺服器\",\n    \"web_push_subscription_expiring_body\": \"開啟ntfy以繼續接收通知\",\n    \"web_push_subscription_expiring_title\": \"通知會被暫停\",\n    \"web_push_unknown_notification_body\": \"你可能需要開啟網頁來更新ntfy\",\n    \"web_push_unknown_notification_title\": \"接收到不明通知\",\n    \"account_basics_cannot_edit_or_delete_provisioned_user\": \"已佈建的使用者無法編輯或刪除\",\n    \"account_tokens_table_cannot_delete_or_edit_provisioned_token\": \"無法編輯或刪除已佈建的權杖\"\n}\n"
  },
  {
    "path": "web/public/sw.js",
    "content": "/* eslint-disable import/no-extraneous-dependencies */\nimport { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from \"workbox-precaching\";\nimport { NavigationRoute, registerRoute } from \"workbox-routing\";\nimport { NetworkFirst } from \"workbox-strategies\";\nimport { clientsClaim } from \"workbox-core\";\nimport { dbAsync } from \"../src/app/db\";\nimport { ACTION_HTTP, ACTION_VIEW } from \"../src/app/actions\";\nimport { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from \"../src/app/notificationUtils\";\nimport initI18n from \"../src/app/i18n\";\nimport {\n  EVENT_MESSAGE,\n  EVENT_MESSAGE_CLEAR,\n  EVENT_MESSAGE_DELETE,\n  WEBPUSH_EVENT_MESSAGE,\n  WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,\n} from \"../src/app/events\";\n\n/**\n * General docs for service workers and PWAs:\n * https://vite-pwa-org.netlify.app/guide/\n * https://developer.chrome.com/docs/workbox/\n *\n * This file uses the (event) => event.waitUntil(<promise>) pattern.\n * This is because the event handler itself cannot be async, but\n * the service worker needs to stay active while the promise completes.\n */\n\nconst broadcastChannel = new BroadcastChannel(\"web-push-broadcast\");\n\n/**\n * Handle a received web push message and show notification.\n *\n * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running)\n * receives the broadcast and plays a sound (see web/src/app/WebPush.js).\n */\nconst handlePushMessage = async (data) => {\n  const { subscription_id: subscriptionId, message } = data;\n  const db = await dbAsync();\n\n  console.log(\"[ServiceWorker] Message received\", data);\n\n  // Look up subscription for baseUrl and topic\n  const subscription = await db.subscriptions.get(subscriptionId);\n  if (!subscription) {\n    console.log(\"[ServiceWorker] Subscription not found\", subscriptionId);\n    return;\n  }\n\n  // Delete existing notification with same sequence ID (if any)\n  const sequenceId = message.sequence_id || message.id;\n  if (sequenceId) {\n    await db.notifications.where({ subscriptionId, sequenceId }).delete();\n  }\n\n  // Add notification to database\n  await db.notifications.add({\n    ...messageWithSequenceId(message),\n    subscriptionId,\n    new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation\n  });\n\n  // Update subscription last message id (for ?since=... queries)\n  await db.subscriptions.update(subscriptionId, {\n    last: message.id,\n  });\n\n  // Update badge in PWA\n  const badgeCount = await db.notifications.where({ new: 1 }).count();\n  self.navigator.setAppBadge?.(badgeCount);\n\n  // Broadcast the message to potentially play a sound\n  broadcastChannel.postMessage(message);\n\n  await self.registration.showNotification(\n    ...toNotificationParams({\n      message,\n      defaultTitle: message.topic,\n      topicRoute: new URL(message.topic, self.location.origin).toString(),\n      baseUrl: subscription.baseUrl,\n      topic: subscription.topic,\n    })\n  );\n};\n\n/**\n * Handle a message_delete event: delete the notification from the database.\n */\nconst handlePushMessageDelete = async (data) => {\n  const { subscription_id: subscriptionId, message } = data;\n  const db = await dbAsync();\n  console.log(\"[ServiceWorker] Deleting notification sequence\", data);\n\n  // Look up subscription for baseUrl and topic\n  const subscription = await db.subscriptions.get(subscriptionId);\n  if (!subscription) {\n    console.log(\"[ServiceWorker] Subscription not found\", subscriptionId);\n    return;\n  }\n\n  // Delete notification with the same sequence_id\n  const sequenceId = message.sequence_id;\n  if (sequenceId) {\n    await db.notifications.where({ subscriptionId, sequenceId }).delete();\n  }\n\n  // Close browser notification with matching tag (scoped by topic)\n  const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);\n  const notifications = await self.registration.getNotifications({ tag });\n  notifications.forEach((notification) => notification.close());\n\n  // Update subscription last message id (for ?since=... queries)\n  await db.subscriptions.update(subscriptionId, {\n    last: message.id,\n  });\n};\n\n/**\n * Handle a message_clear event: clear/dismiss the notification.\n */\nconst handlePushMessageClear = async (data) => {\n  const { subscription_id: subscriptionId, message } = data;\n  const db = await dbAsync();\n  console.log(\"[ServiceWorker] Marking notification as read\", data);\n\n  // Look up subscription for baseUrl and topic\n  const subscription = await db.subscriptions.get(subscriptionId);\n  if (!subscription) {\n    console.log(\"[ServiceWorker] Subscription not found\", subscriptionId);\n    return;\n  }\n\n  // Mark notification as read (set new = 0)\n  const sequenceId = message.sequence_id;\n  if (sequenceId) {\n    await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });\n  }\n\n  // Close browser notification with matching tag (scoped by topic)\n  const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);\n  const notifications = await self.registration.getNotifications({ tag });\n  notifications.forEach((notification) => notification.close());\n\n  // Update subscription last message id (for ?since=... queries)\n  await db.subscriptions.update(subscriptionId, {\n    last: message.id,\n  });\n\n  // Update badge count\n  const badgeCount = await db.notifications.where({ new: 1 }).count();\n  self.navigator.setAppBadge?.(badgeCount);\n};\n\n/**\n * Handle a received web push subscription expiring.\n */\nconst handlePushSubscriptionExpiring = async (data) => {\n  const t = await initI18n();\n  console.log(\"[ServiceWorker] Handling incoming subscription expiring event\", data);\n\n  await self.registration.showNotification(t(\"web_push_subscription_expiring_title\"), {\n    body: t(\"web_push_subscription_expiring_body\"),\n    icon,\n    data,\n    badge,\n  });\n};\n\n/**\n * Handle unknown push message. We can't ignore the push, since\n * permission can be revoked by the browser.\n */\nconst handlePushUnknown = async (data) => {\n  const t = await initI18n();\n  console.log(\"[ServiceWorker] Unknown event received\", data);\n\n  await self.registration.showNotification(t(\"web_push_unknown_notification_title\"), {\n    body: t(\"web_push_unknown_notification_body\"),\n    icon,\n    data,\n    badge,\n  });\n};\n\n/**\n * Handle a received web push notification\n * @param {object} data see server/types.go, type webPushPayload\n */\nconst handlePush = async (data) => {\n  // This logic is (partially) duplicated in\n  // - Android: SubscriberService::onNotificationReceived()\n  // - Android: FirebaseService::onMessageReceived()\n  // - Web app: hooks.js:handleNotification()\n  // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...\n\n  if (data.event === WEBPUSH_EVENT_MESSAGE) {\n    const { message } = data;\n    if (message.event === EVENT_MESSAGE) {\n      return await handlePushMessage(data);\n    } else if (message.event === EVENT_MESSAGE_DELETE) {\n      return await handlePushMessageDelete(data);\n    } else if (message.event === EVENT_MESSAGE_CLEAR) {\n      return await handlePushMessageClear(data);\n    }\n  } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {\n    return await handlePushSubscriptionExpiring(data);\n  }\n\n  return await handlePushUnknown(data);\n};\n\n/**\n * Handle a user clicking on the displayed notification from `showNotification`.\n * This is also called when the user clicks on an action button.\n */\nconst handleClick = async (event) => {\n  const t = await initI18n();\n\n  const clients = await self.clients.matchAll({ type: \"window\" });\n  const rootUrl = new URL(self.location.origin);\n  const rootClient = clients.find((client) => client.url === rootUrl.toString());\n  const fallbackClient = clients[0];\n\n  if (!event.notification.data?.message) {\n    // e.g. something other than a message, e.g. a subscription_expiring event\n    // simply open the web app on the root route (/)\n    if (rootClient) {\n      rootClient.focus();\n    } else if (fallbackClient) {\n      fallbackClient.focus();\n      fallbackClient.navigate(rootUrl.toString());\n    } else {\n      self.clients.openWindow(rootUrl);\n    }\n    event.notification.close();\n  } else {\n    const { message, topicRoute } = event.notification.data;\n\n    if (event.action) {\n      const action = event.notification.data.message.actions.find(({ label }) => event.action === label);\n\n      // Helper to clear notification and mark as read\n      const clearNotification = async () => {\n        event.notification.close();\n        const { subscriptionId, message: msg } = event.notification.data;\n        const seqId = msg.sequence_id || msg.id;\n        if (subscriptionId && seqId) {\n          const db = await dbAsync();\n          await db.notifications.where({ subscriptionId, sequenceId: seqId }).modify({ new: 0 });\n          const badgeCount = await db.notifications.where({ new: 1 }).count();\n          self.navigator.setAppBadge?.(badgeCount);\n        }\n      };\n\n      if (action.action === ACTION_VIEW) {\n        self.clients.openWindow(action.url);\n        if (action.clear) {\n          await clearNotification();\n        }\n      } else if (action.action === ACTION_HTTP) {\n        try {\n          const response = await fetch(action.url, {\n            method: action.method ?? \"POST\",\n            headers: action.headers ?? {},\n            body: action.body,\n          });\n\n          if (!response.ok) {\n            throw new Error(`HTTP ${response.status} ${response.statusText}`);\n          }\n\n          // Only clear on success\n          if (action.clear) {\n            await clearNotification();\n          }\n        } catch (e) {\n          console.error(\"[ServiceWorker] Error performing http action\", e);\n          self.registration.showNotification(`${t(\"notifications_actions_failed_notification\")}: ${action.label} (${action.action})`, {\n            body: e.message,\n            icon,\n            badge,\n          });\n        }\n      }\n    } else if (message.click) {\n      self.clients.openWindow(message.click);\n\n      event.notification.close();\n    } else {\n      // If no action was clicked, and the message doesn't have a click url:\n      // - first try focus an open tab on the `/:topic` route\n      // - if not, use an open tab on the root route (`/`) and navigate to the topic\n      // - if not, use whichever tab we have open and navigate to the topic\n      // - finally, open a new tab focused on the topic\n\n      const topicClient = clients.find((client) => client.url === topicRoute);\n\n      if (topicClient) {\n        topicClient.focus();\n      } else if (rootClient) {\n        rootClient.focus();\n        rootClient.navigate(topicRoute);\n      } else if (fallbackClient) {\n        fallbackClient.focus();\n        fallbackClient.navigate(topicRoute);\n      } else {\n        self.clients.openWindow(topicRoute);\n      }\n\n      event.notification.close();\n    }\n  }\n};\n\nself.addEventListener(\"install\", () => {\n  console.log(\"[ServiceWorker] Installed\");\n  self.skipWaiting();\n});\n\nself.addEventListener(\"activate\", () => {\n  console.log(\"[ServiceWorker] Activated\");\n  self.skipWaiting();\n});\n\n// There's no good way to test this, and Chrome doesn't seem to implement this,\n// so leaving it for now\nself.addEventListener(\"pushsubscriptionchange\", (event) => {\n  console.log(\"[ServiceWorker] PushSubscriptionChange\");\n  console.log(event);\n});\n\nself.addEventListener(\"push\", (event) => {\n  const data = event.data.json();\n  console.log(\"[ServiceWorker] Received Web Push Event\", { event, data });\n  event.waitUntil(handlePush(data));\n});\n\nself.addEventListener(\"notificationclick\", (event) => {\n  console.log(\"[ServiceWorker] NotificationClick\");\n  event.waitUntil(handleClick(event));\n});\n\n// See https://vite-pwa-org.netlify.app/guide/inject-manifest.html#service-worker-code\n// self.__WB_MANIFEST is the workbox injection point that injects the manifest of the\n// vite dist files and their revision ids, for example:\n// [{\"revision\":\"aaabbbcccdddeeefff12345\",\"url\":\"/index.html\"},...]\nprecacheAndRoute(\n  // eslint-disable-next-line no-underscore-dangle\n  self.__WB_MANIFEST\n);\n\n// Claim all open windows\nclientsClaim();\n\n// Delete any cached old dist files from previous service worker versions\ncleanupOutdatedCaches();\n\nif (!import.meta.env.DEV) {\n  // we need the app_root setting, so we import the config.js file from the go server\n  // this does NOT include the same base_url as the web app running in a window,\n  // since we don't have access to `window` like in `src/app/config.js`\n  self.importScripts(\"/config.js\");\n\n  // this is the fallback single-page-app route, matching vite.config.js PWA config,\n  // and is served by the go web server. It is needed for the single-page-app to work.\n  // https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route\n  registerRoute(\n    new NavigationRoute(createHandlerBoundToURL(\"/app.html\"), {\n      allowlist: [\n        // the app root itself, could be /, or not\n        new RegExp(`^${config.app_root}$`),\n      ],\n    })\n  );\n\n  // the manifest excludes config.js (see vite.config.js) since the dist-file differs from the\n  // actual config served by the go server. this adds it back with `NetworkFirst`, so that the\n  // most recent config from the go server is cached, but the app still works if the network\n  // is unavailable. this is important since there's no \"refresh\" button in the installed pwa\n  // to force a reload.\n  registerRoute(({ url }) => url.pathname === \"/config.js\", new NetworkFirst());\n}\n"
  },
  {
    "path": "web/src/app/AccountApi.js",
    "content": "import i18n from \"i18next\";\nimport {\n  accountBillingPortalUrl,\n  accountBillingSubscriptionUrl,\n  accountPasswordUrl,\n  accountPhoneUrl,\n  accountPhoneVerifyUrl,\n  accountReservationSingleUrl,\n  accountReservationUrl,\n  accountSettingsUrl,\n  accountSubscriptionUrl,\n  accountTokenUrl,\n  accountUrl,\n  maybeWithBearerAuth,\n  tiersUrl,\n  withBasicAuth,\n  withBearerAuth,\n} from \"./utils\";\nimport session from \"./Session\";\nimport subscriptionManager from \"./SubscriptionManager\";\nimport prefs from \"./Prefs\";\nimport routes from \"../components/routes\";\nimport { fetchOrThrow, UnauthorizedError } from \"./errors\";\n\nconst delayMillis = 45000; // 45 seconds\nconst intervalMillis = 900000; // 15 minutes\n\nclass AccountApi {\n  constructor() {\n    this.timer = null;\n    this.listener = null; // Fired when account is fetched from remote\n    this.tiers = null; // Cached\n  }\n\n  registerListener(listener) {\n    this.listener = listener;\n  }\n\n  resetListener() {\n    this.listener = null;\n  }\n\n  async login(user) {\n    const url = accountTokenUrl(config.base_url);\n    console.log(`[AccountApi] Checking auth for ${url}`);\n    const response = await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBasicAuth({}, user.username, user.password),\n    });\n    const json = await response.json(); // May throw SyntaxError\n    if (!json.token) {\n      throw new Error(`Unexpected server response: Cannot find token`);\n    }\n    return json.token;\n  }\n\n  async logout() {\n    const url = accountTokenUrl(config.base_url);\n    console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth({}, session.token()),\n    });\n  }\n\n  async create(username, password) {\n    const url = accountUrl(config.base_url);\n    const body = JSON.stringify({\n      username,\n      password,\n    });\n    console.log(`[AccountApi] Creating user account ${url}`);\n    await fetchOrThrow(url, {\n      method: \"POST\",\n      body,\n    });\n  }\n\n  async get() {\n    const url = accountUrl(config.base_url);\n    console.log(`[AccountApi] Fetching user account ${url}`);\n    const response = await fetchOrThrow(url, {\n      headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous\n    });\n    const account = await response.json(); // May throw SyntaxError\n    console.log(`[AccountApi] Account`, account);\n    if (this.listener) {\n      this.listener(account);\n    }\n    return account;\n  }\n\n  async delete(password) {\n    const url = accountUrl(config.base_url);\n    console.log(`[AccountApi] Deleting user account ${url}`);\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        password,\n      }),\n    });\n  }\n\n  async changePassword(currentPassword, newPassword) {\n    const url = accountPasswordUrl(config.base_url);\n    console.log(`[AccountApi] Changing account password ${url}`);\n    await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        password: currentPassword,\n        new_password: newPassword,\n      }),\n    });\n  }\n\n  async createToken(label, expires) {\n    const url = accountTokenUrl(config.base_url);\n    const body = {\n      label,\n      expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,\n    };\n    console.log(`[AccountApi] Creating user access token ${url}`);\n    await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify(body),\n    });\n  }\n\n  async updateToken(token, label, expires) {\n    const url = accountTokenUrl(config.base_url);\n    const body = {\n      token,\n      label,\n    };\n    if (expires > 0) {\n      body.expires = Math.floor(Date.now() / 1000) + expires;\n    }\n    console.log(`[AccountApi] Creating user access token ${url}`);\n    await fetchOrThrow(url, {\n      method: \"PATCH\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify(body),\n    });\n  }\n\n  async extendToken() {\n    const url = accountTokenUrl(config.base_url);\n    console.log(`[AccountApi] Extending user access token ${url}`);\n    await fetchOrThrow(url, {\n      method: \"PATCH\",\n      headers: withBearerAuth({}, session.token()),\n    });\n  }\n\n  async deleteToken(token) {\n    const url = accountTokenUrl(config.base_url);\n    console.log(`[AccountApi] Deleting user access token ${url}`);\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth({ \"X-Token\": token }, session.token()),\n    });\n  }\n\n  async updateSettings(payload) {\n    const url = accountSettingsUrl(config.base_url);\n    const body = JSON.stringify(payload);\n    console.log(`[AccountApi] Updating user account ${url}: ${body}`);\n    await fetchOrThrow(url, {\n      method: \"PATCH\",\n      headers: withBearerAuth({}, session.token()),\n      body,\n    });\n  }\n\n  async addSubscription(baseUrl, topic) {\n    const url = accountSubscriptionUrl(config.base_url);\n    const body = JSON.stringify({\n      base_url: baseUrl,\n      topic,\n    });\n    console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);\n    const response = await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBearerAuth({}, session.token()),\n      body,\n    });\n    const subscription = await response.json(); // May throw SyntaxError\n    console.log(`[AccountApi] Subscription`, subscription);\n    return subscription;\n  }\n\n  async updateSubscription(baseUrl, topic, payload) {\n    const url = accountSubscriptionUrl(config.base_url);\n    const body = JSON.stringify({\n      base_url: baseUrl,\n      topic,\n      ...payload,\n    });\n    console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);\n    const response = await fetchOrThrow(url, {\n      method: \"PATCH\",\n      headers: withBearerAuth({}, session.token()),\n      body,\n    });\n    const subscription = await response.json(); // May throw SyntaxError\n    console.log(`[AccountApi] Subscription`, subscription);\n    return subscription;\n  }\n\n  async deleteSubscription(baseUrl, topic) {\n    const url = accountSubscriptionUrl(config.base_url);\n    console.log(`[AccountApi] Removing user subscription ${url}`);\n    const headers = {\n      \"X-BaseURL\": baseUrl,\n      \"X-Topic\": topic,\n    };\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth(headers, session.token()),\n    });\n  }\n\n  async upsertReservation(topic, everyone) {\n    const url = accountReservationUrl(config.base_url);\n    console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);\n    await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        topic,\n        everyone,\n      }),\n    });\n  }\n\n  async deleteReservation(topic, deleteMessages) {\n    const url = accountReservationSingleUrl(config.base_url, topic);\n    console.log(`[AccountApi] Removing topic reservation ${url}`);\n    const headers = {\n      \"X-Delete-Messages\": deleteMessages ? \"true\" : \"false\",\n    };\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth(headers, session.token()),\n    });\n  }\n\n  async billingTiers() {\n    if (this.tiers) {\n      return this.tiers;\n    }\n    const url = tiersUrl(config.base_url);\n    console.log(`[AccountApi] Fetching billing tiers`);\n    const response = await fetchOrThrow(url); // No auth needed!\n    this.tiers = await response.json(); // May throw SyntaxError\n    return this.tiers;\n  }\n\n  async createBillingSubscription(tier, interval) {\n    console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);\n    return this.upsertBillingSubscription(\"POST\", tier, interval);\n  }\n\n  async updateBillingSubscription(tier, interval) {\n    console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);\n    return this.upsertBillingSubscription(\"PUT\", tier, interval);\n  }\n\n  async upsertBillingSubscription(method, tier, interval) {\n    const url = accountBillingSubscriptionUrl(config.base_url);\n    const response = await fetchOrThrow(url, {\n      method,\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        tier,\n        interval,\n      }),\n    });\n    return response.json(); // May throw SyntaxError\n  }\n\n  async deleteBillingSubscription() {\n    const url = accountBillingSubscriptionUrl(config.base_url);\n    console.log(`[AccountApi] Cancelling billing subscription`);\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth({}, session.token()),\n    });\n  }\n\n  async createBillingPortalSession() {\n    const url = accountBillingPortalUrl(config.base_url);\n    console.log(`[AccountApi] Creating billing portal session`);\n    const response = await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: withBearerAuth({}, session.token()),\n    });\n    return response.json(); // May throw SyntaxError\n  }\n\n  async verifyPhoneNumber(phoneNumber, channel) {\n    const url = accountPhoneVerifyUrl(config.base_url);\n    console.log(`[AccountApi] Sending phone verification ${url}`);\n    await fetchOrThrow(url, {\n      method: \"PUT\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        number: phoneNumber,\n        channel,\n      }),\n    });\n  }\n\n  async addPhoneNumber(phoneNumber, code) {\n    const url = accountPhoneUrl(config.base_url);\n    console.log(`[AccountApi] Adding phone number with verification code ${url}`);\n    await fetchOrThrow(url, {\n      method: \"PUT\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        number: phoneNumber,\n        code,\n      }),\n    });\n  }\n\n  async deletePhoneNumber(phoneNumber) {\n    const url = accountPhoneUrl(config.base_url);\n    console.log(`[AccountApi] Deleting phone number ${url}`);\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: withBearerAuth({}, session.token()),\n      body: JSON.stringify({\n        number: phoneNumber,\n      }),\n    });\n  }\n\n  async sync() {\n    try {\n      if (!session.token()) {\n        return null;\n      }\n      console.log(`[AccountApi] Syncing account`);\n      const account = await this.get();\n      if (account.language) {\n        await i18n.changeLanguage(account.language);\n      }\n      if (account.notification) {\n        if (account.notification.sound) {\n          await prefs.setSound(account.notification.sound);\n        }\n        if (account.notification.delete_after) {\n          await prefs.setDeleteAfter(account.notification.delete_after);\n        }\n        if (account.notification.min_priority) {\n          await prefs.setMinPriority(account.notification.min_priority);\n        }\n      }\n      if (account.subscriptions) {\n        await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);\n      }\n      return account;\n    } catch (e) {\n      console.log(`[AccountApi] Error fetching account`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      }\n      return undefined;\n    }\n  }\n\n  startWorker() {\n    if (this.timer !== null) {\n      return;\n    }\n    console.log(`[AccountApi] Starting worker`);\n    this.timer = setInterval(() => this.runWorker(), intervalMillis);\n    setTimeout(() => this.runWorker(), delayMillis);\n  }\n\n  stopWorker() {\n    clearTimeout(this.timer);\n  }\n\n  async runWorker() {\n    if (!session.token()) {\n      return;\n    }\n    console.log(`[AccountApi] Extending user access token`);\n    try {\n      await this.extendToken();\n    } catch (e) {\n      console.log(`[AccountApi] Error extending user access token`, e);\n    }\n  }\n}\n\n// Maps to user.Role in user/types.go\nexport const Role = {\n  ADMIN: \"admin\",\n  USER: \"user\",\n};\n\n// Maps to server.visitorLimitBasis in server/visitor.go\nexport const LimitBasis = {\n  IP: \"ip\",\n  TIER: \"tier\",\n};\n\n// Maps to stripe.SubscriptionStatus\nexport const SubscriptionStatus = {\n  ACTIVE: \"active\",\n  PAST_DUE: \"past_due\",\n};\n\n// Maps to stripe.PriceRecurringInterval\nexport const SubscriptionInterval = {\n  MONTH: \"month\",\n  YEAR: \"year\",\n};\n\n// Maps to user.Permission in user/types.go\nexport const Permission = {\n  READ_WRITE: \"read-write\",\n  READ_ONLY: \"read-only\",\n  WRITE_ONLY: \"write-only\",\n  DENY_ALL: \"deny-all\",\n};\n\nconst accountApi = new AccountApi();\nexport default accountApi;\n"
  },
  {
    "path": "web/src/app/Api.js",
    "content": "import {\n  fetchLinesIterator,\n  maybeWithAuth,\n  topicShortUrl,\n  topicUrl,\n  topicUrlAuth,\n  topicUrlJsonPoll,\n  topicUrlJsonPollWithSince,\n  webPushUrl,\n} from \"./utils\";\nimport userManager from \"./UserManager\";\nimport { fetchOrThrow } from \"./errors\";\n\nclass Api {\n  async poll(baseUrl, topic, since) {\n    const user = await userManager.get(baseUrl);\n    const shortUrl = topicShortUrl(baseUrl, topic);\n    const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic);\n    const messages = [];\n    const headers = maybeWithAuth({}, user);\n    console.log(`[Api] Polling ${url}`);\n    for await (const line of fetchLinesIterator(url, headers)) {\n      const message = JSON.parse(line);\n      if (message.id) {\n        console.log(`[Api, ${shortUrl}] Received message ${line}`);\n        messages.push(message);\n      }\n    }\n    return messages;\n  }\n\n  async publish(baseUrl, topic, message, options) {\n    const user = await userManager.get(baseUrl);\n    console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);\n    const headers = {};\n    const body = {\n      topic,\n      message,\n      ...options,\n    };\n    await fetchOrThrow(baseUrl, {\n      method: \"PUT\",\n      body: JSON.stringify(body),\n      headers: maybeWithAuth(headers, user),\n    });\n  }\n\n  /**\n   * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.\n   * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.\n   *\n   * Firefox XHR bug:\n   *    Firefox has a bug(?), which returns 0 and \"\" for all fields of the XHR response in the case of an error,\n   *    so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the\n   *    correct headers are clearly set. It's quite the odd behavior.\n   *\n   *  There is an example, and the bug report here:\n   *  - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755\n   *  - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345\n   */\n  publishXHR(url, body, headers, onProgress) {\n    console.log(`[Api] Publishing message to ${url}`);\n    const xhr = new XMLHttpRequest();\n    const send = new Promise((resolve, reject) => {\n      xhr.open(\"PUT\", url);\n      if (body.type) {\n        xhr.overrideMimeType(body.type);\n      }\n      for (const [key, value] of Object.entries(headers)) {\n        xhr.setRequestHeader(key, value);\n      }\n      xhr.upload.addEventListener(\"progress\", onProgress);\n      xhr.addEventListener(\"readystatechange\", () => {\n        if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {\n          console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);\n          resolve(xhr.response);\n        } else if (xhr.readyState === 4) {\n          // Firefox bug; see description above!\n          console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);\n          let errorText;\n          try {\n            const error = JSON.parse(xhr.responseText);\n            if (error.code && error.error) {\n              errorText = `Error ${error.code}: ${error.error}`;\n            }\n          } catch (e) {\n            // Nothing\n          }\n          xhr.abort();\n          reject(errorText ?? \"An error occurred\");\n        }\n      });\n      xhr.send(body);\n    });\n    send.abort = () => {\n      console.log(`[Api] Publish aborted by user`);\n      xhr.abort();\n    };\n    return send;\n  }\n\n  async topicAuth(baseUrl, topic, user) {\n    const url = topicUrlAuth(baseUrl, topic);\n    console.log(`[Api] Checking auth for ${url}`);\n    const response = await fetch(url, {\n      headers: maybeWithAuth({}, user),\n    });\n    if (response.status >= 200 && response.status <= 299) {\n      return true;\n    }\n    if (response.status === 401 || response.status === 403) {\n      // See server/server.go\n      return false;\n    }\n    throw new Error(`Unexpected server response ${response.status}`);\n  }\n\n  async updateWebPush(pushSubscription, topics) {\n    const user = await userManager.get(config.base_url);\n    const url = webPushUrl(config.base_url);\n    console.log(`[Api] Updating Web Push subscription`, { url, topics, endpoint: pushSubscription.endpoint });\n    const serializedSubscription = JSON.parse(JSON.stringify(pushSubscription)); // Ugh ... https://stackoverflow.com/a/40525434/1440785\n    await fetchOrThrow(url, {\n      method: \"POST\",\n      headers: maybeWithAuth({}, user),\n      body: JSON.stringify({\n        endpoint: serializedSubscription.endpoint,\n        auth: serializedSubscription.keys.auth,\n        p256dh: serializedSubscription.keys.p256dh,\n        topics,\n      }),\n    });\n  }\n\n  async deleteWebPush(pushSubscription) {\n    const user = await userManager.get(config.base_url);\n    const url = webPushUrl(config.base_url);\n    console.log(`[Api] Deleting Web Push subscription`, { url, endpoint: pushSubscription.endpoint });\n    await fetchOrThrow(url, {\n      method: \"DELETE\",\n      headers: maybeWithAuth({}, user),\n      body: JSON.stringify({\n        endpoint: pushSubscription.endpoint,\n      }),\n    });\n  }\n}\n\nconst api = new Api();\nexport default api;\n"
  },
  {
    "path": "web/src/app/Connection.js",
    "content": "/* eslint-disable max-classes-per-file */\nimport { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from \"./utils\";\nimport { EVENT_OPEN, isNotificationEvent } from \"./events\";\n\nconst retryBackoffSeconds = [5, 10, 20, 30, 60, 120];\n\nexport class ConnectionState {\n  static Connected = \"connected\";\n\n  static Connecting = \"connecting\";\n}\n\n/**\n * A connection contains a single WebSocket connection for one topic. It handles its connection\n * status itself, including reconnect attempts and backoff.\n *\n * Incoming messages and state changes are forwarded via listeners.\n */\nclass Connection {\n  constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {\n    this.connectionId = connectionId;\n    this.subscriptionId = subscriptionId;\n    this.baseUrl = baseUrl;\n    this.topic = topic;\n    this.user = user;\n    this.since = since;\n    this.shortUrl = topicShortUrl(baseUrl, topic);\n    this.onNotification = onNotification;\n    this.onStateChanged = onStateChanged;\n    this.ws = null;\n    this.retryCount = 0;\n    this.retryTimeout = null;\n  }\n\n  start() {\n    // Don't fetch old messages; we do that as a poll() when adding a subscription;\n    // we don't want to re-trigger the main view re-render potentially hundreds of times.\n\n    const wsUrl = this.wsUrl();\n    console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);\n\n    this.ws = new WebSocket(wsUrl);\n    this.ws.onopen = (event) => {\n      console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);\n      this.retryCount = 0;\n      this.onStateChanged(this.subscriptionId, ConnectionState.Connected);\n    };\n    this.ws.onmessage = (event) => {\n      console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);\n      try {\n        const data = JSON.parse(event.data);\n        if (data.event === EVENT_OPEN) {\n          return;\n        }\n        // Accept message, message_delete, and message_clear events\n        const relevantAndValid = isNotificationEvent(data.event) && \"id\" in data && \"time\" in data;\n        if (!relevantAndValid) {\n          console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);\n          return;\n        }\n        this.since = data.id;\n        this.onNotification(this.subscriptionId, data);\n      } catch (e) {\n        console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);\n      }\n    };\n    this.ws.onclose = (event) => {\n      if (event.wasClean) {\n        console.log(\n          `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`\n        );\n        this.ws = null;\n      } else {\n        const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];\n        this.retryCount += 1;\n        console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);\n        this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);\n        this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);\n      }\n    };\n    this.ws.onerror = (event) => {\n      console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);\n    };\n  }\n\n  close() {\n    console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);\n    const socket = this.ws;\n    const { retryTimeout } = this;\n    if (socket !== null) {\n      socket.close();\n    }\n    if (retryTimeout !== null) {\n      clearTimeout(retryTimeout);\n    }\n    this.retryTimeout = null;\n    this.ws = null;\n  }\n\n  wsUrl() {\n    const params = [];\n    if (this.since) {\n      params.push(`since=${this.since}`);\n    }\n    if (this.user) {\n      params.push(`auth=${this.authParam()}`);\n    }\n    const wsUrl = topicUrlWs(this.baseUrl, this.topic);\n    return params.length === 0 ? wsUrl : `${wsUrl}?${params.join(\"&\")}`;\n  }\n\n  authParam() {\n    if (this.user.password) {\n      return encodeBase64Url(basicAuth(this.user.username, this.user.password));\n    }\n    return encodeBase64Url(bearerAuth(this.user.token));\n  }\n}\n\nexport default Connection;\n"
  },
  {
    "path": "web/src/app/ConnectionManager.js",
    "content": "import Connection from \"./Connection\";\nimport { hashCode } from \"./utils\";\n\nconst makeConnectionId = (subscription, user) =>\n  user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? \"\"}|${user.token ?? \"\"}`) : hashCode(`${subscription.id}`);\n\n/**\n * The connection manager keeps track of active connections (WebSocket connections, see Connection).\n *\n * Its refresh() method reconciles state changes with the target state by closing/opening connections\n * as required. This is done pretty much exactly the same way as in the Android app.\n */\nclass ConnectionManager {\n  constructor() {\n    this.connections = new Map(); // ConnectionId -> Connection (hash, see below)\n    this.stateListener = null; // Fired when connection state changes\n    this.messageListener = null; // Fired when new notifications arrive\n  }\n\n  registerStateListener(listener) {\n    this.stateListener = listener;\n  }\n\n  resetStateListener() {\n    this.stateListener = null;\n  }\n\n  registerMessageListener(listener) {\n    this.messageListener = listener;\n  }\n\n  resetMessageListener() {\n    this.messageListener = null;\n  }\n\n  /**\n   * This function figures out which websocket connections should be running by comparing the\n   * current state of the world (connections) with the target state (targetIds).\n   *\n   * It uses a \"connectionId\", which is sha256($subscriptionId|$username|$password) to identify\n   * connections. If any of them change, the connection is closed/replaced.\n   */\n  async refresh(subscriptions, users) {\n    if (!subscriptions || !users) {\n      return;\n    }\n    console.log(`[ConnectionManager] Refreshing connections`);\n    const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => {\n      const [user] = users.filter((u) => u.baseUrl === s.baseUrl);\n      const connectionId = makeConnectionId(s, user);\n      return { ...s, user, connectionId };\n    });\n\n    const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);\n    const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));\n\n    // Create and add new connections\n    subscriptionsWithUsersAndConnectionId.forEach((subscription) => {\n      const subscriptionId = subscription.id;\n      const { connectionId } = subscription;\n      const added = !this.connections.get(connectionId);\n      if (added) {\n        const { baseUrl, topic, user } = subscription;\n        const since = subscription.last;\n        const connection = new Connection(\n          connectionId,\n          subscriptionId,\n          baseUrl,\n          topic,\n          user,\n          since,\n          (subId, notification) => this.notificationReceived(subId, notification),\n          (subId, state) => this.stateChanged(subId, state)\n        );\n        this.connections.set(connectionId, connection);\n        console.log(\n          `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${\n            user ? user.username : \"anonymous\"\n          })`\n        );\n        connection.start();\n      }\n    });\n\n    // Delete old connections\n    deletedIds.forEach((id) => {\n      console.log(`[ConnectionManager] Closing connection ${id}`);\n      const connection = this.connections.get(id);\n      this.connections.delete(id);\n      connection.close();\n    });\n  }\n\n  stateChanged(subscriptionId, state) {\n    if (this.stateListener) {\n      try {\n        this.stateListener(subscriptionId, state);\n      } catch (e) {\n        console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);\n      }\n    }\n  }\n\n  notificationReceived(subscriptionId, notification) {\n    if (this.messageListener) {\n      try {\n        this.messageListener(subscriptionId, notification);\n      } catch (e) {\n        console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);\n      }\n    }\n  }\n}\n\nconst connectionManager = new ConnectionManager();\nexport default connectionManager;\n"
  },
  {
    "path": "web/src/app/Notifier.js",
    "content": "import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from \"./utils\";\nimport { notificationTag, toNotificationParams } from \"./notificationUtils\";\nimport prefs from \"./Prefs\";\nimport routes from \"../components/routes\";\n\n/**\n * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers\n * support this; most importantly, all iOS browsers do not support window.Notification.\n */\nclass Notifier {\n  lastSoundPlayedAt = 0;\n\n  async notify(subscription, notification) {\n    if (!this.supported()) {\n      return;\n    }\n\n    await this.playSound();\n\n    const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);\n    const defaultTitle = topicDisplayName(subscription);\n\n    console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}`);\n\n    const registration = await this.serviceWorkerRegistration();\n    await registration.showNotification(\n      ...toNotificationParams({\n        message: notification,\n        defaultTitle,\n        topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),\n        baseUrl: subscription.baseUrl,\n        topic: subscription.topic,\n      })\n    );\n  }\n\n  async cancel(subscription, notification) {\n    if (!this.supported()) {\n      return;\n    }\n    try {\n      const sequenceId = notification.sequence_id || notification.id;\n      const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);\n      console.log(`[Notifier] Cancelling notification with tag ${tag}`);\n      const registration = await this.serviceWorkerRegistration();\n      const notifications = await registration.getNotifications({ tag });\n      notifications.forEach((n) => n.close());\n    } catch (e) {\n      console.log(`[Notifier] Error cancelling notification`, e);\n    }\n  }\n\n  async playSound() {\n    // Play sound, but not more than once every 2 seconds\n    const now = Date.now();\n    if (now - this.lastSoundPlayedAt < 2000) {\n      console.log(`[Notifier] Not playing notification sound, since it was last played <2s ago`, this.lastSoundPlayedAt);\n      return;\n    }\n    const sound = await prefs.sound();\n    if (sound && sound !== \"none\") {\n      try {\n        await playSound(sound);\n        this.lastSoundPlayedAt = Date.now();\n      } catch (e) {\n        console.log(`[Notifier] Error playing audio`, e);\n      }\n    }\n  }\n\n  async webPushSubscription(hasWebPushTopics) {\n    const pushManager = await this.pushManager();\n    const existingSubscription = await pushManager.getSubscription();\n    if (existingSubscription) {\n      return existingSubscription;\n    }\n\n    // Create a new subscription only if there are new topics to subscribe to. It is possible that Web Push\n    // was previously enabled and then disabled again in which case there would be an existingSubscription.\n    // If, however, it was _not_ enabled previously, we create a new subscription if it is now enabled.\n\n    if (hasWebPushTopics) {\n      return pushManager.subscribe({\n        userVisibleOnly: true,\n        applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),\n      });\n    }\n\n    return undefined;\n  }\n\n  async pushManager() {\n    return (await this.serviceWorkerRegistration()).pushManager;\n  }\n\n  async serviceWorkerRegistration() {\n    const registration = await navigator.serviceWorker.getRegistration();\n    if (!registration) {\n      throw new Error(\"No service worker registration found\");\n    }\n    return registration;\n  }\n\n  notRequested() {\n    return this.supported() && Notification.permission === \"default\";\n  }\n\n  granted() {\n    return this.supported() && Notification.permission === \"granted\";\n  }\n\n  denied() {\n    return this.supported() && Notification.permission === \"denied\";\n  }\n\n  async maybeRequestPermission() {\n    if (!this.supported()) {\n      return false;\n    }\n\n    return new Promise((resolve) => {\n      Notification.requestPermission((permission) => {\n        resolve(permission === \"granted\");\n      });\n    });\n  }\n\n  supported() {\n    return this.browserSupported() && this.contextSupported();\n  }\n\n  browserSupported() {\n    return \"Notification\" in window;\n  }\n\n  pushSupported() {\n    return config.enable_web_push && \"serviceWorker\" in navigator && \"PushManager\" in window;\n  }\n\n  pushPossible() {\n    return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();\n  }\n\n  /**\n   * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API\n   * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification\n   */\n  contextSupported() {\n    return window.location.protocol === \"https:\" || window.location.hostname.match(\"^127.\") || window.location.hostname === \"localhost\";\n  }\n\n  // no PushManager when not installed, but it _is_ supported.\n  iosSupportedButInstallRequired() {\n    return (\n      config.enable_web_push &&\n      // a service worker exists\n      \"serviceWorker\" in navigator &&\n      // but the pushmanager API is missing, which implies we're on an iOS device without installing\n      !(\"PushManager\" in window) &&\n      // check that this is the case by checking for `standalone`, which only exists on Safari\n      window.navigator.standalone === false\n    );\n  }\n}\n\nconst notifier = new Notifier();\nexport default notifier;\n"
  },
  {
    "path": "web/src/app/Poller.js",
    "content": "import api from \"./Api\";\nimport prefs from \"./Prefs\";\nimport subscriptionManager from \"./SubscriptionManager\";\nimport { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from \"./events\";\n\nconst delayMillis = 2000; // 2 seconds\nconst intervalMillis = 300000; // 5 minutes\n\nclass Poller {\n  constructor() {\n    this.timer = null;\n  }\n\n  startWorker() {\n    if (this.timer !== null) {\n      return;\n    }\n    console.log(`[Poller] Starting worker`);\n    this.timer = setInterval(() => this.pollAll(), intervalMillis);\n    setTimeout(() => this.pollAll(), delayMillis);\n  }\n\n  stopWorker() {\n    clearTimeout(this.timer);\n  }\n\n  async pollAll() {\n    console.log(`[Poller] Polling all subscriptions`);\n    const subscriptions = await subscriptionManager.all();\n\n    await Promise.all(\n      subscriptions.map(async (s) => {\n        try {\n          await this.poll(s);\n        } catch (e) {\n          console.log(`[Poller] Error polling ${s.id}`, e);\n        }\n      })\n    );\n  }\n\n  async poll(subscription) {\n    console.log(`[Poller] Polling ${subscription.id}`);\n\n    const since = subscription.last;\n    const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);\n\n    // Filter out notifications older than the prune threshold\n    const deleteAfterSeconds = await prefs.deleteAfter();\n    const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0;\n    const recentNotifications =\n      pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications;\n\n    // Find the latest notification for each sequence ID\n    const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications);\n\n    // Delete all existing notifications for which the latest notification is marked as deleted\n    const deletedSequenceIds = Object.entries(latestBySequenceId)\n      .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE)\n      .map(([sequenceId]) => sequenceId);\n    if (deletedSequenceIds.length > 0) {\n      console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);\n      await Promise.all(\n        deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))\n      );\n    }\n\n    // Add only the latest notification for each non-deleted sequence\n    const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE);\n    if (notificationsToAdd.length > 0) {\n      console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);\n      await subscriptionManager.addNotifications(subscription.id, notificationsToAdd);\n    } else {\n      console.log(`[Poller] No new notifications found for ${subscription.id}`);\n    }\n  }\n\n  pollInBackground(subscription) {\n    (async () => {\n      try {\n        await this.poll(subscription);\n      } catch (e) {\n        console.error(`[App] Error polling subscription ${subscription.id}`, e);\n      }\n    })();\n  }\n\n  /**\n   * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.\n   * Returns an object mapping sequenceId -> latest notification.\n   */\n  latestNotificationsBySequenceId(notifications) {\n    const latestBySequenceId = {};\n    notifications.forEach((notification) => {\n      const sequenceId = notification.sequence_id || notification.id;\n      if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {\n        latestBySequenceId[sequenceId] = notification;\n      }\n    });\n    return latestBySequenceId;\n  }\n}\n\nconst poller = new Poller();\nexport default poller;\n"
  },
  {
    "path": "web/src/app/Prefs.js",
    "content": "import db from \"./db\";\n\nexport const THEME = {\n  DARK: \"dark\",\n  LIGHT: \"light\",\n  SYSTEM: \"system\",\n};\n\nclass Prefs {\n  constructor(dbImpl) {\n    this.db = dbImpl;\n  }\n\n  async setSound(sound) {\n    this.db.prefs.put({ key: \"sound\", value: sound.toString() });\n  }\n\n  async sound() {\n    const sound = await this.db.prefs.get(\"sound\");\n    return sound ? sound.value : \"ding\";\n  }\n\n  async setMinPriority(minPriority) {\n    this.db.prefs.put({ key: \"minPriority\", value: minPriority.toString() });\n  }\n\n  async minPriority() {\n    const minPriority = await this.db.prefs.get(\"minPriority\");\n    return minPriority ? Number(minPriority.value) : 1;\n  }\n\n  async setDeleteAfter(deleteAfter) {\n    await this.db.prefs.put({ key: \"deleteAfter\", value: deleteAfter.toString() });\n  }\n\n  async deleteAfter() {\n    const deleteAfter = await this.db.prefs.get(\"deleteAfter\");\n    return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week\n  }\n\n  async webPushEnabled() {\n    const webPushEnabled = await this.db.prefs.get(\"webPushEnabled\");\n    return webPushEnabled?.value;\n  }\n\n  async setWebPushEnabled(enabled) {\n    await this.db.prefs.put({ key: \"webPushEnabled\", value: enabled });\n  }\n\n  async theme() {\n    const theme = await this.db.prefs.get(\"theme\");\n    return theme?.value ?? THEME.SYSTEM;\n  }\n\n  async setTheme(mode) {\n    await this.db.prefs.put({ key: \"theme\", value: mode });\n  }\n}\n\nconst prefs = new Prefs(db());\nexport default prefs;\n"
  },
  {
    "path": "web/src/app/Pruner.js",
    "content": "import prefs from \"./Prefs\";\nimport subscriptionManager from \"./SubscriptionManager\";\n\nconst delayMillis = 25000; // 25 seconds\nconst intervalMillis = 1800000; // 30 minutes\n\nclass Pruner {\n  constructor() {\n    this.timer = null;\n  }\n\n  startWorker() {\n    if (this.timer !== null) {\n      return;\n    }\n    console.log(`[Pruner] Starting worker`);\n    this.timer = setInterval(() => this.prune(), intervalMillis);\n    setTimeout(() => this.prune(), delayMillis);\n  }\n\n  stopWorker() {\n    if (this.timer) {\n      clearTimeout(this.timer);\n      this.timer = null;\n    }\n    console.log(\"[Pruner] Stopped worker\");\n  }\n\n  async prune() {\n    const deleteAfterSeconds = await prefs.deleteAfter();\n    const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;\n    if (deleteAfterSeconds === 0) {\n      console.log(`[Pruner] Pruning is disabled. Skipping.`);\n      return;\n    }\n    console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);\n    try {\n      await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);\n    } catch (e) {\n      console.log(`[Pruner] Error pruning old subscriptions`, e);\n    }\n  }\n}\n\nconst pruner = new Pruner();\nexport default pruner;\n"
  },
  {
    "path": "web/src/app/Session.js",
    "content": "import Dexie from \"dexie\";\n\n/**\n * Manages the logged-in user's session and access token.\n * The session replica is stored in IndexedDB so that the service worker can access it.\n */\nclass Session {\n  constructor() {\n    const db = new Dexie(\"session-replica\");\n    db.version(1).stores({\n      kv: \"&key\",\n    });\n    this.db = db;\n\n    // existing sessions (pre-v2.6.0) haven't called `store` with the session-replica,\n    // so attempt to sync any values from localStorage to IndexedDB\n    if (typeof localStorage !== \"undefined\" && this.exists()) {\n      const username = this.username();\n      const token = this.token();\n\n      this.db.kv\n        .bulkPut([\n          { key: \"user\", value: username },\n          { key: \"token\", value: token },\n        ])\n        .then(() => {\n          console.log(\"[Session] Synced localStorage session to IndexedDB\", { username });\n        })\n        .catch((e) => {\n          console.error(\"[Session] Failed to sync localStorage session to IndexedDB\", e);\n        });\n    }\n  }\n\n  async store(username, token) {\n    await this.db.kv.bulkPut([\n      { key: \"user\", value: username },\n      { key: \"token\", value: token },\n    ]);\n    localStorage.setItem(\"user\", username);\n    localStorage.setItem(\"token\", token);\n  }\n\n  async resetAndRedirect(url) {\n    await this.db.delete();\n    localStorage.removeItem(\"user\");\n    localStorage.removeItem(\"token\");\n    window.location.href = url;\n  }\n\n  async usernameAsync() {\n    return (await this.db.kv.get({ key: \"user\" }))?.value;\n  }\n\n  exists() {\n    return this.username() && this.token();\n  }\n\n  username() {\n    return localStorage.getItem(\"user\");\n  }\n\n  token() {\n    return localStorage.getItem(\"token\");\n  }\n}\n\nconst session = new Session();\nexport default session;\n"
  },
  {
    "path": "web/src/app/SubscriptionManager.js",
    "content": "import api from \"./Api\";\nimport notifier from \"./Notifier\";\nimport prefs from \"./Prefs\";\nimport db from \"./db\";\nimport { topicUrl } from \"./utils\";\nimport { messageWithSequenceId } from \"./notificationUtils\";\nimport { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from \"./events\";\n\nclass SubscriptionManager {\n  constructor(dbImpl) {\n    this.db = dbImpl;\n  }\n\n  /** All subscriptions, including \"new count\"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */\n  async all() {\n    const subscriptions = await this.db.subscriptions.toArray();\n    return Promise.all(\n      subscriptions.map(async (s) => ({\n        ...s,\n        new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),\n      }))\n    );\n  }\n\n  /**\n   * List of topics for which Web Push is enabled. This excludes (a) internal topics, (b) topics that are muted,\n   * and (c) topics from other hosts. Returns an empty list if Web Push is disabled.\n   *\n   * It is important to note that \"mutedUntil\" must be part of the where() query, otherwise the Dexie live query\n   * will not react to it, and the Web Push topics will not be updated when the user mutes a topic.\n   */\n  async webPushTopics(pushPossible) {\n    if (!pushPossible) {\n      return [];\n    }\n\n    // the Promise.resolve wrapper is not superfluous, without it the live query breaks:\n    // https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier\n    const enabled = await Promise.resolve(prefs.webPushEnabled());\n    if (!enabled) {\n      return [];\n    }\n\n    const subscriptions = await this.db.subscriptions.where({ baseUrl: config.base_url, mutedUntil: 0 }).toArray();\n    return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);\n  }\n\n  async get(subscriptionId) {\n    return this.db.subscriptions.get(subscriptionId);\n  }\n\n  async notify(subscriptionId, notification) {\n    if (notification.event !== EVENT_MESSAGE) {\n      return;\n    }\n    const subscription = await this.get(subscriptionId);\n    if (subscription.mutedUntil > 0) {\n      return;\n    }\n    const priority = notification.priority ?? 3;\n    if (priority < (await prefs.minPriority())) {\n      return;\n    }\n    await notifier.notify(subscription, notification);\n  }\n\n  /**\n   * @param {string} baseUrl\n   * @param {string} topic\n   * @param {object} opts\n   * @param {boolean} opts.internal\n   * @returns\n   */\n  async add(baseUrl, topic, opts = {}) {\n    const id = topicUrl(baseUrl, topic);\n\n    const existingSubscription = await this.get(id);\n    if (existingSubscription) {\n      return existingSubscription;\n    }\n\n    const subscription = {\n      ...opts,\n      id: topicUrl(baseUrl, topic),\n      baseUrl,\n      topic,\n      mutedUntil: 0,\n      last: null,\n    };\n\n    await this.db.subscriptions.put(subscription);\n\n    return subscription;\n  }\n\n  async syncFromRemote(remoteSubscriptions, remoteReservations) {\n    console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);\n\n    // Add remote subscriptions\n    const remoteIds = await Promise.all(\n      remoteSubscriptions.map(async (remote) => {\n        const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;\n\n        const local = await this.add(remote.base_url, remote.topic, {\n          displayName: remote.display_name, // May be undefined\n          reservation, // May be null!\n        });\n\n        return local.id;\n      })\n    );\n\n    // Remove local subscriptions that do not exist remotely\n    const localSubscriptions = await this.db.subscriptions.toArray();\n\n    await Promise.all(\n      localSubscriptions.map(async (local) => {\n        const remoteExists = remoteIds.includes(local.id);\n        if (!local.internal && !remoteExists) {\n          await this.remove(local);\n        }\n      })\n    );\n  }\n\n  async updateWebPushSubscriptions(topics) {\n    const hasWebPushTopics = topics.length > 0;\n    const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);\n\n    if (!browserSubscription) {\n      console.log(\n        \"[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping.\"\n      );\n      return;\n    }\n\n    if (hasWebPushTopics) {\n      await api.updateWebPush(browserSubscription, topics);\n    } else {\n      await api.deleteWebPush(browserSubscription);\n    }\n  }\n\n  async updateState(subscriptionId, state) {\n    this.db.subscriptions.update(subscriptionId, { state });\n  }\n\n  async remove(subscription) {\n    await this.db.subscriptions.delete(subscription.id);\n    await this.db.notifications.where({ subscriptionId: subscription.id }).delete();\n  }\n\n  async first() {\n    return this.db.subscriptions.toCollection().first(); // May be undefined\n  }\n\n  async getNotifications(subscriptionId) {\n    // This is quite awkward, but it is the recommended approach as per the Dexie docs.\n    // It's actually fine, because the reading and filtering is quite fast. The rendering is what's\n    // killing performance. See  https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach\n\n    return this.db.notifications\n      .orderBy(\"time\") // Sort by time\n      .filter((n) => n.subscriptionId === subscriptionId)\n      .reverse()\n      .toArray();\n  }\n\n  async getAllNotifications() {\n    return this.db.notifications\n      .orderBy(\"time\") // Efficient, see docs\n      .reverse()\n      .toArray();\n  }\n\n  /** Adds notification, or returns false if it already exists */\n  async addNotification(subscriptionId, notification) {\n    const exists = await this.db.notifications.get(notification.id);\n    if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {\n      return false;\n    }\n    try {\n      // Note: Service worker (sw.js) and addNotifications() duplicates this logic,\n      // so if you change it here, change it there too.\n\n      // Add notification to database\n      await this.db.notifications.add({\n        ...messageWithSequenceId(notification),\n        subscriptionId,\n        new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation\n      });\n\n      // FIXME consider put() for double tab\n      // Update subscription last message id (for ?since=... queries)\n      await this.db.subscriptions.update(subscriptionId, {\n        last: notification.id,\n      });\n    } catch (e) {\n      console.error(`[SubscriptionManager] Error adding notification`, e);\n    }\n    return true;\n  }\n\n  /** Adds/replaces notifications, will not throw if they exist */\n  async addNotifications(subscriptionId, notifications) {\n    const notificationsWithSubscriptionId = notifications.map((notification) => ({\n      ...messageWithSequenceId(notification),\n      subscriptionId,\n    }));\n    const lastNotificationId = notifications.at(-1).id;\n    await this.db.notifications.bulkPut(notificationsWithSubscriptionId);\n    await this.db.subscriptions.update(subscriptionId, {\n      last: lastNotificationId,\n    });\n  }\n\n  async updateNotification(notification) {\n    const exists = await this.db.notifications.get(notification.id);\n    if (!exists) {\n      return false;\n    }\n    try {\n      await this.db.notifications.put({ ...notification });\n    } catch (e) {\n      console.error(`[SubscriptionManager] Error updating notification`, e);\n    }\n    return true;\n  }\n\n  async deleteNotification(notificationId) {\n    await this.db.notifications.delete(notificationId);\n  }\n\n  async deleteNotificationBySequenceId(subscriptionId, sequenceId) {\n    await this.db.notifications.where({ subscriptionId, sequenceId }).delete();\n  }\n\n  async deleteNotifications(subscriptionId) {\n    await this.db.notifications.where({ subscriptionId }).delete();\n  }\n\n  async markNotificationRead(notificationId) {\n    await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });\n  }\n\n  async markNotificationReadBySequenceId(subscriptionId, sequenceId) {\n    await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });\n  }\n\n  async markNotificationsRead(subscriptionId) {\n    await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });\n  }\n\n  async setMutedUntil(subscriptionId, mutedUntil) {\n    await this.db.subscriptions.update(subscriptionId, {\n      mutedUntil,\n    });\n  }\n\n  async setDisplayName(subscriptionId, displayName) {\n    await this.db.subscriptions.update(subscriptionId, {\n      displayName,\n    });\n  }\n\n  async setReservation(subscriptionId, reservation) {\n    await this.db.subscriptions.update(subscriptionId, {\n      reservation,\n    });\n  }\n\n  async update(subscriptionId, params) {\n    await this.db.subscriptions.update(subscriptionId, params);\n  }\n\n  async pruneNotifications(thresholdTimestamp) {\n    await this.db.notifications.where(\"time\").below(thresholdTimestamp).delete();\n  }\n}\n\nexport default new SubscriptionManager(db());\n"
  },
  {
    "path": "web/src/app/UserManager.js",
    "content": "import db from \"./db\";\nimport session from \"./Session\";\n\nclass UserManager {\n  constructor(dbImpl) {\n    this.db = dbImpl;\n  }\n\n  async all() {\n    const users = await this.db.users.toArray();\n    if (session.exists()) {\n      users.unshift(this.localUser());\n    }\n    return users;\n  }\n\n  async get(baseUrl) {\n    if (session.exists() && baseUrl === config.base_url) {\n      return this.localUser();\n    }\n    return this.db.users.get(baseUrl);\n  }\n\n  async save(user) {\n    if (session.exists() && user.baseUrl === config.base_url) {\n      return;\n    }\n    await this.db.users.put(user);\n  }\n\n  async delete(baseUrl) {\n    if (session.exists() && baseUrl === config.base_url) {\n      return;\n    }\n    await this.db.users.delete(baseUrl);\n  }\n\n  localUser() {\n    if (!session.exists()) {\n      return null;\n    }\n    return {\n      baseUrl: config.base_url,\n      username: session.username(),\n      token: session.token(), // Not \"password\"!\n    };\n  }\n}\n\nexport default new UserManager(db());\n"
  },
  {
    "path": "web/src/app/VersionChecker.js",
    "content": "/**\n * VersionChecker polls the /v1/config endpoint to detect new server versions\n * or configuration changes, prompting users to refresh the page.\n */\n\nconst intervalMillis = 5 * 60 * 1000; // 5 minutes\n\nclass VersionChecker {\n  constructor() {\n    this.initialConfigHash = null;\n    this.listener = null;\n    this.timer = null;\n  }\n\n  /**\n   * Starts the version checker worker. It stores the initial config hash\n   * from the config.js and polls the server every 5 minutes.\n   */\n  startWorker() {\n    // Store initial config hash from the config loaded at page load\n    this.initialConfigHash = window.config?.config_hash || \"\";\n    console.log(\"[VersionChecker] Starting version checker\");\n    this.timer = setInterval(() => this.checkVersion(), intervalMillis);\n  }\n\n  stopWorker() {\n    if (this.timer) {\n      clearInterval(this.timer);\n      this.timer = null;\n    }\n    console.log(\"[VersionChecker] Stopped version checker\");\n  }\n\n  registerListener(listener) {\n    this.listener = listener;\n  }\n\n  resetListener() {\n    this.listener = null;\n  }\n\n  async checkVersion() {\n    if (!this.initialConfigHash) {\n      return;\n    }\n\n    try {\n      const response = await fetch(`${window.config?.base_url || \"\"}/v1/config`);\n      if (!response.ok) {\n        console.log(\"[VersionChecker] Failed to fetch config:\", response.status);\n        return;\n      }\n\n      const data = await response.json();\n      const currentHash = data.config_hash;\n\n      if (currentHash && currentHash !== this.initialConfigHash) {\n        console.log(\"[VersionChecker] Version or config changed, showing banner\");\n        if (this.listener) {\n          this.listener();\n        }\n      } else {\n        console.log(\"[VersionChecker] No version change detected\");\n      }\n    } catch (error) {\n      console.log(\"[VersionChecker] Error checking config:\", error);\n    }\n  }\n}\n\nconst versionChecker = new VersionChecker();\nexport default versionChecker;\n"
  },
  {
    "path": "web/src/app/actions.js",
    "content": "// Action types for ntfy messages\n// These correspond to the server action types in server/actions.go\n\nexport const ACTION_VIEW = \"view\";\nexport const ACTION_BROADCAST = \"broadcast\";\nexport const ACTION_HTTP = \"http\";\nexport const ACTION_COPY = \"copy\";\n"
  },
  {
    "path": "web/src/app/config.js",
    "content": "const { config } = window;\n\n// The backend returns an empty base_url for the config struct,\n// so the frontend (hey, that's us!) can use the current location.\nif (!config.base_url || config.base_url === \"\") {\n  config.base_url = window.location.origin;\n}\n\nexport default config;\n"
  },
  {
    "path": "web/src/app/db.js",
    "content": "import Dexie from \"dexie\";\nimport session from \"./Session\";\n\n// Uses Dexie.js\n// https://dexie.org/docs/API-Reference#quick-reference\n//\n// Notes:\n// - As per docs, we only declare the indexable columns, not all columns\n\nconst createDatabase = (username) => {\n  const dbName = username ? `ntfy-${username}` : \"ntfy\"; // IndexedDB database is based on the logged-in user\n  const db = new Dexie(dbName);\n\n  db.version(3).stores({\n    subscriptions: \"&id,baseUrl,[baseUrl+mutedUntil]\",\n    notifications: \"&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]\",\n    users: \"&baseUrl,username\",\n    prefs: \"&key\",\n  });\n\n  // When another connection (e.g., service worker or another tab) wants to upgrade,\n  // close this connection gracefully to allow the upgrade to proceed\n  db.on(\"versionchange\", () => {\n    console.log(\"[db] versionchange event: closing database\");\n    db.close();\n  });\n\n  return db;\n};\n\nexport const dbAsync = async () => {\n  const username = await session.usernameAsync();\n  return createDatabase(username);\n};\n\nconst db = () => createDatabase(session.username());\n\nexport default db;\n"
  },
  {
    "path": "web/src/app/emojis.js",
    "content": "// This file is generated by scripts/emoji-convert.sh to reduce the size\n// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json\nexport const rawEmojis = [\n  {\n    emoji: \"😀\",\n    aliases: [\"grinning\"],\n    tags: [\"smile\", \"happy\"],\n    category: \"Smileys & Emotion\",\n    description: \"grinning face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😃\",\n    aliases: [\"smiley\"],\n    tags: [\"happy\", \"joy\", \"haha\"],\n    category: \"Smileys & Emotion\",\n    description: \"grinning face with big eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😄\",\n    aliases: [\"smile\"],\n    tags: [\"happy\", \"joy\", \"laugh\", \"pleased\"],\n    category: \"Smileys & Emotion\",\n    description: \"grinning face with smiling eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😁\",\n    aliases: [\"grin\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"beaming face with smiling eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😆\",\n    aliases: [\"laughing\", \"satisfied\"],\n    tags: [\"happy\", \"haha\"],\n    category: \"Smileys & Emotion\",\n    description: \"grinning squinting face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😅\",\n    aliases: [\"sweat_smile\"],\n    tags: [\"hot\"],\n    category: \"Smileys & Emotion\",\n    description: \"grinning face with sweat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤣\",\n    aliases: [\"rofl\"],\n    tags: [\"lol\", \"laughing\"],\n    category: \"Smileys & Emotion\",\n    description: \"rolling on the floor laughing\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"😂\",\n    aliases: [\"joy\"],\n    tags: [\"tears\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with tears of joy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙂\",\n    aliases: [\"slightly_smiling_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"slightly smiling face\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🙃\",\n    aliases: [\"upside_down_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"upside-down face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"😉\",\n    aliases: [\"wink\"],\n    tags: [\"flirt\"],\n    category: \"Smileys & Emotion\",\n    description: \"winking face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😊\",\n    aliases: [\"blush\"],\n    tags: [\"proud\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with smiling eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😇\",\n    aliases: [\"innocent\"],\n    tags: [\"angel\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with halo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥰\",\n    aliases: [\"smiling_face_with_three_hearts\"],\n    tags: [\"love\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with hearts\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😍\",\n    aliases: [\"heart_eyes\"],\n    tags: [\"love\", \"crush\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with heart-eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤩\",\n    aliases: [\"star_struck\"],\n    tags: [\"eyes\"],\n    category: \"Smileys & Emotion\",\n    description: \"star-struck\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😘\",\n    aliases: [\"kissing_heart\"],\n    tags: [\"flirt\"],\n    category: \"Smileys & Emotion\",\n    description: \"face blowing a kiss\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😗\",\n    aliases: [\"kissing\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"kissing face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"☺️\",\n    aliases: [\"relaxed\"],\n    tags: [\"blush\", \"pleased\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"😚\",\n    aliases: [\"kissing_closed_eyes\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"kissing face with closed eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😙\",\n    aliases: [\"kissing_smiling_eyes\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"kissing face with smiling eyes\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"🥲\",\n    aliases: [\"smiling_face_with_tear\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with tear\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"😋\",\n    aliases: [\"yum\"],\n    tags: [\"tongue\", \"lick\"],\n    category: \"Smileys & Emotion\",\n    description: \"face savoring food\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😛\",\n    aliases: [\"stuck_out_tongue\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face with tongue\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😜\",\n    aliases: [\"stuck_out_tongue_winking_eye\"],\n    tags: [\"prank\", \"silly\"],\n    category: \"Smileys & Emotion\",\n    description: \"winking face with tongue\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤪\",\n    aliases: [\"zany_face\"],\n    tags: [\"goofy\", \"wacky\"],\n    category: \"Smileys & Emotion\",\n    description: \"zany face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😝\",\n    aliases: [\"stuck_out_tongue_closed_eyes\"],\n    tags: [\"prank\"],\n    category: \"Smileys & Emotion\",\n    description: \"squinting face with tongue\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤑\",\n    aliases: [\"money_mouth_face\"],\n    tags: [\"rich\"],\n    category: \"Smileys & Emotion\",\n    description: \"money-mouth face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤗\",\n    aliases: [\"hugs\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"hugging face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤭\",\n    aliases: [\"hand_over_mouth\"],\n    tags: [\"quiet\", \"whoops\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with hand over mouth\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤫\",\n    aliases: [\"shushing_face\"],\n    tags: [\"silence\", \"quiet\"],\n    category: \"Smileys & Emotion\",\n    description: \"shushing face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤔\",\n    aliases: [\"thinking\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"thinking face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤐\",\n    aliases: [\"zipper_mouth_face\"],\n    tags: [\"silence\", \"hush\"],\n    category: \"Smileys & Emotion\",\n    description: \"zipper-mouth face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤨\",\n    aliases: [\"raised_eyebrow\"],\n    tags: [\"suspicious\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with raised eyebrow\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😐\",\n    aliases: [\"neutral_face\"],\n    tags: [\"meh\"],\n    category: \"Smileys & Emotion\",\n    description: \"neutral face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😑\",\n    aliases: [\"expressionless\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"expressionless face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😶\",\n    aliases: [\"no_mouth\"],\n    tags: [\"mute\", \"silence\"],\n    category: \"Smileys & Emotion\",\n    description: \"face without mouth\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😶‍🌫️\",\n    aliases: [\"face_in_clouds\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face in clouds\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"😏\",\n    aliases: [\"smirk\"],\n    tags: [\"smug\"],\n    category: \"Smileys & Emotion\",\n    description: \"smirking face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😒\",\n    aliases: [\"unamused\"],\n    tags: [\"meh\"],\n    category: \"Smileys & Emotion\",\n    description: \"unamused face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙄\",\n    aliases: [\"roll_eyes\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face with rolling eyes\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"😬\",\n    aliases: [\"grimacing\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"grimacing face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😮‍💨\",\n    aliases: [\"face_exhaling\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face exhaling\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"🤥\",\n    aliases: [\"lying_face\"],\n    tags: [\"liar\"],\n    category: \"Smileys & Emotion\",\n    description: \"lying face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"😌\",\n    aliases: [\"relieved\"],\n    tags: [\"whew\"],\n    category: \"Smileys & Emotion\",\n    description: \"relieved face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😔\",\n    aliases: [\"pensive\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"pensive face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😪\",\n    aliases: [\"sleepy\"],\n    tags: [\"tired\"],\n    category: \"Smileys & Emotion\",\n    description: \"sleepy face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤤\",\n    aliases: [\"drooling_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"drooling face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"😴\",\n    aliases: [\"sleeping\"],\n    tags: [\"zzz\"],\n    category: \"Smileys & Emotion\",\n    description: \"sleeping face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😷\",\n    aliases: [\"mask\"],\n    tags: [\"sick\", \"ill\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with medical mask\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤒\",\n    aliases: [\"face_with_thermometer\"],\n    tags: [\"sick\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with thermometer\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤕\",\n    aliases: [\"face_with_head_bandage\"],\n    tags: [\"hurt\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with head-bandage\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤢\",\n    aliases: [\"nauseated_face\"],\n    tags: [\"sick\", \"barf\", \"disgusted\"],\n    category: \"Smileys & Emotion\",\n    description: \"nauseated face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤮\",\n    aliases: [\"vomiting_face\"],\n    tags: [\"barf\", \"sick\"],\n    category: \"Smileys & Emotion\",\n    description: \"face vomiting\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤧\",\n    aliases: [\"sneezing_face\"],\n    tags: [\"achoo\", \"sick\"],\n    category: \"Smileys & Emotion\",\n    description: \"sneezing face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥵\",\n    aliases: [\"hot_face\"],\n    tags: [\"heat\", \"sweating\"],\n    category: \"Smileys & Emotion\",\n    description: \"hot face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥶\",\n    aliases: [\"cold_face\"],\n    tags: [\"freezing\", \"ice\"],\n    category: \"Smileys & Emotion\",\n    description: \"cold face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥴\",\n    aliases: [\"woozy_face\"],\n    tags: [\"groggy\"],\n    category: \"Smileys & Emotion\",\n    description: \"woozy face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😵\",\n    aliases: [\"dizzy_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"knocked-out face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😵‍💫\",\n    aliases: [\"face_with_spiral_eyes\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face with spiral eyes\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"🤯\",\n    aliases: [\"exploding_head\"],\n    tags: [\"mind\", \"blown\"],\n    category: \"Smileys & Emotion\",\n    description: \"exploding head\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤠\",\n    aliases: [\"cowboy_hat_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"cowboy hat face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥳\",\n    aliases: [\"partying_face\"],\n    tags: [\"celebration\", \"birthday\"],\n    category: \"Smileys & Emotion\",\n    description: \"partying face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥸\",\n    aliases: [\"disguised_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"disguised face\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"😎\",\n    aliases: [\"sunglasses\"],\n    tags: [\"cool\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with sunglasses\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤓\",\n    aliases: [\"nerd_face\"],\n    tags: [\"geek\", \"glasses\"],\n    category: \"Smileys & Emotion\",\n    description: \"nerd face\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🧐\",\n    aliases: [\"monocle_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"face with monocle\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😕\",\n    aliases: [\"confused\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"confused face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😟\",\n    aliases: [\"worried\"],\n    tags: [\"nervous\"],\n    category: \"Smileys & Emotion\",\n    description: \"worried face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"🙁\",\n    aliases: [\"slightly_frowning_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"slightly frowning face\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"☹️\",\n    aliases: [\"frowning_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"frowning face\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"😮\",\n    aliases: [\"open_mouth\"],\n    tags: [\"surprise\", \"impressed\", \"wow\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with open mouth\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😯\",\n    aliases: [\"hushed\"],\n    tags: [\"silence\", \"speechless\"],\n    category: \"Smileys & Emotion\",\n    description: \"hushed face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😲\",\n    aliases: [\"astonished\"],\n    tags: [\"amazed\", \"gasp\"],\n    category: \"Smileys & Emotion\",\n    description: \"astonished face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😳\",\n    aliases: [\"flushed\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"flushed face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥺\",\n    aliases: [\"pleading_face\"],\n    tags: [\"puppy\", \"eyes\"],\n    category: \"Smileys & Emotion\",\n    description: \"pleading face\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😦\",\n    aliases: [\"frowning\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"frowning face with open mouth\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😧\",\n    aliases: [\"anguished\"],\n    tags: [\"stunned\"],\n    category: \"Smileys & Emotion\",\n    description: \"anguished face\",\n    unicode_version: \"6.1\",\n  },\n  {\n    emoji: \"😨\",\n    aliases: [\"fearful\"],\n    tags: [\"scared\", \"shocked\", \"oops\"],\n    category: \"Smileys & Emotion\",\n    description: \"fearful face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😰\",\n    aliases: [\"cold_sweat\"],\n    tags: [\"nervous\"],\n    category: \"Smileys & Emotion\",\n    description: \"anxious face with sweat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😥\",\n    aliases: [\"disappointed_relieved\"],\n    tags: [\"phew\", \"sweat\", \"nervous\"],\n    category: \"Smileys & Emotion\",\n    description: \"sad but relieved face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😢\",\n    aliases: [\"cry\"],\n    tags: [\"sad\", \"tear\"],\n    category: \"Smileys & Emotion\",\n    description: \"crying face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😭\",\n    aliases: [\"sob\"],\n    tags: [\"sad\", \"cry\", \"bawling\"],\n    category: \"Smileys & Emotion\",\n    description: \"loudly crying face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😱\",\n    aliases: [\"scream\"],\n    tags: [\"horror\", \"shocked\"],\n    category: \"Smileys & Emotion\",\n    description: \"face screaming in fear\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😖\",\n    aliases: [\"confounded\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"confounded face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😣\",\n    aliases: [\"persevere\"],\n    tags: [\"struggling\"],\n    category: \"Smileys & Emotion\",\n    description: \"persevering face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😞\",\n    aliases: [\"disappointed\"],\n    tags: [\"sad\"],\n    category: \"Smileys & Emotion\",\n    description: \"disappointed face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😓\",\n    aliases: [\"sweat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"downcast face with sweat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😩\",\n    aliases: [\"weary\"],\n    tags: [\"tired\"],\n    category: \"Smileys & Emotion\",\n    description: \"weary face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😫\",\n    aliases: [\"tired_face\"],\n    tags: [\"upset\", \"whine\"],\n    category: \"Smileys & Emotion\",\n    description: \"tired face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥱\",\n    aliases: [\"yawning_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"yawning face\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"😤\",\n    aliases: [\"triumph\"],\n    tags: [\"smug\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with steam from nose\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😡\",\n    aliases: [\"rage\", \"pout\"],\n    tags: [\"angry\"],\n    category: \"Smileys & Emotion\",\n    description: \"pouting face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😠\",\n    aliases: [\"angry\"],\n    tags: [\"mad\", \"annoyed\"],\n    category: \"Smileys & Emotion\",\n    description: \"angry face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤬\",\n    aliases: [\"cursing_face\"],\n    tags: [\"foul\"],\n    category: \"Smileys & Emotion\",\n    description: \"face with symbols on mouth\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"😈\",\n    aliases: [\"smiling_imp\"],\n    tags: [\"devil\", \"evil\", \"horns\"],\n    category: \"Smileys & Emotion\",\n    description: \"smiling face with horns\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👿\",\n    aliases: [\"imp\"],\n    tags: [\"angry\", \"devil\", \"evil\", \"horns\"],\n    category: \"Smileys & Emotion\",\n    description: \"angry face with horns\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💀\",\n    aliases: [\"skull\"],\n    tags: [\"dead\", \"danger\", \"poison\"],\n    category: \"Smileys & Emotion\",\n    description: \"skull\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☠️\",\n    aliases: [\"skull_and_crossbones\"],\n    tags: [\"danger\", \"pirate\"],\n    category: \"Smileys & Emotion\",\n    description: \"skull and crossbones\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"💩\",\n    aliases: [\"hankey\", \"poop\", \"shit\"],\n    tags: [\"crap\"],\n    category: \"Smileys & Emotion\",\n    description: \"pile of poo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤡\",\n    aliases: [\"clown_face\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"clown face\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"👹\",\n    aliases: [\"japanese_ogre\"],\n    tags: [\"monster\"],\n    category: \"Smileys & Emotion\",\n    description: \"ogre\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👺\",\n    aliases: [\"japanese_goblin\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"goblin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👻\",\n    aliases: [\"ghost\"],\n    tags: [\"halloween\"],\n    category: \"Smileys & Emotion\",\n    description: \"ghost\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👽\",\n    aliases: [\"alien\"],\n    tags: [\"ufo\"],\n    category: \"Smileys & Emotion\",\n    description: \"alien\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👾\",\n    aliases: [\"space_invader\"],\n    tags: [\"game\", \"retro\"],\n    category: \"Smileys & Emotion\",\n    description: \"alien monster\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤖\",\n    aliases: [\"robot\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"robot\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"😺\",\n    aliases: [\"smiley_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"grinning cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😸\",\n    aliases: [\"smile_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"grinning cat with smiling eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😹\",\n    aliases: [\"joy_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"cat with tears of joy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😻\",\n    aliases: [\"heart_eyes_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"smiling cat with heart-eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😼\",\n    aliases: [\"smirk_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"cat with wry smile\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😽\",\n    aliases: [\"kissing_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"kissing cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙀\",\n    aliases: [\"scream_cat\"],\n    tags: [\"horror\"],\n    category: \"Smileys & Emotion\",\n    description: \"weary cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😿\",\n    aliases: [\"crying_cat_face\"],\n    tags: [\"sad\", \"tear\"],\n    category: \"Smileys & Emotion\",\n    description: \"crying cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"😾\",\n    aliases: [\"pouting_cat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"pouting cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙈\",\n    aliases: [\"see_no_evil\"],\n    tags: [\"monkey\", \"blind\", \"ignore\"],\n    category: \"Smileys & Emotion\",\n    description: \"see-no-evil monkey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙉\",\n    aliases: [\"hear_no_evil\"],\n    tags: [\"monkey\", \"deaf\"],\n    category: \"Smileys & Emotion\",\n    description: \"hear-no-evil monkey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙊\",\n    aliases: [\"speak_no_evil\"],\n    tags: [\"monkey\", \"mute\", \"hush\"],\n    category: \"Smileys & Emotion\",\n    description: \"speak-no-evil monkey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💋\",\n    aliases: [\"kiss\"],\n    tags: [\"lipstick\"],\n    category: \"Smileys & Emotion\",\n    description: \"kiss mark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💌\",\n    aliases: [\"love_letter\"],\n    tags: [\"email\", \"envelope\"],\n    category: \"Smileys & Emotion\",\n    description: \"love letter\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💘\",\n    aliases: [\"cupid\"],\n    tags: [\"love\", \"heart\"],\n    category: \"Smileys & Emotion\",\n    description: \"heart with arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💝\",\n    aliases: [\"gift_heart\"],\n    tags: [\"chocolates\"],\n    category: \"Smileys & Emotion\",\n    description: \"heart with ribbon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💖\",\n    aliases: [\"sparkling_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"sparkling heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💗\",\n    aliases: [\"heartpulse\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"growing heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💓\",\n    aliases: [\"heartbeat\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"beating heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💞\",\n    aliases: [\"revolving_hearts\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"revolving hearts\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💕\",\n    aliases: [\"two_hearts\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"two hearts\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💟\",\n    aliases: [\"heart_decoration\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"heart decoration\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❣️\",\n    aliases: [\"heavy_heart_exclamation\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"heart exclamation\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"💔\",\n    aliases: [\"broken_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"broken heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❤️‍🔥\",\n    aliases: [\"heart_on_fire\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"heart on fire\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"❤️‍🩹\",\n    aliases: [\"mending_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"mending heart\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"❤️\",\n    aliases: [\"heart\"],\n    tags: [\"love\"],\n    category: \"Smileys & Emotion\",\n    description: \"red heart\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧡\",\n    aliases: [\"orange_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"orange heart\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💛\",\n    aliases: [\"yellow_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"yellow heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💚\",\n    aliases: [\"green_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"green heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💙\",\n    aliases: [\"blue_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"blue heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💜\",\n    aliases: [\"purple_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"purple heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤎\",\n    aliases: [\"brown_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"brown heart\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🖤\",\n    aliases: [\"black_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"black heart\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤍\",\n    aliases: [\"white_heart\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"white heart\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"💯\",\n    aliases: [\"100\"],\n    tags: [\"score\", \"perfect\"],\n    category: \"Smileys & Emotion\",\n    description: \"hundred points\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💢\",\n    aliases: [\"anger\"],\n    tags: [\"angry\"],\n    category: \"Smileys & Emotion\",\n    description: \"anger symbol\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💥\",\n    aliases: [\"boom\", \"collision\"],\n    tags: [\"explode\"],\n    category: \"Smileys & Emotion\",\n    description: \"collision\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💫\",\n    aliases: [\"dizzy\"],\n    tags: [\"star\"],\n    category: \"Smileys & Emotion\",\n    description: \"dizzy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💦\",\n    aliases: [\"sweat_drops\"],\n    tags: [\"water\", \"workout\"],\n    category: \"Smileys & Emotion\",\n    description: \"sweat droplets\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💨\",\n    aliases: [\"dash\"],\n    tags: [\"wind\", \"blow\", \"fast\"],\n    category: \"Smileys & Emotion\",\n    description: \"dashing away\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕳️\",\n    aliases: [\"hole\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"hole\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"💣\",\n    aliases: [\"bomb\"],\n    tags: [\"boom\"],\n    category: \"Smileys & Emotion\",\n    description: \"bomb\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💬\",\n    aliases: [\"speech_balloon\"],\n    tags: [\"comment\"],\n    category: \"Smileys & Emotion\",\n    description: \"speech balloon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👁️‍🗨️\",\n    aliases: [\"eye_speech_bubble\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"eye in speech bubble\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🗨️\",\n    aliases: [\"left_speech_bubble\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"left speech bubble\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🗯️\",\n    aliases: [\"right_anger_bubble\"],\n    tags: [],\n    category: \"Smileys & Emotion\",\n    description: \"right anger bubble\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"💭\",\n    aliases: [\"thought_balloon\"],\n    tags: [\"thinking\"],\n    category: \"Smileys & Emotion\",\n    description: \"thought balloon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💤\",\n    aliases: [\"zzz\"],\n    tags: [\"sleeping\"],\n    category: \"Smileys & Emotion\",\n    description: \"zzz\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👋\",\n    aliases: [\"wave\"],\n    tags: [\"goodbye\"],\n    category: \"People & Body\",\n    description: \"waving hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤚\",\n    aliases: [\"raised_back_of_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"raised back of hand\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🖐️\",\n    aliases: [\"raised_hand_with_fingers_splayed\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"hand with fingers splayed\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"✋\",\n    aliases: [\"hand\", \"raised_hand\"],\n    tags: [\"highfive\", \"stop\"],\n    category: \"People & Body\",\n    description: \"raised hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🖖\",\n    aliases: [\"vulcan_salute\"],\n    tags: [\"prosper\", \"spock\"],\n    category: \"People & Body\",\n    description: \"vulcan salute\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"👌\",\n    aliases: [\"ok_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"OK hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤌\",\n    aliases: [\"pinched_fingers\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"pinched fingers\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🤏\",\n    aliases: [\"pinching_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"pinching hand\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"✌️\",\n    aliases: [\"v\"],\n    tags: [\"victory\", \"peace\"],\n    category: \"People & Body\",\n    description: \"victory hand\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🤞\",\n    aliases: [\"crossed_fingers\"],\n    tags: [\"luck\", \"hopeful\"],\n    category: \"People & Body\",\n    description: \"crossed fingers\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤟\",\n    aliases: [\"love_you_gesture\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"love-you gesture\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤘\",\n    aliases: [\"metal\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"sign of the horns\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🤙\",\n    aliases: [\"call_me_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"call me hand\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"👈\",\n    aliases: [\"point_left\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"backhand index pointing left\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👉\",\n    aliases: [\"point_right\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"backhand index pointing right\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👆\",\n    aliases: [\"point_up_2\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"backhand index pointing up\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🖕\",\n    aliases: [\"middle_finger\", \"fu\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"middle finger\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"👇\",\n    aliases: [\"point_down\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"backhand index pointing down\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☝️\",\n    aliases: [\"point_up\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"index pointing up\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👍\",\n    aliases: [\"+1\", \"thumbsup\"],\n    tags: [\"approve\", \"ok\"],\n    category: \"People & Body\",\n    description: \"thumbs up\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👎\",\n    aliases: [\"-1\", \"thumbsdown\"],\n    tags: [\"disapprove\", \"bury\"],\n    category: \"People & Body\",\n    description: \"thumbs down\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"✊\",\n    aliases: [\"fist_raised\", \"fist\"],\n    tags: [\"power\"],\n    category: \"People & Body\",\n    description: \"raised fist\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👊\",\n    aliases: [\"fist_oncoming\", \"facepunch\", \"punch\"],\n    tags: [\"attack\"],\n    category: \"People & Body\",\n    description: \"oncoming fist\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤛\",\n    aliases: [\"fist_left\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"left-facing fist\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤜\",\n    aliases: [\"fist_right\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"right-facing fist\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"👏\",\n    aliases: [\"clap\"],\n    tags: [\"praise\", \"applause\"],\n    category: \"People & Body\",\n    description: \"clapping hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙌\",\n    aliases: [\"raised_hands\"],\n    tags: [\"hooray\"],\n    category: \"People & Body\",\n    description: \"raising hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👐\",\n    aliases: [\"open_hands\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"open hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤲\",\n    aliases: [\"palms_up_together\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"palms up together\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤝\",\n    aliases: [\"handshake\"],\n    tags: [\"deal\"],\n    category: \"People & Body\",\n    description: \"handshake\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🙏\",\n    aliases: [\"pray\"],\n    tags: [\"please\", \"hope\", \"wish\"],\n    category: \"People & Body\",\n    description: \"folded hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"✍️\",\n    aliases: [\"writing_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"writing hand\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"💅\",\n    aliases: [\"nail_care\"],\n    tags: [\"beauty\", \"manicure\"],\n    category: \"People & Body\",\n    description: \"nail polish\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤳\",\n    aliases: [\"selfie\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"selfie\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"💪\",\n    aliases: [\"muscle\"],\n    tags: [\"flex\", \"bicep\", \"strong\", \"workout\"],\n    category: \"People & Body\",\n    description: \"flexed biceps\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦾\",\n    aliases: [\"mechanical_arm\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"mechanical arm\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦿\",\n    aliases: [\"mechanical_leg\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"mechanical leg\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦵\",\n    aliases: [\"leg\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"leg\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦶\",\n    aliases: [\"foot\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"foot\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👂\",\n    aliases: [\"ear\"],\n    tags: [\"hear\", \"sound\", \"listen\"],\n    category: \"People & Body\",\n    description: \"ear\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦻\",\n    aliases: [\"ear_with_hearing_aid\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"ear with hearing aid\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👃\",\n    aliases: [\"nose\"],\n    tags: [\"smell\"],\n    category: \"People & Body\",\n    description: \"nose\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧠\",\n    aliases: [\"brain\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"brain\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🫀\",\n    aliases: [\"anatomical_heart\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"anatomical heart\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🫁\",\n    aliases: [\"lungs\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"lungs\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦷\",\n    aliases: [\"tooth\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"tooth\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦴\",\n    aliases: [\"bone\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"bone\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👀\",\n    aliases: [\"eyes\"],\n    tags: [\"look\", \"see\", \"watch\"],\n    category: \"People & Body\",\n    description: \"eyes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👁️\",\n    aliases: [\"eye\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"eye\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"👅\",\n    aliases: [\"tongue\"],\n    tags: [\"taste\"],\n    category: \"People & Body\",\n    description: \"tongue\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👄\",\n    aliases: [\"lips\"],\n    tags: [\"kiss\"],\n    category: \"People & Body\",\n    description: \"mouth\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👶\",\n    aliases: [\"baby\"],\n    tags: [\"child\", \"newborn\"],\n    category: \"People & Body\",\n    description: \"baby\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧒\",\n    aliases: [\"child\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"child\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👦\",\n    aliases: [\"boy\"],\n    tags: [\"child\"],\n    category: \"People & Body\",\n    description: \"boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👧\",\n    aliases: [\"girl\"],\n    tags: [\"child\"],\n    category: \"People & Body\",\n    description: \"girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧑\",\n    aliases: [\"adult\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👱\",\n    aliases: [\"blond_haired_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: blond hair\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨\",\n    aliases: [\"man\"],\n    tags: [\"mustache\", \"father\", \"dad\"],\n    category: \"People & Body\",\n    description: \"man\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧔\",\n    aliases: [\"bearded_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: beard\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧔‍♂️\",\n    aliases: [\"man_beard\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: beard\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"🧔‍♀️\",\n    aliases: [\"woman_beard\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: beard\",\n    unicode_version: \"13.1\",\n  },\n  {\n    emoji: \"👨‍🦰\",\n    aliases: [\"red_haired_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: red hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍🦱\",\n    aliases: [\"curly_haired_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: curly hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍🦳\",\n    aliases: [\"white_haired_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: white hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍🦲\",\n    aliases: [\"bald_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: bald\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👩\",\n    aliases: [\"woman\"],\n    tags: [\"girls\"],\n    category: \"People & Body\",\n    description: \"woman\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍🦰\",\n    aliases: [\"red_haired_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: red hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧑‍🦰\",\n    aliases: [\"person_red_hair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: red hair\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👩‍🦱\",\n    aliases: [\"curly_haired_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: curly hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧑‍🦱\",\n    aliases: [\"person_curly_hair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: curly hair\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👩‍🦳\",\n    aliases: [\"white_haired_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: white hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧑‍🦳\",\n    aliases: [\"person_white_hair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: white hair\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👩‍🦲\",\n    aliases: [\"bald_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: bald\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧑‍🦲\",\n    aliases: [\"person_bald\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person: bald\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👱‍♀️\",\n    aliases: [\"blond_haired_woman\", \"blonde_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman: blond hair\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👱‍♂️\",\n    aliases: [\"blond_haired_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man: blond hair\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧓\",\n    aliases: [\"older_adult\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"older person\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👴\",\n    aliases: [\"older_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"old man\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👵\",\n    aliases: [\"older_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"old woman\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙍\",\n    aliases: [\"frowning_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person frowning\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙍‍♂️\",\n    aliases: [\"frowning_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man frowning\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙍‍♀️\",\n    aliases: [\"frowning_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman frowning\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🙎\",\n    aliases: [\"pouting_face\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person pouting\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙎‍♂️\",\n    aliases: [\"pouting_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man pouting\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙎‍♀️\",\n    aliases: [\"pouting_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman pouting\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🙅\",\n    aliases: [\"no_good\"],\n    tags: [\"stop\", \"halt\", \"denied\"],\n    category: \"People & Body\",\n    description: \"person gesturing NO\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙅‍♂️\",\n    aliases: [\"no_good_man\", \"ng_man\"],\n    tags: [\"stop\", \"halt\", \"denied\"],\n    category: \"People & Body\",\n    description: \"man gesturing NO\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙅‍♀️\",\n    aliases: [\"no_good_woman\", \"ng_woman\"],\n    tags: [\"stop\", \"halt\", \"denied\"],\n    category: \"People & Body\",\n    description: \"woman gesturing NO\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🙆\",\n    aliases: [\"ok_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person gesturing OK\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙆‍♂️\",\n    aliases: [\"ok_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man gesturing OK\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙆‍♀️\",\n    aliases: [\"ok_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman gesturing OK\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💁\",\n    aliases: [\"tipping_hand_person\", \"information_desk_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person tipping hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💁‍♂️\",\n    aliases: [\"tipping_hand_man\", \"sassy_man\"],\n    tags: [\"information\"],\n    category: \"People & Body\",\n    description: \"man tipping hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💁‍♀️\",\n    aliases: [\"tipping_hand_woman\", \"sassy_woman\"],\n    tags: [\"information\"],\n    category: \"People & Body\",\n    description: \"woman tipping hand\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🙋\",\n    aliases: [\"raising_hand\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person raising hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙋‍♂️\",\n    aliases: [\"raising_hand_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man raising hand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙋‍♀️\",\n    aliases: [\"raising_hand_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman raising hand\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧏\",\n    aliases: [\"deaf_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"deaf person\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧏‍♂️\",\n    aliases: [\"deaf_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"deaf man\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧏‍♀️\",\n    aliases: [\"deaf_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"deaf woman\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🙇\",\n    aliases: [\"bow\"],\n    tags: [\"respect\", \"thanks\"],\n    category: \"People & Body\",\n    description: \"person bowing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🙇‍♂️\",\n    aliases: [\"bowing_man\"],\n    tags: [\"respect\", \"thanks\"],\n    category: \"People & Body\",\n    description: \"man bowing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🙇‍♀️\",\n    aliases: [\"bowing_woman\"],\n    tags: [\"respect\", \"thanks\"],\n    category: \"People & Body\",\n    description: \"woman bowing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤦\",\n    aliases: [\"facepalm\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person facepalming\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤦‍♂️\",\n    aliases: [\"man_facepalming\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man facepalming\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤦‍♀️\",\n    aliases: [\"woman_facepalming\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman facepalming\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤷\",\n    aliases: [\"shrug\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person shrugging\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤷‍♂️\",\n    aliases: [\"man_shrugging\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man shrugging\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤷‍♀️\",\n    aliases: [\"woman_shrugging\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman shrugging\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🧑‍⚕️\",\n    aliases: [\"health_worker\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"health worker\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍⚕️\",\n    aliases: [\"man_health_worker\"],\n    tags: [\"doctor\", \"nurse\"],\n    category: \"People & Body\",\n    description: \"man health worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍⚕️\",\n    aliases: [\"woman_health_worker\"],\n    tags: [\"doctor\", \"nurse\"],\n    category: \"People & Body\",\n    description: \"woman health worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🎓\",\n    aliases: [\"student\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"student\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🎓\",\n    aliases: [\"man_student\"],\n    tags: [\"graduation\"],\n    category: \"People & Body\",\n    description: \"man student\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🎓\",\n    aliases: [\"woman_student\"],\n    tags: [\"graduation\"],\n    category: \"People & Body\",\n    description: \"woman student\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🏫\",\n    aliases: [\"teacher\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"teacher\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🏫\",\n    aliases: [\"man_teacher\"],\n    tags: [\"school\", \"professor\"],\n    category: \"People & Body\",\n    description: \"man teacher\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🏫\",\n    aliases: [\"woman_teacher\"],\n    tags: [\"school\", \"professor\"],\n    category: \"People & Body\",\n    description: \"woman teacher\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍⚖️\",\n    aliases: [\"judge\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"judge\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍⚖️\",\n    aliases: [\"man_judge\"],\n    tags: [\"justice\"],\n    category: \"People & Body\",\n    description: \"man judge\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍⚖️\",\n    aliases: [\"woman_judge\"],\n    tags: [\"justice\"],\n    category: \"People & Body\",\n    description: \"woman judge\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🌾\",\n    aliases: [\"farmer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"farmer\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🌾\",\n    aliases: [\"man_farmer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man farmer\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🌾\",\n    aliases: [\"woman_farmer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman farmer\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🍳\",\n    aliases: [\"cook\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"cook\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🍳\",\n    aliases: [\"man_cook\"],\n    tags: [\"chef\"],\n    category: \"People & Body\",\n    description: \"man cook\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🍳\",\n    aliases: [\"woman_cook\"],\n    tags: [\"chef\"],\n    category: \"People & Body\",\n    description: \"woman cook\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🔧\",\n    aliases: [\"mechanic\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"mechanic\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🔧\",\n    aliases: [\"man_mechanic\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man mechanic\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🔧\",\n    aliases: [\"woman_mechanic\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman mechanic\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🏭\",\n    aliases: [\"factory_worker\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"factory worker\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🏭\",\n    aliases: [\"man_factory_worker\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man factory worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🏭\",\n    aliases: [\"woman_factory_worker\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman factory worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍💼\",\n    aliases: [\"office_worker\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"office worker\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍💼\",\n    aliases: [\"man_office_worker\"],\n    tags: [\"business\"],\n    category: \"People & Body\",\n    description: \"man office worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍💼\",\n    aliases: [\"woman_office_worker\"],\n    tags: [\"business\"],\n    category: \"People & Body\",\n    description: \"woman office worker\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🔬\",\n    aliases: [\"scientist\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"scientist\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🔬\",\n    aliases: [\"man_scientist\"],\n    tags: [\"research\"],\n    category: \"People & Body\",\n    description: \"man scientist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🔬\",\n    aliases: [\"woman_scientist\"],\n    tags: [\"research\"],\n    category: \"People & Body\",\n    description: \"woman scientist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍💻\",\n    aliases: [\"technologist\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"technologist\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍💻\",\n    aliases: [\"man_technologist\"],\n    tags: [\"coder\"],\n    category: \"People & Body\",\n    description: \"man technologist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍💻\",\n    aliases: [\"woman_technologist\"],\n    tags: [\"coder\"],\n    category: \"People & Body\",\n    description: \"woman technologist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🎤\",\n    aliases: [\"singer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"singer\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🎤\",\n    aliases: [\"man_singer\"],\n    tags: [\"rockstar\"],\n    category: \"People & Body\",\n    description: \"man singer\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🎤\",\n    aliases: [\"woman_singer\"],\n    tags: [\"rockstar\"],\n    category: \"People & Body\",\n    description: \"woman singer\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🎨\",\n    aliases: [\"artist\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"artist\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🎨\",\n    aliases: [\"man_artist\"],\n    tags: [\"painter\"],\n    category: \"People & Body\",\n    description: \"man artist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🎨\",\n    aliases: [\"woman_artist\"],\n    tags: [\"painter\"],\n    category: \"People & Body\",\n    description: \"woman artist\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍✈️\",\n    aliases: [\"pilot\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"pilot\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍✈️\",\n    aliases: [\"man_pilot\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man pilot\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍✈️\",\n    aliases: [\"woman_pilot\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman pilot\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🚀\",\n    aliases: [\"astronaut\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"astronaut\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🚀\",\n    aliases: [\"man_astronaut\"],\n    tags: [\"space\"],\n    category: \"People & Body\",\n    description: \"man astronaut\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🚀\",\n    aliases: [\"woman_astronaut\"],\n    tags: [\"space\"],\n    category: \"People & Body\",\n    description: \"woman astronaut\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🧑‍🚒\",\n    aliases: [\"firefighter\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"firefighter\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🚒\",\n    aliases: [\"man_firefighter\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man firefighter\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👩‍🚒\",\n    aliases: [\"woman_firefighter\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman firefighter\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"👮\",\n    aliases: [\"police_officer\", \"cop\"],\n    tags: [\"law\"],\n    category: \"People & Body\",\n    description: \"police officer\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👮‍♂️\",\n    aliases: [\"policeman\"],\n    tags: [\"law\", \"cop\"],\n    category: \"People & Body\",\n    description: \"man police officer\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👮‍♀️\",\n    aliases: [\"policewoman\"],\n    tags: [\"law\", \"cop\"],\n    category: \"People & Body\",\n    description: \"woman police officer\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕵️\",\n    aliases: [\"detective\"],\n    tags: [\"sleuth\"],\n    category: \"People & Body\",\n    description: \"detective\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🕵️‍♂️\",\n    aliases: [\"male_detective\"],\n    tags: [\"sleuth\"],\n    category: \"People & Body\",\n    description: \"man detective\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🕵️‍♀️\",\n    aliases: [\"female_detective\"],\n    tags: [\"sleuth\"],\n    category: \"People & Body\",\n    description: \"woman detective\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💂\",\n    aliases: [\"guard\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"guard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💂‍♂️\",\n    aliases: [\"guardsman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man guard\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💂‍♀️\",\n    aliases: [\"guardswoman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman guard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥷\",\n    aliases: [\"ninja\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"ninja\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👷\",\n    aliases: [\"construction_worker\"],\n    tags: [\"helmet\"],\n    category: \"People & Body\",\n    description: \"construction worker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👷‍♂️\",\n    aliases: [\"construction_worker_man\"],\n    tags: [\"helmet\"],\n    category: \"People & Body\",\n    description: \"man construction worker\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👷‍♀️\",\n    aliases: [\"construction_worker_woman\"],\n    tags: [\"helmet\"],\n    category: \"People & Body\",\n    description: \"woman construction worker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤴\",\n    aliases: [\"prince\"],\n    tags: [\"crown\", \"royal\"],\n    category: \"People & Body\",\n    description: \"prince\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"👸\",\n    aliases: [\"princess\"],\n    tags: [\"crown\", \"royal\"],\n    category: \"People & Body\",\n    description: \"princess\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👳\",\n    aliases: [\"person_with_turban\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person wearing turban\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👳‍♂️\",\n    aliases: [\"man_with_turban\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man wearing turban\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👳‍♀️\",\n    aliases: [\"woman_with_turban\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman wearing turban\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👲\",\n    aliases: [\"man_with_gua_pi_mao\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person with skullcap\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧕\",\n    aliases: [\"woman_with_headscarf\"],\n    tags: [\"hijab\"],\n    category: \"People & Body\",\n    description: \"woman with headscarf\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤵\",\n    aliases: [\"person_in_tuxedo\"],\n    tags: [\"groom\", \"marriage\", \"wedding\"],\n    category: \"People & Body\",\n    description: \"person in tuxedo\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤵‍♂️\",\n    aliases: [\"man_in_tuxedo\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man in tuxedo\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🤵‍♀️\",\n    aliases: [\"woman_in_tuxedo\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman in tuxedo\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👰\",\n    aliases: [\"person_with_veil\"],\n    tags: [\"marriage\", \"wedding\"],\n    category: \"People & Body\",\n    description: \"person with veil\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👰‍♂️\",\n    aliases: [\"man_with_veil\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man with veil\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👰‍♀️\",\n    aliases: [\"woman_with_veil\", \"bride_with_veil\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman with veil\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🤰\",\n    aliases: [\"pregnant_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"pregnant woman\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤱\",\n    aliases: [\"breast_feeding\"],\n    tags: [\"nursing\"],\n    category: \"People & Body\",\n    description: \"breast-feeding\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👩‍🍼\",\n    aliases: [\"woman_feeding_baby\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman feeding baby\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👨‍🍼\",\n    aliases: [\"man_feeding_baby\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man feeding baby\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧑‍🍼\",\n    aliases: [\"person_feeding_baby\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person feeding baby\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👼\",\n    aliases: [\"angel\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"baby angel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎅\",\n    aliases: [\"santa\"],\n    tags: [\"christmas\"],\n    category: \"People & Body\",\n    description: \"Santa Claus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤶\",\n    aliases: [\"mrs_claus\"],\n    tags: [\"santa\"],\n    category: \"People & Body\",\n    description: \"Mrs. Claus\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🧑‍🎄\",\n    aliases: [\"mx_claus\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"mx claus\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦸\",\n    aliases: [\"superhero\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"superhero\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦸‍♂️\",\n    aliases: [\"superhero_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man superhero\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦸‍♀️\",\n    aliases: [\"superhero_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman superhero\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦹\",\n    aliases: [\"supervillain\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"supervillain\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦹‍♂️\",\n    aliases: [\"supervillain_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man supervillain\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦹‍♀️\",\n    aliases: [\"supervillain_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman supervillain\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧙\",\n    aliases: [\"mage\"],\n    tags: [\"wizard\"],\n    category: \"People & Body\",\n    description: \"mage\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧙‍♂️\",\n    aliases: [\"mage_man\"],\n    tags: [\"wizard\"],\n    category: \"People & Body\",\n    description: \"man mage\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧙‍♀️\",\n    aliases: [\"mage_woman\"],\n    tags: [\"wizard\"],\n    category: \"People & Body\",\n    description: \"woman mage\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧚\",\n    aliases: [\"fairy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"fairy\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧚‍♂️\",\n    aliases: [\"fairy_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man fairy\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧚‍♀️\",\n    aliases: [\"fairy_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman fairy\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧛\",\n    aliases: [\"vampire\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"vampire\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧛‍♂️\",\n    aliases: [\"vampire_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man vampire\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧛‍♀️\",\n    aliases: [\"vampire_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman vampire\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧜\",\n    aliases: [\"merperson\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"merperson\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧜‍♂️\",\n    aliases: [\"merman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"merman\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧜‍♀️\",\n    aliases: [\"mermaid\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"mermaid\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧝\",\n    aliases: [\"elf\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"elf\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧝‍♂️\",\n    aliases: [\"elf_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man elf\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧝‍♀️\",\n    aliases: [\"elf_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman elf\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧞\",\n    aliases: [\"genie\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"genie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧞‍♂️\",\n    aliases: [\"genie_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man genie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧞‍♀️\",\n    aliases: [\"genie_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman genie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧟\",\n    aliases: [\"zombie\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"zombie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧟‍♂️\",\n    aliases: [\"zombie_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man zombie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧟‍♀️\",\n    aliases: [\"zombie_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman zombie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💆\",\n    aliases: [\"massage\"],\n    tags: [\"spa\"],\n    category: \"People & Body\",\n    description: \"person getting massage\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💆‍♂️\",\n    aliases: [\"massage_man\"],\n    tags: [\"spa\"],\n    category: \"People & Body\",\n    description: \"man getting massage\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💆‍♀️\",\n    aliases: [\"massage_woman\"],\n    tags: [\"spa\"],\n    category: \"People & Body\",\n    description: \"woman getting massage\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💇\",\n    aliases: [\"haircut\"],\n    tags: [\"beauty\"],\n    category: \"People & Body\",\n    description: \"person getting haircut\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💇‍♂️\",\n    aliases: [\"haircut_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man getting haircut\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💇‍♀️\",\n    aliases: [\"haircut_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman getting haircut\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🚶\",\n    aliases: [\"walking\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person walking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚶‍♂️\",\n    aliases: [\"walking_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man walking\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🚶‍♀️\",\n    aliases: [\"walking_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman walking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧍\",\n    aliases: [\"standing_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person standing\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧍‍♂️\",\n    aliases: [\"standing_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man standing\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧍‍♀️\",\n    aliases: [\"standing_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman standing\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧎\",\n    aliases: [\"kneeling_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person kneeling\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧎‍♂️\",\n    aliases: [\"kneeling_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man kneeling\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧎‍♀️\",\n    aliases: [\"kneeling_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman kneeling\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧑‍🦯\",\n    aliases: [\"person_with_probing_cane\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person with white cane\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🦯\",\n    aliases: [\"man_with_probing_cane\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man with white cane\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👩‍🦯\",\n    aliases: [\"woman_with_probing_cane\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman with white cane\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧑‍🦼\",\n    aliases: [\"person_in_motorized_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person in motorized wheelchair\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🦼\",\n    aliases: [\"man_in_motorized_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man in motorized wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👩‍🦼\",\n    aliases: [\"woman_in_motorized_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman in motorized wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧑‍🦽\",\n    aliases: [\"person_in_manual_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person in manual wheelchair\",\n    unicode_version: \"12.1\",\n  },\n  {\n    emoji: \"👨‍🦽\",\n    aliases: [\"man_in_manual_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man in manual wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👩‍🦽\",\n    aliases: [\"woman_in_manual_wheelchair\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman in manual wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🏃\",\n    aliases: [\"runner\", \"running\"],\n    tags: [\"exercise\", \"workout\", \"marathon\"],\n    category: \"People & Body\",\n    description: \"person running\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏃‍♂️\",\n    aliases: [\"running_man\"],\n    tags: [\"exercise\", \"workout\", \"marathon\"],\n    category: \"People & Body\",\n    description: \"man running\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏃‍♀️\",\n    aliases: [\"running_woman\"],\n    tags: [\"exercise\", \"workout\", \"marathon\"],\n    category: \"People & Body\",\n    description: \"woman running\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💃\",\n    aliases: [\"woman_dancing\", \"dancer\"],\n    tags: [\"dress\"],\n    category: \"People & Body\",\n    description: \"woman dancing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕺\",\n    aliases: [\"man_dancing\"],\n    tags: [\"dancer\"],\n    category: \"People & Body\",\n    description: \"man dancing\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🕴️\",\n    aliases: [\"business_suit_levitating\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person in suit levitating\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"👯\",\n    aliases: [\"dancers\"],\n    tags: [\"bunny\"],\n    category: \"People & Body\",\n    description: \"people with bunny ears\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👯‍♂️\",\n    aliases: [\"dancing_men\"],\n    tags: [\"bunny\"],\n    category: \"People & Body\",\n    description: \"men with bunny ears\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👯‍♀️\",\n    aliases: [\"dancing_women\"],\n    tags: [\"bunny\"],\n    category: \"People & Body\",\n    description: \"women with bunny ears\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧖\",\n    aliases: [\"sauna_person\"],\n    tags: [\"steamy\"],\n    category: \"People & Body\",\n    description: \"person in steamy room\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧖‍♂️\",\n    aliases: [\"sauna_man\"],\n    tags: [\"steamy\"],\n    category: \"People & Body\",\n    description: \"man in steamy room\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧖‍♀️\",\n    aliases: [\"sauna_woman\"],\n    tags: [\"steamy\"],\n    category: \"People & Body\",\n    description: \"woman in steamy room\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧗\",\n    aliases: [\"climbing\"],\n    tags: [\"bouldering\"],\n    category: \"People & Body\",\n    description: \"person climbing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧗‍♂️\",\n    aliases: [\"climbing_man\"],\n    tags: [\"bouldering\"],\n    category: \"People & Body\",\n    description: \"man climbing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧗‍♀️\",\n    aliases: [\"climbing_woman\"],\n    tags: [\"bouldering\"],\n    category: \"People & Body\",\n    description: \"woman climbing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤺\",\n    aliases: [\"person_fencing\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person fencing\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🏇\",\n    aliases: [\"horse_racing\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"horse racing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⛷️\",\n    aliases: [\"skier\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"skier\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🏂\",\n    aliases: [\"snowboarder\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"snowboarder\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏌️\",\n    aliases: [\"golfing\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person golfing\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏌️‍♂️\",\n    aliases: [\"golfing_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man golfing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏌️‍♀️\",\n    aliases: [\"golfing_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman golfing\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🏄\",\n    aliases: [\"surfer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person surfing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏄‍♂️\",\n    aliases: [\"surfing_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man surfing\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏄‍♀️\",\n    aliases: [\"surfing_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman surfing\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🚣\",\n    aliases: [\"rowboat\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person rowing boat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚣‍♂️\",\n    aliases: [\"rowing_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man rowing boat\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🚣‍♀️\",\n    aliases: [\"rowing_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman rowing boat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏊\",\n    aliases: [\"swimmer\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person swimming\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏊‍♂️\",\n    aliases: [\"swimming_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man swimming\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏊‍♀️\",\n    aliases: [\"swimming_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman swimming\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⛹️\",\n    aliases: [\"bouncing_ball_person\"],\n    tags: [\"basketball\"],\n    category: \"People & Body\",\n    description: \"person bouncing ball\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⛹️‍♂️\",\n    aliases: [\"bouncing_ball_man\", \"basketball_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man bouncing ball\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"⛹️‍♀️\",\n    aliases: [\"bouncing_ball_woman\", \"basketball_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman bouncing ball\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏋️\",\n    aliases: [\"weight_lifting\"],\n    tags: [\"gym\", \"workout\"],\n    category: \"People & Body\",\n    description: \"person lifting weights\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏋️‍♂️\",\n    aliases: [\"weight_lifting_man\"],\n    tags: [\"gym\", \"workout\"],\n    category: \"People & Body\",\n    description: \"man lifting weights\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏋️‍♀️\",\n    aliases: [\"weight_lifting_woman\"],\n    tags: [\"gym\", \"workout\"],\n    category: \"People & Body\",\n    description: \"woman lifting weights\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚴\",\n    aliases: [\"bicyclist\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person biking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚴‍♂️\",\n    aliases: [\"biking_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man biking\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🚴‍♀️\",\n    aliases: [\"biking_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman biking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚵\",\n    aliases: [\"mountain_bicyclist\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person mountain biking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚵‍♂️\",\n    aliases: [\"mountain_biking_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man mountain biking\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🚵‍♀️\",\n    aliases: [\"mountain_biking_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman mountain biking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤸\",\n    aliases: [\"cartwheeling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person cartwheeling\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤸‍♂️\",\n    aliases: [\"man_cartwheeling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man cartwheeling\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🤸‍♀️\",\n    aliases: [\"woman_cartwheeling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman cartwheeling\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🤼\",\n    aliases: [\"wrestling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"people wrestling\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤼‍♂️\",\n    aliases: [\"men_wrestling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"men wrestling\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤼‍♀️\",\n    aliases: [\"women_wrestling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"women wrestling\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤽\",\n    aliases: [\"water_polo\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person playing water polo\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤽‍♂️\",\n    aliases: [\"man_playing_water_polo\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man playing water polo\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤽‍♀️\",\n    aliases: [\"woman_playing_water_polo\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman playing water polo\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤾\",\n    aliases: [\"handball_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person playing handball\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤾‍♂️\",\n    aliases: [\"man_playing_handball\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man playing handball\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤾‍♀️\",\n    aliases: [\"woman_playing_handball\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman playing handball\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤹\",\n    aliases: [\"juggling_person\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person juggling\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🤹‍♂️\",\n    aliases: [\"man_juggling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"man juggling\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🤹‍♀️\",\n    aliases: [\"woman_juggling\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"woman juggling\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🧘\",\n    aliases: [\"lotus_position\"],\n    tags: [\"meditation\"],\n    category: \"People & Body\",\n    description: \"person in lotus position\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧘‍♂️\",\n    aliases: [\"lotus_position_man\"],\n    tags: [\"meditation\"],\n    category: \"People & Body\",\n    description: \"man in lotus position\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧘‍♀️\",\n    aliases: [\"lotus_position_woman\"],\n    tags: [\"meditation\"],\n    category: \"People & Body\",\n    description: \"woman in lotus position\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🛀\",\n    aliases: [\"bath\"],\n    tags: [\"shower\"],\n    category: \"People & Body\",\n    description: \"person taking bath\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛌\",\n    aliases: [\"sleeping_bed\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"person in bed\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🧑‍🤝‍🧑\",\n    aliases: [\"people_holding_hands\"],\n    tags: [\"couple\", \"date\"],\n    category: \"People & Body\",\n    description: \"people holding hands\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👭\",\n    aliases: [\"two_women_holding_hands\"],\n    tags: [\"couple\", \"date\"],\n    category: \"People & Body\",\n    description: \"women holding hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👫\",\n    aliases: [\"couple\"],\n    tags: [\"date\"],\n    category: \"People & Body\",\n    description: \"woman and man holding hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👬\",\n    aliases: [\"two_men_holding_hands\"],\n    tags: [\"couple\", \"date\"],\n    category: \"People & Body\",\n    description: \"men holding hands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💏\",\n    aliases: [\"couplekiss\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"kiss\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍❤️‍💋‍👨\",\n    aliases: [\"couplekiss_man_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"kiss: woman, man\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍❤️‍💋‍👨\",\n    aliases: [\"couplekiss_man_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"kiss: man, man\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍❤️‍💋‍👩\",\n    aliases: [\"couplekiss_woman_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"kiss: woman, woman\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💑\",\n    aliases: [\"couple_with_heart\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"couple with heart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍❤️‍👨\",\n    aliases: [\"couple_with_heart_woman_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"couple with heart: woman, man\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍❤️‍👨\",\n    aliases: [\"couple_with_heart_man_man\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"couple with heart: man, man\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍❤️‍👩\",\n    aliases: [\"couple_with_heart_woman_woman\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"couple with heart: woman, woman\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👪\",\n    aliases: [\"family\"],\n    tags: [\"home\", \"parents\", \"child\"],\n    category: \"People & Body\",\n    description: \"family\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👩‍👦\",\n    aliases: [\"family_man_woman_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, woman, boy\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👨‍👩‍👧\",\n    aliases: [\"family_man_woman_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, woman, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👩‍👧‍👦\",\n    aliases: [\"family_man_woman_girl_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, woman, girl, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👩‍👦‍👦\",\n    aliases: [\"family_man_woman_boy_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, woman, boy, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👩‍👧‍👧\",\n    aliases: [\"family_man_woman_girl_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, woman, girl, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👨‍👦\",\n    aliases: [\"family_man_man_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, man, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👨‍👧\",\n    aliases: [\"family_man_man_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, man, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👨‍👧‍👦\",\n    aliases: [\"family_man_man_girl_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, man, girl, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👨‍👦‍👦\",\n    aliases: [\"family_man_man_boy_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, man, boy, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👨‍👧‍👧\",\n    aliases: [\"family_man_man_girl_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, man, girl, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👩‍👦\",\n    aliases: [\"family_woman_woman_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, woman, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👩‍👧\",\n    aliases: [\"family_woman_woman_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, woman, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👩‍👧‍👦\",\n    aliases: [\"family_woman_woman_girl_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, woman, girl, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👩‍👦‍👦\",\n    aliases: [\"family_woman_woman_boy_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, woman, boy, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👩‍👧‍👧\",\n    aliases: [\"family_woman_woman_girl_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, woman, girl, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👦\",\n    aliases: [\"family_man_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👦‍👦\",\n    aliases: [\"family_man_boy_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, boy, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👧\",\n    aliases: [\"family_man_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👧‍👦\",\n    aliases: [\"family_man_girl_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, girl, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👨‍👧‍👧\",\n    aliases: [\"family_man_girl_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: man, girl, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👦\",\n    aliases: [\"family_woman_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👦‍👦\",\n    aliases: [\"family_woman_boy_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, boy, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👧\",\n    aliases: [\"family_woman_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👧‍👦\",\n    aliases: [\"family_woman_girl_boy\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, girl, boy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👩‍👧‍👧\",\n    aliases: [\"family_woman_girl_girl\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"family: woman, girl, girl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗣️\",\n    aliases: [\"speaking_head\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"speaking head\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"👤\",\n    aliases: [\"bust_in_silhouette\"],\n    tags: [\"user\"],\n    category: \"People & Body\",\n    description: \"bust in silhouette\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👥\",\n    aliases: [\"busts_in_silhouette\"],\n    tags: [\"users\", \"group\", \"team\"],\n    category: \"People & Body\",\n    description: \"busts in silhouette\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🫂\",\n    aliases: [\"people_hugging\"],\n    tags: [],\n    category: \"People & Body\",\n    description: \"people hugging\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👣\",\n    aliases: [\"footprints\"],\n    tags: [\"feet\", \"tracks\"],\n    category: \"People & Body\",\n    description: \"footprints\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐵\",\n    aliases: [\"monkey_face\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"monkey face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐒\",\n    aliases: [\"monkey\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"monkey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦍\",\n    aliases: [\"gorilla\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"gorilla\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦧\",\n    aliases: [\"orangutan\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"orangutan\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🐶\",\n    aliases: [\"dog\"],\n    tags: [\"pet\"],\n    category: \"Animals & Nature\",\n    description: \"dog face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐕\",\n    aliases: [\"dog2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"dog\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦮\",\n    aliases: [\"guide_dog\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"guide dog\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🐕‍🦺\",\n    aliases: [\"service_dog\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"service dog\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🐩\",\n    aliases: [\"poodle\"],\n    tags: [\"dog\"],\n    category: \"Animals & Nature\",\n    description: \"poodle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐺\",\n    aliases: [\"wolf\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"wolf\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦊\",\n    aliases: [\"fox_face\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"fox\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦝\",\n    aliases: [\"raccoon\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"raccoon\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐱\",\n    aliases: [\"cat\"],\n    tags: [\"pet\"],\n    category: \"Animals & Nature\",\n    description: \"cat face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐈\",\n    aliases: [\"cat2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐈‍⬛\",\n    aliases: [\"black_cat\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"black cat\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦁\",\n    aliases: [\"lion\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"lion\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🐯\",\n    aliases: [\"tiger\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"tiger face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐅\",\n    aliases: [\"tiger2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"tiger\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐆\",\n    aliases: [\"leopard\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"leopard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐴\",\n    aliases: [\"horse\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"horse face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐎\",\n    aliases: [\"racehorse\"],\n    tags: [\"speed\"],\n    category: \"Animals & Nature\",\n    description: \"horse\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦄\",\n    aliases: [\"unicorn\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"unicorn\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🦓\",\n    aliases: [\"zebra\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"zebra\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦌\",\n    aliases: [\"deer\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"deer\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦬\",\n    aliases: [\"bison\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"bison\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🐮\",\n    aliases: [\"cow\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cow face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐂\",\n    aliases: [\"ox\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"ox\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐃\",\n    aliases: [\"water_buffalo\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"water buffalo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐄\",\n    aliases: [\"cow2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐷\",\n    aliases: [\"pig\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"pig face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐖\",\n    aliases: [\"pig2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"pig\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐗\",\n    aliases: [\"boar\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"boar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐽\",\n    aliases: [\"pig_nose\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"pig nose\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐏\",\n    aliases: [\"ram\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"ram\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐑\",\n    aliases: [\"sheep\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"ewe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐐\",\n    aliases: [\"goat\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"goat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐪\",\n    aliases: [\"dromedary_camel\"],\n    tags: [\"desert\"],\n    category: \"Animals & Nature\",\n    description: \"camel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐫\",\n    aliases: [\"camel\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"two-hump camel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦙\",\n    aliases: [\"llama\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"llama\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦒\",\n    aliases: [\"giraffe\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"giraffe\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐘\",\n    aliases: [\"elephant\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"elephant\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦣\",\n    aliases: [\"mammoth\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"mammoth\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦏\",\n    aliases: [\"rhinoceros\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"rhinoceros\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦛\",\n    aliases: [\"hippopotamus\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"hippopotamus\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐭\",\n    aliases: [\"mouse\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"mouse face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐁\",\n    aliases: [\"mouse2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"mouse\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐀\",\n    aliases: [\"rat\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"rat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐹\",\n    aliases: [\"hamster\"],\n    tags: [\"pet\"],\n    category: \"Animals & Nature\",\n    description: \"hamster\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐰\",\n    aliases: [\"rabbit\"],\n    tags: [\"bunny\"],\n    category: \"Animals & Nature\",\n    description: \"rabbit face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐇\",\n    aliases: [\"rabbit2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"rabbit\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐿️\",\n    aliases: [\"chipmunk\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"chipmunk\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🦫\",\n    aliases: [\"beaver\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"beaver\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦔\",\n    aliases: [\"hedgehog\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"hedgehog\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦇\",\n    aliases: [\"bat\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"bat\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🐻\",\n    aliases: [\"bear\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"bear\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐻‍❄️\",\n    aliases: [\"polar_bear\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"polar bear\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🐨\",\n    aliases: [\"koala\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"koala\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐼\",\n    aliases: [\"panda_face\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"panda\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦥\",\n    aliases: [\"sloth\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"sloth\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦦\",\n    aliases: [\"otter\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"otter\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦨\",\n    aliases: [\"skunk\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"skunk\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦘\",\n    aliases: [\"kangaroo\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"kangaroo\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦡\",\n    aliases: [\"badger\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"badger\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐾\",\n    aliases: [\"feet\", \"paw_prints\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"paw prints\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦃\",\n    aliases: [\"turkey\"],\n    tags: [\"thanksgiving\"],\n    category: \"Animals & Nature\",\n    description: \"turkey\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🐔\",\n    aliases: [\"chicken\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"chicken\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐓\",\n    aliases: [\"rooster\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"rooster\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐣\",\n    aliases: [\"hatching_chick\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"hatching chick\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐤\",\n    aliases: [\"baby_chick\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"baby chick\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐥\",\n    aliases: [\"hatched_chick\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"front-facing baby chick\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐦\",\n    aliases: [\"bird\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"bird\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐧\",\n    aliases: [\"penguin\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"penguin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕊️\",\n    aliases: [\"dove\"],\n    tags: [\"peace\"],\n    category: \"Animals & Nature\",\n    description: \"dove\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🦅\",\n    aliases: [\"eagle\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"eagle\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦆\",\n    aliases: [\"duck\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"duck\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦢\",\n    aliases: [\"swan\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"swan\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦉\",\n    aliases: [\"owl\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"owl\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦤\",\n    aliases: [\"dodo\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"dodo\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪶\",\n    aliases: [\"feather\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"feather\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦩\",\n    aliases: [\"flamingo\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"flamingo\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦚\",\n    aliases: [\"peacock\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"peacock\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦜\",\n    aliases: [\"parrot\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"parrot\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐸\",\n    aliases: [\"frog\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"frog\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐊\",\n    aliases: [\"crocodile\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"crocodile\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐢\",\n    aliases: [\"turtle\"],\n    tags: [\"slow\"],\n    category: \"Animals & Nature\",\n    description: \"turtle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦎\",\n    aliases: [\"lizard\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"lizard\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🐍\",\n    aliases: [\"snake\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"snake\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐲\",\n    aliases: [\"dragon_face\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"dragon face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐉\",\n    aliases: [\"dragon\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"dragon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦕\",\n    aliases: [\"sauropod\"],\n    tags: [\"dinosaur\"],\n    category: \"Animals & Nature\",\n    description: \"sauropod\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦖\",\n    aliases: [\"t-rex\"],\n    tags: [\"dinosaur\"],\n    category: \"Animals & Nature\",\n    description: \"T-Rex\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🐳\",\n    aliases: [\"whale\"],\n    tags: [\"sea\"],\n    category: \"Animals & Nature\",\n    description: \"spouting whale\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐋\",\n    aliases: [\"whale2\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"whale\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐬\",\n    aliases: [\"dolphin\", \"flipper\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"dolphin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦭\",\n    aliases: [\"seal\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"seal\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🐟\",\n    aliases: [\"fish\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"fish\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐠\",\n    aliases: [\"tropical_fish\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"tropical fish\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐡\",\n    aliases: [\"blowfish\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"blowfish\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦈\",\n    aliases: [\"shark\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"shark\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🐙\",\n    aliases: [\"octopus\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"octopus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐚\",\n    aliases: [\"shell\"],\n    tags: [\"sea\", \"beach\"],\n    category: \"Animals & Nature\",\n    description: \"spiral shell\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐌\",\n    aliases: [\"snail\"],\n    tags: [\"slow\"],\n    category: \"Animals & Nature\",\n    description: \"snail\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦋\",\n    aliases: [\"butterfly\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"butterfly\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🐛\",\n    aliases: [\"bug\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"bug\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐜\",\n    aliases: [\"ant\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"ant\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🐝\",\n    aliases: [\"bee\", \"honeybee\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"honeybee\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪲\",\n    aliases: [\"beetle\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"beetle\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🐞\",\n    aliases: [\"lady_beetle\"],\n    tags: [\"bug\"],\n    category: \"Animals & Nature\",\n    description: \"lady beetle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🦗\",\n    aliases: [\"cricket\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cricket\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪳\",\n    aliases: [\"cockroach\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cockroach\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🕷️\",\n    aliases: [\"spider\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"spider\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🕸️\",\n    aliases: [\"spider_web\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"spider web\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🦂\",\n    aliases: [\"scorpion\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"scorpion\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🦟\",\n    aliases: [\"mosquito\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"mosquito\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪰\",\n    aliases: [\"fly\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"fly\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪱\",\n    aliases: [\"worm\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"worm\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🦠\",\n    aliases: [\"microbe\"],\n    tags: [\"germ\"],\n    category: \"Animals & Nature\",\n    description: \"microbe\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💐\",\n    aliases: [\"bouquet\"],\n    tags: [\"flowers\"],\n    category: \"Animals & Nature\",\n    description: \"bouquet\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌸\",\n    aliases: [\"cherry_blossom\"],\n    tags: [\"flower\", \"spring\"],\n    category: \"Animals & Nature\",\n    description: \"cherry blossom\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💮\",\n    aliases: [\"white_flower\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"white flower\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏵️\",\n    aliases: [\"rosette\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"rosette\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌹\",\n    aliases: [\"rose\"],\n    tags: [\"flower\"],\n    category: \"Animals & Nature\",\n    description: \"rose\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥀\",\n    aliases: [\"wilted_flower\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"wilted flower\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🌺\",\n    aliases: [\"hibiscus\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"hibiscus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌻\",\n    aliases: [\"sunflower\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"sunflower\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌼\",\n    aliases: [\"blossom\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"blossom\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌷\",\n    aliases: [\"tulip\"],\n    tags: [\"flower\"],\n    category: \"Animals & Nature\",\n    description: \"tulip\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌱\",\n    aliases: [\"seedling\"],\n    tags: [\"plant\"],\n    category: \"Animals & Nature\",\n    description: \"seedling\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪴\",\n    aliases: [\"potted_plant\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"potted plant\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🌲\",\n    aliases: [\"evergreen_tree\"],\n    tags: [\"wood\"],\n    category: \"Animals & Nature\",\n    description: \"evergreen tree\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌳\",\n    aliases: [\"deciduous_tree\"],\n    tags: [\"wood\"],\n    category: \"Animals & Nature\",\n    description: \"deciduous tree\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌴\",\n    aliases: [\"palm_tree\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"palm tree\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌵\",\n    aliases: [\"cactus\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"cactus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌾\",\n    aliases: [\"ear_of_rice\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"sheaf of rice\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌿\",\n    aliases: [\"herb\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"herb\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☘️\",\n    aliases: [\"shamrock\"],\n    tags: [],\n    category: \"Animals & Nature\",\n    description: \"shamrock\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🍀\",\n    aliases: [\"four_leaf_clover\"],\n    tags: [\"luck\"],\n    category: \"Animals & Nature\",\n    description: \"four leaf clover\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍁\",\n    aliases: [\"maple_leaf\"],\n    tags: [\"canada\"],\n    category: \"Animals & Nature\",\n    description: \"maple leaf\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍂\",\n    aliases: [\"fallen_leaf\"],\n    tags: [\"autumn\"],\n    category: \"Animals & Nature\",\n    description: \"fallen leaf\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍃\",\n    aliases: [\"leaves\"],\n    tags: [\"leaf\"],\n    category: \"Animals & Nature\",\n    description: \"leaf fluttering in wind\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍇\",\n    aliases: [\"grapes\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"grapes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍈\",\n    aliases: [\"melon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"melon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍉\",\n    aliases: [\"watermelon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"watermelon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍊\",\n    aliases: [\"tangerine\", \"orange\", \"mandarin\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"tangerine\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍋\",\n    aliases: [\"lemon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"lemon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍌\",\n    aliases: [\"banana\"],\n    tags: [\"fruit\"],\n    category: \"Food & Drink\",\n    description: \"banana\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍍\",\n    aliases: [\"pineapple\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pineapple\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥭\",\n    aliases: [\"mango\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"mango\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🍎\",\n    aliases: [\"apple\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"red apple\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍏\",\n    aliases: [\"green_apple\"],\n    tags: [\"fruit\"],\n    category: \"Food & Drink\",\n    description: \"green apple\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍐\",\n    aliases: [\"pear\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pear\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍑\",\n    aliases: [\"peach\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"peach\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍒\",\n    aliases: [\"cherries\"],\n    tags: [\"fruit\"],\n    category: \"Food & Drink\",\n    description: \"cherries\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍓\",\n    aliases: [\"strawberry\"],\n    tags: [\"fruit\"],\n    category: \"Food & Drink\",\n    description: \"strawberry\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🫐\",\n    aliases: [\"blueberries\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"blueberries\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥝\",\n    aliases: [\"kiwi_fruit\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"kiwi fruit\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🍅\",\n    aliases: [\"tomato\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"tomato\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🫒\",\n    aliases: [\"olive\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"olive\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥥\",\n    aliases: [\"coconut\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"coconut\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥑\",\n    aliases: [\"avocado\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"avocado\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🍆\",\n    aliases: [\"eggplant\"],\n    tags: [\"aubergine\"],\n    category: \"Food & Drink\",\n    description: \"eggplant\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥔\",\n    aliases: [\"potato\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"potato\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥕\",\n    aliases: [\"carrot\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"carrot\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🌽\",\n    aliases: [\"corn\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"ear of corn\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌶️\",\n    aliases: [\"hot_pepper\"],\n    tags: [\"spicy\"],\n    category: \"Food & Drink\",\n    description: \"hot pepper\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🫑\",\n    aliases: [\"bell_pepper\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bell pepper\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥒\",\n    aliases: [\"cucumber\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cucumber\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥬\",\n    aliases: [\"leafy_green\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"leafy green\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥦\",\n    aliases: [\"broccoli\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"broccoli\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧄\",\n    aliases: [\"garlic\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"garlic\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧅\",\n    aliases: [\"onion\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"onion\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🍄\",\n    aliases: [\"mushroom\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"mushroom\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥜\",\n    aliases: [\"peanuts\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"peanuts\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🌰\",\n    aliases: [\"chestnut\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"chestnut\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍞\",\n    aliases: [\"bread\"],\n    tags: [\"toast\"],\n    category: \"Food & Drink\",\n    description: \"bread\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥐\",\n    aliases: [\"croissant\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"croissant\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥖\",\n    aliases: [\"baguette_bread\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"baguette bread\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🫓\",\n    aliases: [\"flatbread\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"flatbread\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥨\",\n    aliases: [\"pretzel\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pretzel\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥯\",\n    aliases: [\"bagel\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bagel\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥞\",\n    aliases: [\"pancakes\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pancakes\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🧇\",\n    aliases: [\"waffle\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"waffle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧀\",\n    aliases: [\"cheese\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cheese wedge\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🍖\",\n    aliases: [\"meat_on_bone\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"meat on bone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍗\",\n    aliases: [\"poultry_leg\"],\n    tags: [\"meat\", \"chicken\"],\n    category: \"Food & Drink\",\n    description: \"poultry leg\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥩\",\n    aliases: [\"cut_of_meat\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cut of meat\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥓\",\n    aliases: [\"bacon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bacon\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🍔\",\n    aliases: [\"hamburger\"],\n    tags: [\"burger\"],\n    category: \"Food & Drink\",\n    description: \"hamburger\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍟\",\n    aliases: [\"fries\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"french fries\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍕\",\n    aliases: [\"pizza\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pizza\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌭\",\n    aliases: [\"hotdog\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"hot dog\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🥪\",\n    aliases: [\"sandwich\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"sandwich\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🌮\",\n    aliases: [\"taco\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"taco\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🌯\",\n    aliases: [\"burrito\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"burrito\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🫔\",\n    aliases: [\"tamale\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"tamale\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥙\",\n    aliases: [\"stuffed_flatbread\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"stuffed flatbread\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🧆\",\n    aliases: [\"falafel\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"falafel\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🥚\",\n    aliases: [\"egg\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"egg\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🍳\",\n    aliases: [\"fried_egg\"],\n    tags: [\"breakfast\"],\n    category: \"Food & Drink\",\n    description: \"cooking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥘\",\n    aliases: [\"shallow_pan_of_food\"],\n    tags: [\"paella\", \"curry\"],\n    category: \"Food & Drink\",\n    description: \"shallow pan of food\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🍲\",\n    aliases: [\"stew\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pot of food\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🫕\",\n    aliases: [\"fondue\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"fondue\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🥣\",\n    aliases: [\"bowl_with_spoon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bowl with spoon\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥗\",\n    aliases: [\"green_salad\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"green salad\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🍿\",\n    aliases: [\"popcorn\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"popcorn\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🧈\",\n    aliases: [\"butter\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"butter\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧂\",\n    aliases: [\"salt\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"salt\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥫\",\n    aliases: [\"canned_food\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"canned food\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🍱\",\n    aliases: [\"bento\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bento box\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍘\",\n    aliases: [\"rice_cracker\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"rice cracker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍙\",\n    aliases: [\"rice_ball\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"rice ball\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍚\",\n    aliases: [\"rice\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cooked rice\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍛\",\n    aliases: [\"curry\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"curry rice\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍜\",\n    aliases: [\"ramen\"],\n    tags: [\"noodle\"],\n    category: \"Food & Drink\",\n    description: \"steaming bowl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍝\",\n    aliases: [\"spaghetti\"],\n    tags: [\"pasta\"],\n    category: \"Food & Drink\",\n    description: \"spaghetti\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍠\",\n    aliases: [\"sweet_potato\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"roasted sweet potato\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍢\",\n    aliases: [\"oden\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"oden\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍣\",\n    aliases: [\"sushi\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"sushi\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍤\",\n    aliases: [\"fried_shrimp\"],\n    tags: [\"tempura\"],\n    category: \"Food & Drink\",\n    description: \"fried shrimp\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍥\",\n    aliases: [\"fish_cake\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"fish cake with swirl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥮\",\n    aliases: [\"moon_cake\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"moon cake\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🍡\",\n    aliases: [\"dango\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"dango\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥟\",\n    aliases: [\"dumpling\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"dumpling\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥠\",\n    aliases: [\"fortune_cookie\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"fortune cookie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥡\",\n    aliases: [\"takeout_box\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"takeout box\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦀\",\n    aliases: [\"crab\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"crab\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🦞\",\n    aliases: [\"lobster\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"lobster\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦐\",\n    aliases: [\"shrimp\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"shrimp\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦑\",\n    aliases: [\"squid\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"squid\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦪\",\n    aliases: [\"oyster\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"oyster\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🍦\",\n    aliases: [\"icecream\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"soft ice cream\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍧\",\n    aliases: [\"shaved_ice\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"shaved ice\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍨\",\n    aliases: [\"ice_cream\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"ice cream\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍩\",\n    aliases: [\"doughnut\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"doughnut\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍪\",\n    aliases: [\"cookie\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cookie\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎂\",\n    aliases: [\"birthday\"],\n    tags: [\"party\"],\n    category: \"Food & Drink\",\n    description: \"birthday cake\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍰\",\n    aliases: [\"cake\"],\n    tags: [\"dessert\"],\n    category: \"Food & Drink\",\n    description: \"shortcake\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧁\",\n    aliases: [\"cupcake\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cupcake\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥧\",\n    aliases: [\"pie\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"pie\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🍫\",\n    aliases: [\"chocolate_bar\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"chocolate bar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍬\",\n    aliases: [\"candy\"],\n    tags: [\"sweet\"],\n    category: \"Food & Drink\",\n    description: \"candy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍭\",\n    aliases: [\"lollipop\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"lollipop\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍮\",\n    aliases: [\"custard\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"custard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍯\",\n    aliases: [\"honey_pot\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"honey pot\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍼\",\n    aliases: [\"baby_bottle\"],\n    tags: [\"milk\"],\n    category: \"Food & Drink\",\n    description: \"baby bottle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥛\",\n    aliases: [\"milk_glass\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"glass of milk\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"☕\",\n    aliases: [\"coffee\"],\n    tags: [\"cafe\", \"espresso\"],\n    category: \"Food & Drink\",\n    description: \"hot beverage\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"🫖\",\n    aliases: [\"teapot\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"teapot\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🍵\",\n    aliases: [\"tea\"],\n    tags: [\"green\", \"breakfast\"],\n    category: \"Food & Drink\",\n    description: \"teacup without handle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍶\",\n    aliases: [\"sake\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"sake\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍾\",\n    aliases: [\"champagne\"],\n    tags: [\"bottle\", \"bubbly\", \"celebration\"],\n    category: \"Food & Drink\",\n    description: \"bottle with popping cork\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🍷\",\n    aliases: [\"wine_glass\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"wine glass\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍸\",\n    aliases: [\"cocktail\"],\n    tags: [\"drink\"],\n    category: \"Food & Drink\",\n    description: \"cocktail glass\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍹\",\n    aliases: [\"tropical_drink\"],\n    tags: [\"summer\", \"vacation\"],\n    category: \"Food & Drink\",\n    description: \"tropical drink\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍺\",\n    aliases: [\"beer\"],\n    tags: [\"drink\"],\n    category: \"Food & Drink\",\n    description: \"beer mug\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🍻\",\n    aliases: [\"beers\"],\n    tags: [\"drinks\"],\n    category: \"Food & Drink\",\n    description: \"clinking beer mugs\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥂\",\n    aliases: [\"clinking_glasses\"],\n    tags: [\"cheers\", \"toast\"],\n    category: \"Food & Drink\",\n    description: \"clinking glasses\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥃\",\n    aliases: [\"tumbler_glass\"],\n    tags: [\"whisky\"],\n    category: \"Food & Drink\",\n    description: \"tumbler glass\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥤\",\n    aliases: [\"cup_with_straw\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"cup with straw\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧋\",\n    aliases: [\"bubble_tea\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"bubble tea\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧃\",\n    aliases: [\"beverage_box\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"beverage box\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧉\",\n    aliases: [\"mate\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"mate\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧊\",\n    aliases: [\"ice_cube\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"ice\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🥢\",\n    aliases: [\"chopsticks\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"chopsticks\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🍽️\",\n    aliases: [\"plate_with_cutlery\"],\n    tags: [\"dining\", \"dinner\"],\n    category: \"Food & Drink\",\n    description: \"fork and knife with plate\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🍴\",\n    aliases: [\"fork_and_knife\"],\n    tags: [\"cutlery\"],\n    category: \"Food & Drink\",\n    description: \"fork and knife\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥄\",\n    aliases: [\"spoon\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"spoon\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🔪\",\n    aliases: [\"hocho\", \"knife\"],\n    tags: [\"cut\", \"chop\"],\n    category: \"Food & Drink\",\n    description: \"kitchen knife\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏺\",\n    aliases: [\"amphora\"],\n    tags: [],\n    category: \"Food & Drink\",\n    description: \"amphora\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🌍\",\n    aliases: [\"earth_africa\"],\n    tags: [\"globe\", \"world\", \"international\"],\n    category: \"Travel & Places\",\n    description: \"globe showing Europe-Africa\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌎\",\n    aliases: [\"earth_americas\"],\n    tags: [\"globe\", \"world\", \"international\"],\n    category: \"Travel & Places\",\n    description: \"globe showing Americas\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌏\",\n    aliases: [\"earth_asia\"],\n    tags: [\"globe\", \"world\", \"international\"],\n    category: \"Travel & Places\",\n    description: \"globe showing Asia-Australia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌐\",\n    aliases: [\"globe_with_meridians\"],\n    tags: [\"world\", \"global\", \"international\"],\n    category: \"Travel & Places\",\n    description: \"globe with meridians\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗺️\",\n    aliases: [\"world_map\"],\n    tags: [\"travel\"],\n    category: \"Travel & Places\",\n    description: \"world map\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🗾\",\n    aliases: [\"japan\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"map of Japan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧭\",\n    aliases: [\"compass\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"compass\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏔️\",\n    aliases: [\"mountain_snow\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"snow-capped mountain\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⛰️\",\n    aliases: [\"mountain\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mountain\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🌋\",\n    aliases: [\"volcano\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"volcano\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗻\",\n    aliases: [\"mount_fuji\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mount fuji\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏕️\",\n    aliases: [\"camping\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"camping\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏖️\",\n    aliases: [\"beach_umbrella\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"beach with umbrella\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏜️\",\n    aliases: [\"desert\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"desert\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏝️\",\n    aliases: [\"desert_island\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"desert island\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏞️\",\n    aliases: [\"national_park\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"national park\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏟️\",\n    aliases: [\"stadium\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"stadium\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏛️\",\n    aliases: [\"classical_building\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"classical building\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏗️\",\n    aliases: [\"building_construction\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"building construction\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🧱\",\n    aliases: [\"bricks\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"brick\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪨\",\n    aliases: [\"rock\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"rock\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪵\",\n    aliases: [\"wood\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"wood\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🛖\",\n    aliases: [\"hut\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"hut\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🏘️\",\n    aliases: [\"houses\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"houses\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏚️\",\n    aliases: [\"derelict_house\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"derelict house\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏠\",\n    aliases: [\"house\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"house\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏡\",\n    aliases: [\"house_with_garden\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"house with garden\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏢\",\n    aliases: [\"office\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"office building\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏣\",\n    aliases: [\"post_office\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"Japanese post office\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏤\",\n    aliases: [\"european_post_office\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"post office\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏥\",\n    aliases: [\"hospital\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"hospital\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏦\",\n    aliases: [\"bank\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"bank\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏨\",\n    aliases: [\"hotel\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"hotel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏩\",\n    aliases: [\"love_hotel\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"love hotel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏪\",\n    aliases: [\"convenience_store\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"convenience store\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏫\",\n    aliases: [\"school\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"school\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏬\",\n    aliases: [\"department_store\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"department store\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏭\",\n    aliases: [\"factory\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"factory\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏯\",\n    aliases: [\"japanese_castle\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"Japanese castle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏰\",\n    aliases: [\"european_castle\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"castle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💒\",\n    aliases: [\"wedding\"],\n    tags: [\"marriage\"],\n    category: \"Travel & Places\",\n    description: \"wedding\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗼\",\n    aliases: [\"tokyo_tower\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"Tokyo tower\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗽\",\n    aliases: [\"statue_of_liberty\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"Statue of Liberty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⛪\",\n    aliases: [\"church\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"church\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🕌\",\n    aliases: [\"mosque\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mosque\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🛕\",\n    aliases: [\"hindu_temple\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"hindu temple\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🕍\",\n    aliases: [\"synagogue\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"synagogue\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"⛩️\",\n    aliases: [\"shinto_shrine\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"shinto shrine\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🕋\",\n    aliases: [\"kaaba\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"kaaba\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"⛲\",\n    aliases: [\"fountain\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"fountain\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⛺\",\n    aliases: [\"tent\"],\n    tags: [\"camping\"],\n    category: \"Travel & Places\",\n    description: \"tent\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🌁\",\n    aliases: [\"foggy\"],\n    tags: [\"karl\"],\n    category: \"Travel & Places\",\n    description: \"foggy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌃\",\n    aliases: [\"night_with_stars\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"night with stars\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏙️\",\n    aliases: [\"cityscape\"],\n    tags: [\"skyline\"],\n    category: \"Travel & Places\",\n    description: \"cityscape\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌄\",\n    aliases: [\"sunrise_over_mountains\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sunrise over mountains\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌅\",\n    aliases: [\"sunrise\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sunrise\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌆\",\n    aliases: [\"city_sunset\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cityscape at dusk\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌇\",\n    aliases: [\"city_sunrise\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sunset\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌉\",\n    aliases: [\"bridge_at_night\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"bridge at night\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"♨️\",\n    aliases: [\"hotsprings\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"hot springs\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🎠\",\n    aliases: [\"carousel_horse\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"carousel horse\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎡\",\n    aliases: [\"ferris_wheel\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ferris wheel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎢\",\n    aliases: [\"roller_coaster\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"roller coaster\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💈\",\n    aliases: [\"barber\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"barber pole\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎪\",\n    aliases: [\"circus_tent\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"circus tent\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚂\",\n    aliases: [\"steam_locomotive\"],\n    tags: [\"train\"],\n    category: \"Travel & Places\",\n    description: \"locomotive\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚃\",\n    aliases: [\"railway_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"railway car\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚄\",\n    aliases: [\"bullettrain_side\"],\n    tags: [\"train\"],\n    category: \"Travel & Places\",\n    description: \"high-speed train\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚅\",\n    aliases: [\"bullettrain_front\"],\n    tags: [\"train\"],\n    category: \"Travel & Places\",\n    description: \"bullet train\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚆\",\n    aliases: [\"train2\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"train\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚇\",\n    aliases: [\"metro\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"metro\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚈\",\n    aliases: [\"light_rail\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"light rail\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚉\",\n    aliases: [\"station\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"station\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚊\",\n    aliases: [\"tram\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"tram\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚝\",\n    aliases: [\"monorail\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"monorail\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚞\",\n    aliases: [\"mountain_railway\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mountain railway\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚋\",\n    aliases: [\"train\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"tram car\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚌\",\n    aliases: [\"bus\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"bus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚍\",\n    aliases: [\"oncoming_bus\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"oncoming bus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚎\",\n    aliases: [\"trolleybus\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"trolleybus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚐\",\n    aliases: [\"minibus\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"minibus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚑\",\n    aliases: [\"ambulance\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ambulance\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚒\",\n    aliases: [\"fire_engine\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"fire engine\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚓\",\n    aliases: [\"police_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"police car\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚔\",\n    aliases: [\"oncoming_police_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"oncoming police car\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚕\",\n    aliases: [\"taxi\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"taxi\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚖\",\n    aliases: [\"oncoming_taxi\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"oncoming taxi\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚗\",\n    aliases: [\"car\", \"red_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"automobile\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚘\",\n    aliases: [\"oncoming_automobile\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"oncoming automobile\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚙\",\n    aliases: [\"blue_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sport utility vehicle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛻\",\n    aliases: [\"pickup_truck\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"pickup truck\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🚚\",\n    aliases: [\"truck\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"delivery truck\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚛\",\n    aliases: [\"articulated_lorry\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"articulated lorry\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚜\",\n    aliases: [\"tractor\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"tractor\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏎️\",\n    aliases: [\"racing_car\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"racing car\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏍️\",\n    aliases: [\"motorcycle\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"motorcycle\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛵\",\n    aliases: [\"motor_scooter\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"motor scooter\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🦽\",\n    aliases: [\"manual_wheelchair\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"manual wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🦼\",\n    aliases: [\"motorized_wheelchair\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"motorized wheelchair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🛺\",\n    aliases: [\"auto_rickshaw\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"auto rickshaw\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🚲\",\n    aliases: [\"bike\"],\n    tags: [\"bicycle\"],\n    category: \"Travel & Places\",\n    description: \"bicycle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛴\",\n    aliases: [\"kick_scooter\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"kick scooter\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🛹\",\n    aliases: [\"skateboard\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"skateboard\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🛼\",\n    aliases: [\"roller_skate\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"roller skate\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🚏\",\n    aliases: [\"busstop\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"bus stop\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛣️\",\n    aliases: [\"motorway\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"motorway\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛤️\",\n    aliases: [\"railway_track\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"railway track\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛢️\",\n    aliases: [\"oil_drum\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"oil drum\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⛽\",\n    aliases: [\"fuelpump\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"fuel pump\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🚨\",\n    aliases: [\"rotating_light\"],\n    tags: [\"911\", \"emergency\"],\n    category: \"Travel & Places\",\n    description: \"police car light\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚥\",\n    aliases: [\"traffic_light\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"horizontal traffic light\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚦\",\n    aliases: [\"vertical_traffic_light\"],\n    tags: [\"semaphore\"],\n    category: \"Travel & Places\",\n    description: \"vertical traffic light\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛑\",\n    aliases: [\"stop_sign\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"stop sign\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🚧\",\n    aliases: [\"construction\"],\n    tags: [\"wip\"],\n    category: \"Travel & Places\",\n    description: \"construction\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⚓\",\n    aliases: [\"anchor\"],\n    tags: [\"ship\"],\n    category: \"Travel & Places\",\n    description: \"anchor\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"⛵\",\n    aliases: [\"boat\", \"sailboat\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sailboat\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🛶\",\n    aliases: [\"canoe\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"canoe\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🚤\",\n    aliases: [\"speedboat\"],\n    tags: [\"ship\"],\n    category: \"Travel & Places\",\n    description: \"speedboat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛳️\",\n    aliases: [\"passenger_ship\"],\n    tags: [\"cruise\"],\n    category: \"Travel & Places\",\n    description: \"passenger ship\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⛴️\",\n    aliases: [\"ferry\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ferry\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🛥️\",\n    aliases: [\"motor_boat\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"motor boat\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🚢\",\n    aliases: [\"ship\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ship\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"✈️\",\n    aliases: [\"airplane\"],\n    tags: [\"flight\"],\n    category: \"Travel & Places\",\n    description: \"airplane\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🛩️\",\n    aliases: [\"small_airplane\"],\n    tags: [\"flight\"],\n    category: \"Travel & Places\",\n    description: \"small airplane\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛫\",\n    aliases: [\"flight_departure\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"airplane departure\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛬\",\n    aliases: [\"flight_arrival\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"airplane arrival\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🪂\",\n    aliases: [\"parachute\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"parachute\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"💺\",\n    aliases: [\"seat\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"seat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚁\",\n    aliases: [\"helicopter\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"helicopter\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚟\",\n    aliases: [\"suspension_railway\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"suspension railway\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚠\",\n    aliases: [\"mountain_cableway\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mountain cableway\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚡\",\n    aliases: [\"aerial_tramway\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"aerial tramway\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛰️\",\n    aliases: [\"artificial_satellite\"],\n    tags: [\"orbit\", \"space\"],\n    category: \"Travel & Places\",\n    description: \"satellite\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🚀\",\n    aliases: [\"rocket\"],\n    tags: [\"ship\", \"launch\"],\n    category: \"Travel & Places\",\n    description: \"rocket\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛸\",\n    aliases: [\"flying_saucer\"],\n    tags: [\"ufo\"],\n    category: \"Travel & Places\",\n    description: \"flying saucer\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🛎️\",\n    aliases: [\"bellhop_bell\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"bellhop bell\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🧳\",\n    aliases: [\"luggage\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"luggage\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"⌛\",\n    aliases: [\"hourglass\"],\n    tags: [\"time\"],\n    category: \"Travel & Places\",\n    description: \"hourglass done\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⏳\",\n    aliases: [\"hourglass_flowing_sand\"],\n    tags: [\"time\"],\n    category: \"Travel & Places\",\n    description: \"hourglass not done\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⌚\",\n    aliases: [\"watch\"],\n    tags: [\"time\"],\n    category: \"Travel & Places\",\n    description: \"watch\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⏰\",\n    aliases: [\"alarm_clock\"],\n    tags: [\"morning\"],\n    category: \"Travel & Places\",\n    description: \"alarm clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏱️\",\n    aliases: [\"stopwatch\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"stopwatch\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏲️\",\n    aliases: [\"timer_clock\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"timer clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕰️\",\n    aliases: [\"mantelpiece_clock\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"mantelpiece clock\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🕛\",\n    aliases: [\"clock12\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"twelve o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕧\",\n    aliases: [\"clock1230\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"twelve-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕐\",\n    aliases: [\"clock1\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"one o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕜\",\n    aliases: [\"clock130\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"one-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕑\",\n    aliases: [\"clock2\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"two o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕝\",\n    aliases: [\"clock230\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"two-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕒\",\n    aliases: [\"clock3\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"three o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕞\",\n    aliases: [\"clock330\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"three-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕓\",\n    aliases: [\"clock4\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"four o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕟\",\n    aliases: [\"clock430\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"four-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕔\",\n    aliases: [\"clock5\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"five o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕠\",\n    aliases: [\"clock530\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"five-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕕\",\n    aliases: [\"clock6\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"six o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕡\",\n    aliases: [\"clock630\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"six-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕖\",\n    aliases: [\"clock7\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"seven o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕢\",\n    aliases: [\"clock730\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"seven-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕗\",\n    aliases: [\"clock8\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"eight o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕣\",\n    aliases: [\"clock830\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"eight-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕘\",\n    aliases: [\"clock9\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"nine o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕤\",\n    aliases: [\"clock930\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"nine-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕙\",\n    aliases: [\"clock10\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ten o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕥\",\n    aliases: [\"clock1030\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ten-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕚\",\n    aliases: [\"clock11\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"eleven o’clock\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕦\",\n    aliases: [\"clock1130\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"eleven-thirty\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌑\",\n    aliases: [\"new_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"new moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌒\",\n    aliases: [\"waxing_crescent_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"waxing crescent moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌓\",\n    aliases: [\"first_quarter_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"first quarter moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌔\",\n    aliases: [\"moon\", \"waxing_gibbous_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"waxing gibbous moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌕\",\n    aliases: [\"full_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"full moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌖\",\n    aliases: [\"waning_gibbous_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"waning gibbous moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌗\",\n    aliases: [\"last_quarter_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"last quarter moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌘\",\n    aliases: [\"waning_crescent_moon\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"waning crescent moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌙\",\n    aliases: [\"crescent_moon\"],\n    tags: [\"night\"],\n    category: \"Travel & Places\",\n    description: \"crescent moon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌚\",\n    aliases: [\"new_moon_with_face\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"new moon face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌛\",\n    aliases: [\"first_quarter_moon_with_face\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"first quarter moon face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌜\",\n    aliases: [\"last_quarter_moon_with_face\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"last quarter moon face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌡️\",\n    aliases: [\"thermometer\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"thermometer\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"☀️\",\n    aliases: [\"sunny\"],\n    tags: [\"weather\"],\n    category: \"Travel & Places\",\n    description: \"sun\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🌝\",\n    aliases: [\"full_moon_with_face\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"full moon face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌞\",\n    aliases: [\"sun_with_face\"],\n    tags: [\"summer\"],\n    category: \"Travel & Places\",\n    description: \"sun with face\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪐\",\n    aliases: [\"ringed_planet\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"ringed planet\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"⭐\",\n    aliases: [\"star\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"star\",\n    unicode_version: \"5.1\",\n  },\n  {\n    emoji: \"🌟\",\n    aliases: [\"star2\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"glowing star\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌠\",\n    aliases: [\"stars\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"shooting star\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌌\",\n    aliases: [\"milky_way\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"milky way\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☁️\",\n    aliases: [\"cloud\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cloud\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⛅\",\n    aliases: [\"partly_sunny\"],\n    tags: [\"weather\", \"cloud\"],\n    category: \"Travel & Places\",\n    description: \"sun behind cloud\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⛈️\",\n    aliases: [\"cloud_with_lightning_and_rain\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cloud with lightning and rain\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🌤️\",\n    aliases: [\"sun_behind_small_cloud\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sun behind small cloud\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌥️\",\n    aliases: [\"sun_behind_large_cloud\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sun behind large cloud\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌦️\",\n    aliases: [\"sun_behind_rain_cloud\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"sun behind rain cloud\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌧️\",\n    aliases: [\"cloud_with_rain\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cloud with rain\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌨️\",\n    aliases: [\"cloud_with_snow\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cloud with snow\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌩️\",\n    aliases: [\"cloud_with_lightning\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"cloud with lightning\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌪️\",\n    aliases: [\"tornado\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"tornado\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌫️\",\n    aliases: [\"fog\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"fog\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌬️\",\n    aliases: [\"wind_face\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"wind face\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🌀\",\n    aliases: [\"cyclone\"],\n    tags: [\"swirl\"],\n    category: \"Travel & Places\",\n    description: \"cyclone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌈\",\n    aliases: [\"rainbow\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"rainbow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌂\",\n    aliases: [\"closed_umbrella\"],\n    tags: [\"weather\", \"rain\"],\n    category: \"Travel & Places\",\n    description: \"closed umbrella\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☂️\",\n    aliases: [\"open_umbrella\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"umbrella\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☔\",\n    aliases: [\"umbrella\"],\n    tags: [\"rain\", \"weather\"],\n    category: \"Travel & Places\",\n    description: \"umbrella with rain drops\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"⛱️\",\n    aliases: [\"parasol_on_ground\"],\n    tags: [\"beach_umbrella\"],\n    category: \"Travel & Places\",\n    description: \"umbrella on ground\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⚡\",\n    aliases: [\"zap\"],\n    tags: [\"lightning\", \"thunder\"],\n    category: \"Travel & Places\",\n    description: \"high voltage\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"❄️\",\n    aliases: [\"snowflake\"],\n    tags: [\"winter\", \"cold\", \"weather\"],\n    category: \"Travel & Places\",\n    description: \"snowflake\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☃️\",\n    aliases: [\"snowman_with_snow\"],\n    tags: [\"winter\", \"christmas\"],\n    category: \"Travel & Places\",\n    description: \"snowman\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⛄\",\n    aliases: [\"snowman\"],\n    tags: [\"winter\"],\n    category: \"Travel & Places\",\n    description: \"snowman without snow\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"☄️\",\n    aliases: [\"comet\"],\n    tags: [],\n    category: \"Travel & Places\",\n    description: \"comet\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🔥\",\n    aliases: [\"fire\"],\n    tags: [\"burn\"],\n    category: \"Travel & Places\",\n    description: \"fire\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💧\",\n    aliases: [\"droplet\"],\n    tags: [\"water\"],\n    category: \"Travel & Places\",\n    description: \"droplet\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🌊\",\n    aliases: [\"ocean\"],\n    tags: [\"sea\"],\n    category: \"Travel & Places\",\n    description: \"water wave\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎃\",\n    aliases: [\"jack_o_lantern\"],\n    tags: [\"halloween\"],\n    category: \"Activities\",\n    description: \"jack-o-lantern\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎄\",\n    aliases: [\"christmas_tree\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"Christmas tree\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎆\",\n    aliases: [\"fireworks\"],\n    tags: [\"festival\", \"celebration\"],\n    category: \"Activities\",\n    description: \"fireworks\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎇\",\n    aliases: [\"sparkler\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"sparkler\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧨\",\n    aliases: [\"firecracker\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"firecracker\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"✨\",\n    aliases: [\"sparkles\"],\n    tags: [\"shiny\"],\n    category: \"Activities\",\n    description: \"sparkles\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎈\",\n    aliases: [\"balloon\"],\n    tags: [\"party\", \"birthday\"],\n    category: \"Activities\",\n    description: \"balloon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎉\",\n    aliases: [\"tada\"],\n    tags: [\"hooray\", \"party\"],\n    category: \"Activities\",\n    description: \"party popper\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎊\",\n    aliases: [\"confetti_ball\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"confetti ball\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎋\",\n    aliases: [\"tanabata_tree\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"tanabata tree\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎍\",\n    aliases: [\"bamboo\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"pine decoration\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎎\",\n    aliases: [\"dolls\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"Japanese dolls\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎏\",\n    aliases: [\"flags\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"carp streamer\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎐\",\n    aliases: [\"wind_chime\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"wind chime\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎑\",\n    aliases: [\"rice_scene\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"moon viewing ceremony\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧧\",\n    aliases: [\"red_envelope\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"red envelope\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎀\",\n    aliases: [\"ribbon\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"ribbon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎁\",\n    aliases: [\"gift\"],\n    tags: [\"present\", \"birthday\", \"christmas\"],\n    category: \"Activities\",\n    description: \"wrapped gift\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎗️\",\n    aliases: [\"reminder_ribbon\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"reminder ribbon\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎟️\",\n    aliases: [\"tickets\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"admission tickets\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎫\",\n    aliases: [\"ticket\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"ticket\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎖️\",\n    aliases: [\"medal_military\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"military medal\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏆\",\n    aliases: [\"trophy\"],\n    tags: [\"award\", \"contest\", \"winner\"],\n    category: \"Activities\",\n    description: \"trophy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏅\",\n    aliases: [\"medal_sports\"],\n    tags: [\"gold\", \"winner\"],\n    category: \"Activities\",\n    description: \"sports medal\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🥇\",\n    aliases: [\"1st_place_medal\"],\n    tags: [\"gold\"],\n    category: \"Activities\",\n    description: \"1st place medal\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥈\",\n    aliases: [\"2nd_place_medal\"],\n    tags: [\"silver\"],\n    category: \"Activities\",\n    description: \"2nd place medal\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥉\",\n    aliases: [\"3rd_place_medal\"],\n    tags: [\"bronze\"],\n    category: \"Activities\",\n    description: \"3rd place medal\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"⚽\",\n    aliases: [\"soccer\"],\n    tags: [\"sports\"],\n    category: \"Activities\",\n    description: \"soccer ball\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⚾\",\n    aliases: [\"baseball\"],\n    tags: [\"sports\"],\n    category: \"Activities\",\n    description: \"baseball\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🥎\",\n    aliases: [\"softball\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"softball\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏀\",\n    aliases: [\"basketball\"],\n    tags: [\"sports\"],\n    category: \"Activities\",\n    description: \"basketball\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏐\",\n    aliases: [\"volleyball\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"volleyball\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🏈\",\n    aliases: [\"football\"],\n    tags: [\"sports\"],\n    category: \"Activities\",\n    description: \"american football\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏉\",\n    aliases: [\"rugby_football\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"rugby football\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎾\",\n    aliases: [\"tennis\"],\n    tags: [\"sports\"],\n    category: \"Activities\",\n    description: \"tennis\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥏\",\n    aliases: [\"flying_disc\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"flying disc\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎳\",\n    aliases: [\"bowling\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"bowling\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏏\",\n    aliases: [\"cricket_game\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"cricket game\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🏑\",\n    aliases: [\"field_hockey\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"field hockey\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🏒\",\n    aliases: [\"ice_hockey\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"ice hockey\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🥍\",\n    aliases: [\"lacrosse\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"lacrosse\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏓\",\n    aliases: [\"ping_pong\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"ping pong\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🏸\",\n    aliases: [\"badminton\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"badminton\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🥊\",\n    aliases: [\"boxing_glove\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"boxing glove\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥋\",\n    aliases: [\"martial_arts_uniform\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"martial arts uniform\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🥅\",\n    aliases: [\"goal_net\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"goal net\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"⛳\",\n    aliases: [\"golf\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"flag in hole\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⛸️\",\n    aliases: [\"ice_skate\"],\n    tags: [\"skating\"],\n    category: \"Activities\",\n    description: \"ice skate\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🎣\",\n    aliases: [\"fishing_pole_and_fish\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"fishing pole\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🤿\",\n    aliases: [\"diving_mask\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"diving mask\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🎽\",\n    aliases: [\"running_shirt_with_sash\"],\n    tags: [\"marathon\"],\n    category: \"Activities\",\n    description: \"running shirt\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎿\",\n    aliases: [\"ski\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"skis\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛷\",\n    aliases: [\"sled\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"sled\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥌\",\n    aliases: [\"curling_stone\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"curling stone\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎯\",\n    aliases: [\"dart\"],\n    tags: [\"target\"],\n    category: \"Activities\",\n    description: \"bullseye\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪀\",\n    aliases: [\"yo_yo\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"yo-yo\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🪁\",\n    aliases: [\"kite\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"kite\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🎱\",\n    aliases: [\"8ball\"],\n    tags: [\"pool\", \"billiards\"],\n    category: \"Activities\",\n    description: \"pool 8 ball\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔮\",\n    aliases: [\"crystal_ball\"],\n    tags: [\"fortune\"],\n    category: \"Activities\",\n    description: \"crystal ball\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪄\",\n    aliases: [\"magic_wand\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"magic wand\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧿\",\n    aliases: [\"nazar_amulet\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"nazar amulet\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎮\",\n    aliases: [\"video_game\"],\n    tags: [\"play\", \"controller\", \"console\"],\n    category: \"Activities\",\n    description: \"video game\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕹️\",\n    aliases: [\"joystick\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"joystick\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎰\",\n    aliases: [\"slot_machine\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"slot machine\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎲\",\n    aliases: [\"game_die\"],\n    tags: [\"dice\", \"gambling\"],\n    category: \"Activities\",\n    description: \"game die\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧩\",\n    aliases: [\"jigsaw\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"puzzle piece\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧸\",\n    aliases: [\"teddy_bear\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"teddy bear\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪅\",\n    aliases: [\"pinata\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"piñata\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪆\",\n    aliases: [\"nesting_dolls\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"nesting dolls\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"♠️\",\n    aliases: [\"spades\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"spade suit\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♥️\",\n    aliases: [\"hearts\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"heart suit\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♦️\",\n    aliases: [\"diamonds\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"diamond suit\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♣️\",\n    aliases: [\"clubs\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"club suit\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♟️\",\n    aliases: [\"chess_pawn\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"chess pawn\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🃏\",\n    aliases: [\"black_joker\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"joker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🀄\",\n    aliases: [\"mahjong\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"mahjong red dragon\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🎴\",\n    aliases: [\"flower_playing_cards\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"flower playing cards\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎭\",\n    aliases: [\"performing_arts\"],\n    tags: [\"theater\", \"drama\"],\n    category: \"Activities\",\n    description: \"performing arts\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🖼️\",\n    aliases: [\"framed_picture\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"framed picture\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎨\",\n    aliases: [\"art\"],\n    tags: [\"design\", \"paint\"],\n    category: \"Activities\",\n    description: \"artist palette\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧵\",\n    aliases: [\"thread\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"thread\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪡\",\n    aliases: [\"sewing_needle\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"sewing needle\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧶\",\n    aliases: [\"yarn\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"yarn\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪢\",\n    aliases: [\"knot\"],\n    tags: [],\n    category: \"Activities\",\n    description: \"knot\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👓\",\n    aliases: [\"eyeglasses\"],\n    tags: [\"glasses\"],\n    category: \"Objects\",\n    description: \"glasses\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕶️\",\n    aliases: [\"dark_sunglasses\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"sunglasses\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🥽\",\n    aliases: [\"goggles\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"goggles\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥼\",\n    aliases: [\"lab_coat\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"lab coat\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🦺\",\n    aliases: [\"safety_vest\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"safety vest\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👔\",\n    aliases: [\"necktie\"],\n    tags: [\"shirt\", \"formal\"],\n    category: \"Objects\",\n    description: \"necktie\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👕\",\n    aliases: [\"shirt\", \"tshirt\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"t-shirt\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👖\",\n    aliases: [\"jeans\"],\n    tags: [\"pants\"],\n    category: \"Objects\",\n    description: \"jeans\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧣\",\n    aliases: [\"scarf\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"scarf\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧤\",\n    aliases: [\"gloves\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"gloves\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧥\",\n    aliases: [\"coat\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"coat\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧦\",\n    aliases: [\"socks\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"socks\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👗\",\n    aliases: [\"dress\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"dress\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👘\",\n    aliases: [\"kimono\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"kimono\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥻\",\n    aliases: [\"sari\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"sari\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🩱\",\n    aliases: [\"one_piece_swimsuit\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"one-piece swimsuit\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🩲\",\n    aliases: [\"swim_brief\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"briefs\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🩳\",\n    aliases: [\"shorts\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"shorts\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👙\",\n    aliases: [\"bikini\"],\n    tags: [\"beach\"],\n    category: \"Objects\",\n    description: \"bikini\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👚\",\n    aliases: [\"womans_clothes\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"woman’s clothes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👛\",\n    aliases: [\"purse\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"purse\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👜\",\n    aliases: [\"handbag\"],\n    tags: [\"bag\"],\n    category: \"Objects\",\n    description: \"handbag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👝\",\n    aliases: [\"pouch\"],\n    tags: [\"bag\"],\n    category: \"Objects\",\n    description: \"clutch bag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛍️\",\n    aliases: [\"shopping\"],\n    tags: [\"bags\"],\n    category: \"Objects\",\n    description: \"shopping bags\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎒\",\n    aliases: [\"school_satchel\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"backpack\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🩴\",\n    aliases: [\"thong_sandal\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"thong sandal\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"👞\",\n    aliases: [\"mans_shoe\", \"shoe\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"man’s shoe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👟\",\n    aliases: [\"athletic_shoe\"],\n    tags: [\"sneaker\", \"sport\", \"running\"],\n    category: \"Objects\",\n    description: \"running shoe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🥾\",\n    aliases: [\"hiking_boot\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"hiking boot\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🥿\",\n    aliases: [\"flat_shoe\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"flat shoe\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"👠\",\n    aliases: [\"high_heel\"],\n    tags: [\"shoe\"],\n    category: \"Objects\",\n    description: \"high-heeled shoe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👡\",\n    aliases: [\"sandal\"],\n    tags: [\"shoe\"],\n    category: \"Objects\",\n    description: \"woman’s sandal\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🩰\",\n    aliases: [\"ballet_shoes\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"ballet shoes\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"👢\",\n    aliases: [\"boot\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"woman’s boot\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👑\",\n    aliases: [\"crown\"],\n    tags: [\"king\", \"queen\", \"royal\"],\n    category: \"Objects\",\n    description: \"crown\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"👒\",\n    aliases: [\"womans_hat\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"woman’s hat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎩\",\n    aliases: [\"tophat\"],\n    tags: [\"hat\", \"classy\"],\n    category: \"Objects\",\n    description: \"top hat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎓\",\n    aliases: [\"mortar_board\"],\n    tags: [\"education\", \"college\", \"university\", \"graduation\"],\n    category: \"Objects\",\n    description: \"graduation cap\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧢\",\n    aliases: [\"billed_cap\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"billed cap\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪖\",\n    aliases: [\"military_helmet\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"military helmet\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"⛑️\",\n    aliases: [\"rescue_worker_helmet\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"rescue worker’s helmet\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"📿\",\n    aliases: [\"prayer_beads\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"prayer beads\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"💄\",\n    aliases: [\"lipstick\"],\n    tags: [\"makeup\"],\n    category: \"Objects\",\n    description: \"lipstick\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💍\",\n    aliases: [\"ring\"],\n    tags: [\"wedding\", \"marriage\", \"engaged\"],\n    category: \"Objects\",\n    description: \"ring\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💎\",\n    aliases: [\"gem\"],\n    tags: [\"diamond\"],\n    category: \"Objects\",\n    description: \"gem stone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔇\",\n    aliases: [\"mute\"],\n    tags: [\"sound\", \"volume\"],\n    category: \"Objects\",\n    description: \"muted speaker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔈\",\n    aliases: [\"speaker\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"speaker low volume\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔉\",\n    aliases: [\"sound\"],\n    tags: [\"volume\"],\n    category: \"Objects\",\n    description: \"speaker medium volume\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔊\",\n    aliases: [\"loud_sound\"],\n    tags: [\"volume\"],\n    category: \"Objects\",\n    description: \"speaker high volume\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📢\",\n    aliases: [\"loudspeaker\"],\n    tags: [\"announcement\"],\n    category: \"Objects\",\n    description: \"loudspeaker\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📣\",\n    aliases: [\"mega\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"megaphone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📯\",\n    aliases: [\"postal_horn\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"postal horn\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔔\",\n    aliases: [\"bell\"],\n    tags: [\"sound\", \"notification\"],\n    category: \"Objects\",\n    description: \"bell\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔕\",\n    aliases: [\"no_bell\"],\n    tags: [\"volume\", \"off\"],\n    category: \"Objects\",\n    description: \"bell with slash\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎼\",\n    aliases: [\"musical_score\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"musical score\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎵\",\n    aliases: [\"musical_note\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"musical note\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎶\",\n    aliases: [\"notes\"],\n    tags: [\"music\"],\n    category: \"Objects\",\n    description: \"musical notes\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎙️\",\n    aliases: [\"studio_microphone\"],\n    tags: [\"podcast\"],\n    category: \"Objects\",\n    description: \"studio microphone\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎚️\",\n    aliases: [\"level_slider\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"level slider\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎛️\",\n    aliases: [\"control_knobs\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"control knobs\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎤\",\n    aliases: [\"microphone\"],\n    tags: [\"sing\"],\n    category: \"Objects\",\n    description: \"microphone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎧\",\n    aliases: [\"headphones\"],\n    tags: [\"music\", \"earphones\"],\n    category: \"Objects\",\n    description: \"headphone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📻\",\n    aliases: [\"radio\"],\n    tags: [\"podcast\"],\n    category: \"Objects\",\n    description: \"radio\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎷\",\n    aliases: [\"saxophone\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"saxophone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪗\",\n    aliases: [\"accordion\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"accordion\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🎸\",\n    aliases: [\"guitar\"],\n    tags: [\"rock\"],\n    category: \"Objects\",\n    description: \"guitar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎹\",\n    aliases: [\"musical_keyboard\"],\n    tags: [\"piano\"],\n    category: \"Objects\",\n    description: \"musical keyboard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎺\",\n    aliases: [\"trumpet\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"trumpet\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎻\",\n    aliases: [\"violin\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"violin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪕\",\n    aliases: [\"banjo\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"banjo\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🥁\",\n    aliases: [\"drum\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"drum\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🪘\",\n    aliases: [\"long_drum\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"long drum\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"📱\",\n    aliases: [\"iphone\"],\n    tags: [\"smartphone\", \"mobile\"],\n    category: \"Objects\",\n    description: \"mobile phone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📲\",\n    aliases: [\"calling\"],\n    tags: [\"call\", \"incoming\"],\n    category: \"Objects\",\n    description: \"mobile phone with arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☎️\",\n    aliases: [\"phone\", \"telephone\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"telephone\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"📞\",\n    aliases: [\"telephone_receiver\"],\n    tags: [\"phone\", \"call\"],\n    category: \"Objects\",\n    description: \"telephone receiver\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📟\",\n    aliases: [\"pager\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"pager\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📠\",\n    aliases: [\"fax\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"fax machine\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔋\",\n    aliases: [\"battery\"],\n    tags: [\"power\"],\n    category: \"Objects\",\n    description: \"battery\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔌\",\n    aliases: [\"electric_plug\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"electric plug\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💻\",\n    aliases: [\"computer\"],\n    tags: [\"desktop\", \"screen\"],\n    category: \"Objects\",\n    description: \"laptop\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🖥️\",\n    aliases: [\"desktop_computer\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"desktop computer\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🖨️\",\n    aliases: [\"printer\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"printer\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⌨️\",\n    aliases: [\"keyboard\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"keyboard\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🖱️\",\n    aliases: [\"computer_mouse\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"computer mouse\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🖲️\",\n    aliases: [\"trackball\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"trackball\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"💽\",\n    aliases: [\"minidisc\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"computer disk\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💾\",\n    aliases: [\"floppy_disk\"],\n    tags: [\"save\"],\n    category: \"Objects\",\n    description: \"floppy disk\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💿\",\n    aliases: [\"cd\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"optical disk\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📀\",\n    aliases: [\"dvd\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"dvd\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧮\",\n    aliases: [\"abacus\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"abacus\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎥\",\n    aliases: [\"movie_camera\"],\n    tags: [\"film\", \"video\"],\n    category: \"Objects\",\n    description: \"movie camera\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎞️\",\n    aliases: [\"film_strip\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"film frames\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📽️\",\n    aliases: [\"film_projector\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"film projector\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🎬\",\n    aliases: [\"clapper\"],\n    tags: [\"film\"],\n    category: \"Objects\",\n    description: \"clapper board\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📺\",\n    aliases: [\"tv\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"television\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📷\",\n    aliases: [\"camera\"],\n    tags: [\"photo\"],\n    category: \"Objects\",\n    description: \"camera\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📸\",\n    aliases: [\"camera_flash\"],\n    tags: [\"photo\"],\n    category: \"Objects\",\n    description: \"camera with flash\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📹\",\n    aliases: [\"video_camera\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"video camera\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📼\",\n    aliases: [\"vhs\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"videocassette\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔍\",\n    aliases: [\"mag\"],\n    tags: [\"search\", \"zoom\"],\n    category: \"Objects\",\n    description: \"magnifying glass tilted left\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔎\",\n    aliases: [\"mag_right\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"magnifying glass tilted right\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🕯️\",\n    aliases: [\"candle\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"candle\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"💡\",\n    aliases: [\"bulb\"],\n    tags: [\"idea\", \"light\"],\n    category: \"Objects\",\n    description: \"light bulb\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔦\",\n    aliases: [\"flashlight\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"flashlight\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏮\",\n    aliases: [\"izakaya_lantern\", \"lantern\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"red paper lantern\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪔\",\n    aliases: [\"diya_lamp\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"diya lamp\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"📔\",\n    aliases: [\"notebook_with_decorative_cover\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"notebook with decorative cover\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📕\",\n    aliases: [\"closed_book\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"closed book\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📖\",\n    aliases: [\"book\", \"open_book\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"open book\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📗\",\n    aliases: [\"green_book\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"green book\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📘\",\n    aliases: [\"blue_book\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"blue book\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📙\",\n    aliases: [\"orange_book\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"orange book\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📚\",\n    aliases: [\"books\"],\n    tags: [\"library\"],\n    category: \"Objects\",\n    description: \"books\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📓\",\n    aliases: [\"notebook\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"notebook\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📒\",\n    aliases: [\"ledger\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"ledger\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📃\",\n    aliases: [\"page_with_curl\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"page with curl\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📜\",\n    aliases: [\"scroll\"],\n    tags: [\"document\"],\n    category: \"Objects\",\n    description: \"scroll\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📄\",\n    aliases: [\"page_facing_up\"],\n    tags: [\"document\"],\n    category: \"Objects\",\n    description: \"page facing up\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📰\",\n    aliases: [\"newspaper\"],\n    tags: [\"press\"],\n    category: \"Objects\",\n    description: \"newspaper\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗞️\",\n    aliases: [\"newspaper_roll\"],\n    tags: [\"press\"],\n    category: \"Objects\",\n    description: \"rolled-up newspaper\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📑\",\n    aliases: [\"bookmark_tabs\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"bookmark tabs\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔖\",\n    aliases: [\"bookmark\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"bookmark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏷️\",\n    aliases: [\"label\"],\n    tags: [\"tag\"],\n    category: \"Objects\",\n    description: \"label\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"💰\",\n    aliases: [\"moneybag\"],\n    tags: [\"dollar\", \"cream\"],\n    category: \"Objects\",\n    description: \"money bag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪙\",\n    aliases: [\"coin\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"coin\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"💴\",\n    aliases: [\"yen\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"yen banknote\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💵\",\n    aliases: [\"dollar\"],\n    tags: [\"money\"],\n    category: \"Objects\",\n    description: \"dollar banknote\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💶\",\n    aliases: [\"euro\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"euro banknote\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💷\",\n    aliases: [\"pound\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"pound banknote\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💸\",\n    aliases: [\"money_with_wings\"],\n    tags: [\"dollar\"],\n    category: \"Objects\",\n    description: \"money with wings\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💳\",\n    aliases: [\"credit_card\"],\n    tags: [\"subscription\"],\n    category: \"Objects\",\n    description: \"credit card\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🧾\",\n    aliases: [\"receipt\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"receipt\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"💹\",\n    aliases: [\"chart\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"chart increasing with yen\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"✉️\",\n    aliases: [\"envelope\"],\n    tags: [\"letter\", \"email\"],\n    category: \"Objects\",\n    description: \"envelope\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"📧\",\n    aliases: [\"email\", \"e-mail\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"e-mail\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📨\",\n    aliases: [\"incoming_envelope\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"incoming envelope\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📩\",\n    aliases: [\"envelope_with_arrow\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"envelope with arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📤\",\n    aliases: [\"outbox_tray\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"outbox tray\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📥\",\n    aliases: [\"inbox_tray\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"inbox tray\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📦\",\n    aliases: [\"package\"],\n    tags: [\"shipping\"],\n    category: \"Objects\",\n    description: \"package\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📫\",\n    aliases: [\"mailbox\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"closed mailbox with raised flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📪\",\n    aliases: [\"mailbox_closed\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"closed mailbox with lowered flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📬\",\n    aliases: [\"mailbox_with_mail\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"open mailbox with raised flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📭\",\n    aliases: [\"mailbox_with_no_mail\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"open mailbox with lowered flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📮\",\n    aliases: [\"postbox\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"postbox\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗳️\",\n    aliases: [\"ballot_box\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"ballot box with ballot\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"✏️\",\n    aliases: [\"pencil2\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"pencil\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"✒️\",\n    aliases: [\"black_nib\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"black nib\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🖋️\",\n    aliases: [\"fountain_pen\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"fountain pen\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🖊️\",\n    aliases: [\"pen\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"pen\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🖌️\",\n    aliases: [\"paintbrush\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"paintbrush\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🖍️\",\n    aliases: [\"crayon\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"crayon\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📝\",\n    aliases: [\"memo\", \"pencil\"],\n    tags: [\"document\", \"note\"],\n    category: \"Objects\",\n    description: \"memo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💼\",\n    aliases: [\"briefcase\"],\n    tags: [\"business\"],\n    category: \"Objects\",\n    description: \"briefcase\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📁\",\n    aliases: [\"file_folder\"],\n    tags: [\"directory\"],\n    category: \"Objects\",\n    description: \"file folder\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📂\",\n    aliases: [\"open_file_folder\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"open file folder\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗂️\",\n    aliases: [\"card_index_dividers\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"card index dividers\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📅\",\n    aliases: [\"date\"],\n    tags: [\"calendar\", \"schedule\"],\n    category: \"Objects\",\n    description: \"calendar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📆\",\n    aliases: [\"calendar\"],\n    tags: [\"schedule\"],\n    category: \"Objects\",\n    description: \"tear-off calendar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗒️\",\n    aliases: [\"spiral_notepad\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"spiral notepad\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🗓️\",\n    aliases: [\"spiral_calendar\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"spiral calendar\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📇\",\n    aliases: [\"card_index\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"card index\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📈\",\n    aliases: [\"chart_with_upwards_trend\"],\n    tags: [\"graph\", \"metrics\"],\n    category: \"Objects\",\n    description: \"chart increasing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📉\",\n    aliases: [\"chart_with_downwards_trend\"],\n    tags: [\"graph\", \"metrics\"],\n    category: \"Objects\",\n    description: \"chart decreasing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📊\",\n    aliases: [\"bar_chart\"],\n    tags: [\"stats\", \"metrics\"],\n    category: \"Objects\",\n    description: \"bar chart\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📋\",\n    aliases: [\"clipboard\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"clipboard\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📌\",\n    aliases: [\"pushpin\"],\n    tags: [\"location\"],\n    category: \"Objects\",\n    description: \"pushpin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📍\",\n    aliases: [\"round_pushpin\"],\n    tags: [\"location\"],\n    category: \"Objects\",\n    description: \"round pushpin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📎\",\n    aliases: [\"paperclip\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"paperclip\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🖇️\",\n    aliases: [\"paperclips\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"linked paperclips\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"📏\",\n    aliases: [\"straight_ruler\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"straight ruler\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📐\",\n    aliases: [\"triangular_ruler\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"triangular ruler\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"✂️\",\n    aliases: [\"scissors\"],\n    tags: [\"cut\"],\n    category: \"Objects\",\n    description: \"scissors\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🗃️\",\n    aliases: [\"card_file_box\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"card file box\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🗄️\",\n    aliases: [\"file_cabinet\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"file cabinet\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🗑️\",\n    aliases: [\"wastebasket\"],\n    tags: [\"trash\"],\n    category: \"Objects\",\n    description: \"wastebasket\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🔒\",\n    aliases: [\"lock\"],\n    tags: [\"security\", \"private\"],\n    category: \"Objects\",\n    description: \"locked\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔓\",\n    aliases: [\"unlock\"],\n    tags: [\"security\"],\n    category: \"Objects\",\n    description: \"unlocked\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔏\",\n    aliases: [\"lock_with_ink_pen\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"locked with pen\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔐\",\n    aliases: [\"closed_lock_with_key\"],\n    tags: [\"security\"],\n    category: \"Objects\",\n    description: \"locked with key\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔑\",\n    aliases: [\"key\"],\n    tags: [\"lock\", \"password\"],\n    category: \"Objects\",\n    description: \"key\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🗝️\",\n    aliases: [\"old_key\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"old key\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🔨\",\n    aliases: [\"hammer\"],\n    tags: [\"tool\"],\n    category: \"Objects\",\n    description: \"hammer\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪓\",\n    aliases: [\"axe\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"axe\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"⛏️\",\n    aliases: [\"pick\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"pick\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"⚒️\",\n    aliases: [\"hammer_and_pick\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"hammer and pick\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🛠️\",\n    aliases: [\"hammer_and_wrench\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"hammer and wrench\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🗡️\",\n    aliases: [\"dagger\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"dagger\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⚔️\",\n    aliases: [\"crossed_swords\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"crossed swords\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🔫\",\n    aliases: [\"gun\"],\n    tags: [\"shoot\", \"weapon\"],\n    category: \"Objects\",\n    description: \"water pistol\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪃\",\n    aliases: [\"boomerang\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"boomerang\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🏹\",\n    aliases: [\"bow_and_arrow\"],\n    tags: [\"archery\"],\n    category: \"Objects\",\n    description: \"bow and arrow\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🛡️\",\n    aliases: [\"shield\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"shield\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🪚\",\n    aliases: [\"carpentry_saw\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"carpentry saw\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🔧\",\n    aliases: [\"wrench\"],\n    tags: [\"tool\"],\n    category: \"Objects\",\n    description: \"wrench\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪛\",\n    aliases: [\"screwdriver\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"screwdriver\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🔩\",\n    aliases: [\"nut_and_bolt\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"nut and bolt\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⚙️\",\n    aliases: [\"gear\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"gear\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🗜️\",\n    aliases: [\"clamp\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"clamp\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⚖️\",\n    aliases: [\"balance_scale\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"balance scale\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🦯\",\n    aliases: [\"probing_cane\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"white cane\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🔗\",\n    aliases: [\"link\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"link\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⛓️\",\n    aliases: [\"chains\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"chains\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🪝\",\n    aliases: [\"hook\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"hook\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧰\",\n    aliases: [\"toolbox\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"toolbox\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧲\",\n    aliases: [\"magnet\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"magnet\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪜\",\n    aliases: [\"ladder\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"ladder\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"⚗️\",\n    aliases: [\"alembic\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"alembic\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🧪\",\n    aliases: [\"test_tube\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"test tube\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧫\",\n    aliases: [\"petri_dish\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"petri dish\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧬\",\n    aliases: [\"dna\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"dna\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🔬\",\n    aliases: [\"microscope\"],\n    tags: [\"science\", \"laboratory\", \"investigate\"],\n    category: \"Objects\",\n    description: \"microscope\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔭\",\n    aliases: [\"telescope\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"telescope\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📡\",\n    aliases: [\"satellite\"],\n    tags: [\"signal\"],\n    category: \"Objects\",\n    description: \"satellite antenna\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💉\",\n    aliases: [\"syringe\"],\n    tags: [\"health\", \"hospital\", \"needle\"],\n    category: \"Objects\",\n    description: \"syringe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🩸\",\n    aliases: [\"drop_of_blood\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"drop of blood\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"💊\",\n    aliases: [\"pill\"],\n    tags: [\"health\", \"medicine\"],\n    category: \"Objects\",\n    description: \"pill\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🩹\",\n    aliases: [\"adhesive_bandage\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"adhesive bandage\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🩺\",\n    aliases: [\"stethoscope\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"stethoscope\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🚪\",\n    aliases: [\"door\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"door\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛗\",\n    aliases: [\"elevator\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"elevator\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪞\",\n    aliases: [\"mirror\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"mirror\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪟\",\n    aliases: [\"window\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"window\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🛏️\",\n    aliases: [\"bed\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"bed\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🛋️\",\n    aliases: [\"couch_and_lamp\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"couch and lamp\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🪑\",\n    aliases: [\"chair\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"chair\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🚽\",\n    aliases: [\"toilet\"],\n    tags: [\"wc\"],\n    category: \"Objects\",\n    description: \"toilet\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪠\",\n    aliases: [\"plunger\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"plunger\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🚿\",\n    aliases: [\"shower\"],\n    tags: [\"bath\"],\n    category: \"Objects\",\n    description: \"shower\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛁\",\n    aliases: [\"bathtub\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"bathtub\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪤\",\n    aliases: [\"mouse_trap\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"mouse trap\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🪒\",\n    aliases: [\"razor\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"razor\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🧴\",\n    aliases: [\"lotion_bottle\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"lotion bottle\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧷\",\n    aliases: [\"safety_pin\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"safety pin\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧹\",\n    aliases: [\"broom\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"broom\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧺\",\n    aliases: [\"basket\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"basket\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧻\",\n    aliases: [\"roll_of_paper\"],\n    tags: [\"toilet\"],\n    category: \"Objects\",\n    description: \"roll of paper\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪣\",\n    aliases: [\"bucket\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"bucket\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧼\",\n    aliases: [\"soap\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"soap\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🪥\",\n    aliases: [\"toothbrush\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"toothbrush\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🧽\",\n    aliases: [\"sponge\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"sponge\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🧯\",\n    aliases: [\"fire_extinguisher\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"fire extinguisher\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🛒\",\n    aliases: [\"shopping_cart\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"shopping cart\",\n    unicode_version: \"9.0\",\n  },\n  {\n    emoji: \"🚬\",\n    aliases: [\"smoking\"],\n    tags: [\"cigarette\"],\n    category: \"Objects\",\n    description: \"cigarette\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⚰️\",\n    aliases: [\"coffin\"],\n    tags: [\"funeral\"],\n    category: \"Objects\",\n    description: \"coffin\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🪦\",\n    aliases: [\"headstone\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"headstone\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"⚱️\",\n    aliases: [\"funeral_urn\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"funeral urn\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🗿\",\n    aliases: [\"moyai\"],\n    tags: [\"stone\"],\n    category: \"Objects\",\n    description: \"moai\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🪧\",\n    aliases: [\"placard\"],\n    tags: [],\n    category: \"Objects\",\n    description: \"placard\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🏧\",\n    aliases: [\"atm\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"ATM sign\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚮\",\n    aliases: [\"put_litter_in_its_place\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"litter in bin sign\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚰\",\n    aliases: [\"potable_water\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"potable water\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"♿\",\n    aliases: [\"wheelchair\"],\n    tags: [\"accessibility\"],\n    category: \"Symbols\",\n    description: \"wheelchair symbol\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🚹\",\n    aliases: [\"mens\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"men’s room\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚺\",\n    aliases: [\"womens\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"women’s room\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚻\",\n    aliases: [\"restroom\"],\n    tags: [\"toilet\"],\n    category: \"Symbols\",\n    description: \"restroom\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚼\",\n    aliases: [\"baby_symbol\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"baby symbol\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚾\",\n    aliases: [\"wc\"],\n    tags: [\"toilet\", \"restroom\"],\n    category: \"Symbols\",\n    description: \"water closet\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛂\",\n    aliases: [\"passport_control\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"passport control\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛃\",\n    aliases: [\"customs\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"customs\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛄\",\n    aliases: [\"baggage_claim\"],\n    tags: [\"airport\"],\n    category: \"Symbols\",\n    description: \"baggage claim\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛅\",\n    aliases: [\"left_luggage\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"left luggage\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⚠️\",\n    aliases: [\"warning\"],\n    tags: [\"wip\"],\n    category: \"Symbols\",\n    description: \"warning\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"🚸\",\n    aliases: [\"children_crossing\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"children crossing\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⛔\",\n    aliases: [\"no_entry\"],\n    tags: [\"limit\"],\n    category: \"Symbols\",\n    description: \"no entry\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🚫\",\n    aliases: [\"no_entry_sign\"],\n    tags: [\"block\", \"forbidden\"],\n    category: \"Symbols\",\n    description: \"prohibited\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚳\",\n    aliases: [\"no_bicycles\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no bicycles\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚭\",\n    aliases: [\"no_smoking\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no smoking\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚯\",\n    aliases: [\"do_not_litter\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no littering\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚱\",\n    aliases: [\"non-potable_water\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"non-potable water\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚷\",\n    aliases: [\"no_pedestrians\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no pedestrians\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📵\",\n    aliases: [\"no_mobile_phones\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no mobile phones\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔞\",\n    aliases: [\"underage\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"no one under eighteen\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☢️\",\n    aliases: [\"radioactive\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"radioactive\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☣️\",\n    aliases: [\"biohazard\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"biohazard\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⬆️\",\n    aliases: [\"arrow_up\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"up arrow\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"↗️\",\n    aliases: [\"arrow_upper_right\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"up-right arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"➡️\",\n    aliases: [\"arrow_right\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"right arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"↘️\",\n    aliases: [\"arrow_lower_right\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"down-right arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⬇️\",\n    aliases: [\"arrow_down\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"down arrow\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"↙️\",\n    aliases: [\"arrow_lower_left\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"down-left arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⬅️\",\n    aliases: [\"arrow_left\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"left arrow\",\n    unicode_version: \"4.0\",\n  },\n  {\n    emoji: \"↖️\",\n    aliases: [\"arrow_upper_left\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"up-left arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"↕️\",\n    aliases: [\"arrow_up_down\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"up-down arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"↔️\",\n    aliases: [\"left_right_arrow\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"left-right arrow\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"↩️\",\n    aliases: [\"leftwards_arrow_with_hook\"],\n    tags: [\"return\"],\n    category: \"Symbols\",\n    description: \"right arrow curving left\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"↪️\",\n    aliases: [\"arrow_right_hook\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"left arrow curving right\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⤴️\",\n    aliases: [\"arrow_heading_up\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"right arrow curving up\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⤵️\",\n    aliases: [\"arrow_heading_down\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"right arrow curving down\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🔃\",\n    aliases: [\"arrows_clockwise\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"clockwise vertical arrows\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔄\",\n    aliases: [\"arrows_counterclockwise\"],\n    tags: [\"sync\"],\n    category: \"Symbols\",\n    description: \"counterclockwise arrows button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔙\",\n    aliases: [\"back\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"BACK arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔚\",\n    aliases: [\"end\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"END arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔛\",\n    aliases: [\"on\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"ON! arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔜\",\n    aliases: [\"soon\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"SOON arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔝\",\n    aliases: [\"top\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"TOP arrow\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🛐\",\n    aliases: [\"place_of_worship\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"place of worship\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"⚛️\",\n    aliases: [\"atom_symbol\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"atom symbol\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🕉️\",\n    aliases: [\"om\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"om\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"✡️\",\n    aliases: [\"star_of_david\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"star of David\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☸️\",\n    aliases: [\"wheel_of_dharma\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"wheel of dharma\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☯️\",\n    aliases: [\"yin_yang\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"yin yang\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"✝️\",\n    aliases: [\"latin_cross\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"latin cross\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☦️\",\n    aliases: [\"orthodox_cross\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"orthodox cross\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☪️\",\n    aliases: [\"star_and_crescent\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"star and crescent\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"☮️\",\n    aliases: [\"peace_symbol\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"peace symbol\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🕎\",\n    aliases: [\"menorah\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"menorah\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🔯\",\n    aliases: [\"six_pointed_star\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"dotted six-pointed star\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"♈\",\n    aliases: [\"aries\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Aries\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♉\",\n    aliases: [\"taurus\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Taurus\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♊\",\n    aliases: [\"gemini\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Gemini\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♋\",\n    aliases: [\"cancer\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Cancer\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♌\",\n    aliases: [\"leo\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Leo\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♍\",\n    aliases: [\"virgo\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Virgo\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♎\",\n    aliases: [\"libra\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Libra\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♏\",\n    aliases: [\"scorpius\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Scorpio\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♐\",\n    aliases: [\"sagittarius\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Sagittarius\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♑\",\n    aliases: [\"capricorn\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Capricorn\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♒\",\n    aliases: [\"aquarius\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Aquarius\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"♓\",\n    aliases: [\"pisces\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Pisces\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⛎\",\n    aliases: [\"ophiuchus\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Ophiuchus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔀\",\n    aliases: [\"twisted_rightwards_arrows\"],\n    tags: [\"shuffle\"],\n    category: \"Symbols\",\n    description: \"shuffle tracks button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔁\",\n    aliases: [\"repeat\"],\n    tags: [\"loop\"],\n    category: \"Symbols\",\n    description: \"repeat button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔂\",\n    aliases: [\"repeat_one\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"repeat single button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"▶️\",\n    aliases: [\"arrow_forward\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"play button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⏩\",\n    aliases: [\"fast_forward\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"fast-forward button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏭️\",\n    aliases: [\"next_track_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"next track button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏯️\",\n    aliases: [\"play_or_pause_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"play or pause button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"◀️\",\n    aliases: [\"arrow_backward\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"reverse button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⏪\",\n    aliases: [\"rewind\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"fast reverse button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏮️\",\n    aliases: [\"previous_track_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"last track button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔼\",\n    aliases: [\"arrow_up_small\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"upwards button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏫\",\n    aliases: [\"arrow_double_up\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"fast up button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔽\",\n    aliases: [\"arrow_down_small\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"downwards button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏬\",\n    aliases: [\"arrow_double_down\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"fast down button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⏸️\",\n    aliases: [\"pause_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"pause button\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⏹️\",\n    aliases: [\"stop_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"stop button\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⏺️\",\n    aliases: [\"record_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"record button\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"⏏️\",\n    aliases: [\"eject_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"eject button\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🎦\",\n    aliases: [\"cinema\"],\n    tags: [\"film\", \"movie\"],\n    category: \"Symbols\",\n    description: \"cinema\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔅\",\n    aliases: [\"low_brightness\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"dim button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔆\",\n    aliases: [\"high_brightness\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"bright button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📶\",\n    aliases: [\"signal_strength\"],\n    tags: [\"wifi\"],\n    category: \"Symbols\",\n    description: \"antenna bars\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📳\",\n    aliases: [\"vibration_mode\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"vibration mode\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📴\",\n    aliases: [\"mobile_phone_off\"],\n    tags: [\"mute\", \"off\"],\n    category: \"Symbols\",\n    description: \"mobile phone off\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"♀️\",\n    aliases: [\"female_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"female sign\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"♂️\",\n    aliases: [\"male_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"male sign\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"⚧️\",\n    aliases: [\"transgender_symbol\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"transgender symbol\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"✖️\",\n    aliases: [\"heavy_multiplication_x\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"multiply\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"➕\",\n    aliases: [\"heavy_plus_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"plus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"➖\",\n    aliases: [\"heavy_minus_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"minus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"➗\",\n    aliases: [\"heavy_division_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"divide\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"♾️\",\n    aliases: [\"infinity\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"infinity\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"‼️\",\n    aliases: [\"bangbang\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"double exclamation mark\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"⁉️\",\n    aliases: [\"interrobang\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"exclamation question mark\",\n    unicode_version: \"3.0\",\n  },\n  {\n    emoji: \"❓\",\n    aliases: [\"question\"],\n    tags: [\"confused\"],\n    category: \"Symbols\",\n    description: \"red question mark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❔\",\n    aliases: [\"grey_question\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white question mark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❕\",\n    aliases: [\"grey_exclamation\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white exclamation mark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❗\",\n    aliases: [\"exclamation\", \"heavy_exclamation_mark\"],\n    tags: [\"bang\"],\n    category: \"Symbols\",\n    description: \"red exclamation mark\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"〰️\",\n    aliases: [\"wavy_dash\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"wavy dash\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"💱\",\n    aliases: [\"currency_exchange\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"currency exchange\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💲\",\n    aliases: [\"heavy_dollar_sign\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"heavy dollar sign\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⚕️\",\n    aliases: [\"medical_symbol\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"medical symbol\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"♻️\",\n    aliases: [\"recycle\"],\n    tags: [\"environment\", \"green\"],\n    category: \"Symbols\",\n    description: \"recycling symbol\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"⚜️\",\n    aliases: [\"fleur_de_lis\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"fleur-de-lis\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🔱\",\n    aliases: [\"trident\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"trident emblem\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"📛\",\n    aliases: [\"name_badge\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"name badge\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔰\",\n    aliases: [\"beginner\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese symbol for beginner\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"⭕\",\n    aliases: [\"o\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"hollow red circle\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"✅\",\n    aliases: [\"white_check_mark\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"check mark button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"☑️\",\n    aliases: [\"ballot_box_with_check\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"check box with check\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"✔️\",\n    aliases: [\"heavy_check_mark\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"check mark\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"❌\",\n    aliases: [\"x\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"cross mark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"❎\",\n    aliases: [\"negative_squared_cross_mark\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"cross mark button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"➰\",\n    aliases: [\"curly_loop\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"curly loop\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"➿\",\n    aliases: [\"loop\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"double curly loop\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"〽️\",\n    aliases: [\"part_alternation_mark\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"part alternation mark\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"✳️\",\n    aliases: [\"eight_spoked_asterisk\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"eight-spoked asterisk\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"✴️\",\n    aliases: [\"eight_pointed_black_star\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"eight-pointed star\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"❇️\",\n    aliases: [\"sparkle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"sparkle\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"©️\",\n    aliases: [\"copyright\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"copyright\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"®️\",\n    aliases: [\"registered\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"registered\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"™️\",\n    aliases: [\"tm\"],\n    tags: [\"trademark\"],\n    category: \"Symbols\",\n    description: \"trade mark\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"#️⃣\",\n    aliases: [\"hash\"],\n    tags: [\"number\"],\n    category: \"Symbols\",\n    description: \"keycap: #\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"*️⃣\",\n    aliases: [\"asterisk\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: *\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"0️⃣\",\n    aliases: [\"zero\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 0\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"1️⃣\",\n    aliases: [\"one\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 1\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"2️⃣\",\n    aliases: [\"two\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 2\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"3️⃣\",\n    aliases: [\"three\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 3\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"4️⃣\",\n    aliases: [\"four\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 4\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"5️⃣\",\n    aliases: [\"five\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 5\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"6️⃣\",\n    aliases: [\"six\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 6\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"7️⃣\",\n    aliases: [\"seven\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 7\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"8️⃣\",\n    aliases: [\"eight\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 8\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"9️⃣\",\n    aliases: [\"nine\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 9\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🔟\",\n    aliases: [\"keycap_ten\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"keycap: 10\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔠\",\n    aliases: [\"capital_abcd\"],\n    tags: [\"letters\"],\n    category: \"Symbols\",\n    description: \"input latin uppercase\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔡\",\n    aliases: [\"abcd\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"input latin lowercase\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔢\",\n    aliases: [\"1234\"],\n    tags: [\"numbers\"],\n    category: \"Symbols\",\n    description: \"input numbers\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔣\",\n    aliases: [\"symbols\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"input symbols\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔤\",\n    aliases: [\"abc\"],\n    tags: [\"alphabet\"],\n    category: \"Symbols\",\n    description: \"input latin letters\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🅰️\",\n    aliases: [\"a\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"A button (blood type)\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆎\",\n    aliases: [\"ab\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"AB button (blood type)\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🅱️\",\n    aliases: [\"b\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"B button (blood type)\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆑\",\n    aliases: [\"cl\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"CL button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆒\",\n    aliases: [\"cool\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"COOL button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆓\",\n    aliases: [\"free\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"FREE button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"ℹ️\",\n    aliases: [\"information_source\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"information\",\n    unicode_version: \"3.0\",\n  },\n  {\n    emoji: \"🆔\",\n    aliases: [\"id\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"ID button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"Ⓜ️\",\n    aliases: [\"m\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"circled M\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🆕\",\n    aliases: [\"new\"],\n    tags: [\"fresh\"],\n    category: \"Symbols\",\n    description: \"NEW button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆖\",\n    aliases: [\"ng\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"NG button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🅾️\",\n    aliases: [\"o2\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"O button (blood type)\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆗\",\n    aliases: [\"ok\"],\n    tags: [\"yes\"],\n    category: \"Symbols\",\n    description: \"OK button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🅿️\",\n    aliases: [\"parking\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"P button\",\n    unicode_version: \"5.2\",\n  },\n  {\n    emoji: \"🆘\",\n    aliases: [\"sos\"],\n    tags: [\"help\", \"emergency\"],\n    category: \"Symbols\",\n    description: \"SOS button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆙\",\n    aliases: [\"up\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"UP! button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🆚\",\n    aliases: [\"vs\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"VS button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈁\",\n    aliases: [\"koko\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “here” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈂️\",\n    aliases: [\"sa\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “service charge” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈷️\",\n    aliases: [\"u6708\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “monthly amount” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈶\",\n    aliases: [\"u6709\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “not free of charge” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈯\",\n    aliases: [\"u6307\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “reserved” button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🉐\",\n    aliases: [\"ideograph_advantage\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “bargain” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈹\",\n    aliases: [\"u5272\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “discount” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈚\",\n    aliases: [\"u7121\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “free of charge” button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🈲\",\n    aliases: [\"u7981\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “prohibited” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🉑\",\n    aliases: [\"accept\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “acceptable” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈸\",\n    aliases: [\"u7533\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “application” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈴\",\n    aliases: [\"u5408\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “passing grade” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈳\",\n    aliases: [\"u7a7a\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “vacancy” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"㊗️\",\n    aliases: [\"congratulations\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “congratulations” button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"㊙️\",\n    aliases: [\"secret\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “secret” button\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🈺\",\n    aliases: [\"u55b6\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “open for business” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🈵\",\n    aliases: [\"u6e80\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"Japanese “no vacancy” button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔴\",\n    aliases: [\"red_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"red circle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🟠\",\n    aliases: [\"orange_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"orange circle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟡\",\n    aliases: [\"yellow_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"yellow circle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟢\",\n    aliases: [\"green_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"green circle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🔵\",\n    aliases: [\"large_blue_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"blue circle\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🟣\",\n    aliases: [\"purple_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"purple circle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟤\",\n    aliases: [\"brown_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"brown circle\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"⚫\",\n    aliases: [\"black_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black circle\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"⚪\",\n    aliases: [\"white_circle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white circle\",\n    unicode_version: \"4.1\",\n  },\n  {\n    emoji: \"🟥\",\n    aliases: [\"red_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"red square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟧\",\n    aliases: [\"orange_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"orange square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟨\",\n    aliases: [\"yellow_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"yellow square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟩\",\n    aliases: [\"green_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"green square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟦\",\n    aliases: [\"blue_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"blue square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟪\",\n    aliases: [\"purple_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"purple square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"🟫\",\n    aliases: [\"brown_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"brown square\",\n    unicode_version: \"12.0\",\n  },\n  {\n    emoji: \"⬛\",\n    aliases: [\"black_large_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black large square\",\n    unicode_version: \"5.1\",\n  },\n  {\n    emoji: \"⬜\",\n    aliases: [\"white_large_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white large square\",\n    unicode_version: \"5.1\",\n  },\n  {\n    emoji: \"◼️\",\n    aliases: [\"black_medium_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black medium square\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"◻️\",\n    aliases: [\"white_medium_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white medium square\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"◾\",\n    aliases: [\"black_medium_small_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black medium-small square\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"◽\",\n    aliases: [\"white_medium_small_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white medium-small square\",\n    unicode_version: \"3.2\",\n  },\n  {\n    emoji: \"▪️\",\n    aliases: [\"black_small_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black small square\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"▫️\",\n    aliases: [\"white_small_square\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white small square\",\n    unicode_version: \"\",\n  },\n  {\n    emoji: \"🔶\",\n    aliases: [\"large_orange_diamond\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"large orange diamond\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔷\",\n    aliases: [\"large_blue_diamond\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"large blue diamond\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔸\",\n    aliases: [\"small_orange_diamond\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"small orange diamond\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔹\",\n    aliases: [\"small_blue_diamond\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"small blue diamond\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔺\",\n    aliases: [\"small_red_triangle\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"red triangle pointed up\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔻\",\n    aliases: [\"small_red_triangle_down\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"red triangle pointed down\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"💠\",\n    aliases: [\"diamond_shape_with_a_dot_inside\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"diamond with a dot\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔘\",\n    aliases: [\"radio_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"radio button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔳\",\n    aliases: [\"white_square_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"white square button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🔲\",\n    aliases: [\"black_square_button\"],\n    tags: [],\n    category: \"Symbols\",\n    description: \"black square button\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏁\",\n    aliases: [\"checkered_flag\"],\n    tags: [\"milestone\", \"finish\"],\n    category: \"Flags\",\n    description: \"chequered flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🚩\",\n    aliases: [\"triangular_flag_on_post\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"triangular flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🎌\",\n    aliases: [\"crossed_flags\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"crossed flags\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏴\",\n    aliases: [\"black_flag\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"black flag\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏳️\",\n    aliases: [\"white_flag\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"white flag\",\n    unicode_version: \"7.0\",\n  },\n  {\n    emoji: \"🏳️‍🌈\",\n    aliases: [\"rainbow_flag\"],\n    tags: [\"pride\"],\n    category: \"Flags\",\n    description: \"rainbow flag\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏳️‍⚧️\",\n    aliases: [\"transgender_flag\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"transgender flag\",\n    unicode_version: \"13.0\",\n  },\n  {\n    emoji: \"🏴‍☠️\",\n    aliases: [\"pirate_flag\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"pirate flag\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇦🇨\",\n    aliases: [\"ascension_island\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ascension Island\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇦🇩\",\n    aliases: [\"andorra\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Andorra\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇪\",\n    aliases: [\"united_arab_emirates\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: United Arab Emirates\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇫\",\n    aliases: [\"afghanistan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Afghanistan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇬\",\n    aliases: [\"antigua_barbuda\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Antigua & Barbuda\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇮\",\n    aliases: [\"anguilla\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Anguilla\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇱\",\n    aliases: [\"albania\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Albania\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇲\",\n    aliases: [\"armenia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Armenia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇴\",\n    aliases: [\"angola\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Angola\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇶\",\n    aliases: [\"antarctica\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Antarctica\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇷\",\n    aliases: [\"argentina\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Argentina\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇸\",\n    aliases: [\"american_samoa\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: American Samoa\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇹\",\n    aliases: [\"austria\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Austria\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇺\",\n    aliases: [\"australia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Australia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇼\",\n    aliases: [\"aruba\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Aruba\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇽\",\n    aliases: [\"aland_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Åland Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇦🇿\",\n    aliases: [\"azerbaijan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Azerbaijan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇦\",\n    aliases: [\"bosnia_herzegovina\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bosnia & Herzegovina\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇧\",\n    aliases: [\"barbados\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Barbados\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇩\",\n    aliases: [\"bangladesh\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bangladesh\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇪\",\n    aliases: [\"belgium\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Belgium\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇫\",\n    aliases: [\"burkina_faso\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Burkina Faso\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇬\",\n    aliases: [\"bulgaria\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bulgaria\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇭\",\n    aliases: [\"bahrain\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bahrain\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇮\",\n    aliases: [\"burundi\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Burundi\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇯\",\n    aliases: [\"benin\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Benin\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇱\",\n    aliases: [\"st_barthelemy\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Barthélemy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇲\",\n    aliases: [\"bermuda\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bermuda\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇳\",\n    aliases: [\"brunei\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Brunei\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇴\",\n    aliases: [\"bolivia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bolivia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇶\",\n    aliases: [\"caribbean_netherlands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Caribbean Netherlands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇷\",\n    aliases: [\"brazil\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Brazil\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇸\",\n    aliases: [\"bahamas\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bahamas\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇹\",\n    aliases: [\"bhutan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bhutan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇻\",\n    aliases: [\"bouvet_island\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Bouvet Island\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇧🇼\",\n    aliases: [\"botswana\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Botswana\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇾\",\n    aliases: [\"belarus\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Belarus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇧🇿\",\n    aliases: [\"belize\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Belize\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇦\",\n    aliases: [\"canada\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Canada\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇨\",\n    aliases: [\"cocos_islands\"],\n    tags: [\"keeling\"],\n    category: \"Flags\",\n    description: \"flag: Cocos (Keeling) Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇩\",\n    aliases: [\"congo_kinshasa\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Congo - Kinshasa\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇫\",\n    aliases: [\"central_african_republic\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Central African Republic\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇬\",\n    aliases: [\"congo_brazzaville\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Congo - Brazzaville\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇭\",\n    aliases: [\"switzerland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Switzerland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇮\",\n    aliases: [\"cote_divoire\"],\n    tags: [\"ivory\"],\n    category: \"Flags\",\n    description: \"flag: Côte d’Ivoire\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇰\",\n    aliases: [\"cook_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cook Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇱\",\n    aliases: [\"chile\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Chile\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇲\",\n    aliases: [\"cameroon\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cameroon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇳\",\n    aliases: [\"cn\"],\n    tags: [\"china\"],\n    category: \"Flags\",\n    description: \"flag: China\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇴\",\n    aliases: [\"colombia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Colombia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇵\",\n    aliases: [\"clipperton_island\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Clipperton Island\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇨🇷\",\n    aliases: [\"costa_rica\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Costa Rica\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇺\",\n    aliases: [\"cuba\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cuba\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇻\",\n    aliases: [\"cape_verde\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cape Verde\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇼\",\n    aliases: [\"curacao\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Curaçao\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇽\",\n    aliases: [\"christmas_island\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Christmas Island\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇾\",\n    aliases: [\"cyprus\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cyprus\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇨🇿\",\n    aliases: [\"czech_republic\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Czechia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇪\",\n    aliases: [\"de\"],\n    tags: [\"flag\", \"germany\"],\n    category: \"Flags\",\n    description: \"flag: Germany\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇬\",\n    aliases: [\"diego_garcia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Diego Garcia\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇩🇯\",\n    aliases: [\"djibouti\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Djibouti\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇰\",\n    aliases: [\"denmark\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Denmark\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇲\",\n    aliases: [\"dominica\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Dominica\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇴\",\n    aliases: [\"dominican_republic\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Dominican Republic\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇩🇿\",\n    aliases: [\"algeria\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Algeria\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇦\",\n    aliases: [\"ceuta_melilla\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ceuta & Melilla\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇪🇨\",\n    aliases: [\"ecuador\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ecuador\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇪\",\n    aliases: [\"estonia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Estonia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇬\",\n    aliases: [\"egypt\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Egypt\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇭\",\n    aliases: [\"western_sahara\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Western Sahara\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇷\",\n    aliases: [\"eritrea\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Eritrea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇸\",\n    aliases: [\"es\"],\n    tags: [\"spain\"],\n    category: \"Flags\",\n    description: \"flag: Spain\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇹\",\n    aliases: [\"ethiopia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ethiopia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇪🇺\",\n    aliases: [\"eu\", \"european_union\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: European Union\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇮\",\n    aliases: [\"finland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Finland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇯\",\n    aliases: [\"fiji\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Fiji\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇰\",\n    aliases: [\"falkland_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Falkland Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇲\",\n    aliases: [\"micronesia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Micronesia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇴\",\n    aliases: [\"faroe_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Faroe Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇫🇷\",\n    aliases: [\"fr\"],\n    tags: [\"france\", \"french\"],\n    category: \"Flags\",\n    description: \"flag: France\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇦\",\n    aliases: [\"gabon\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Gabon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇧\",\n    aliases: [\"gb\", \"uk\"],\n    tags: [\"flag\", \"british\"],\n    category: \"Flags\",\n    description: \"flag: United Kingdom\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇩\",\n    aliases: [\"grenada\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Grenada\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇪\",\n    aliases: [\"georgia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Georgia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇫\",\n    aliases: [\"french_guiana\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: French Guiana\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇬\",\n    aliases: [\"guernsey\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guernsey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇭\",\n    aliases: [\"ghana\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ghana\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇮\",\n    aliases: [\"gibraltar\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Gibraltar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇱\",\n    aliases: [\"greenland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Greenland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇲\",\n    aliases: [\"gambia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Gambia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇳\",\n    aliases: [\"guinea\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guinea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇵\",\n    aliases: [\"guadeloupe\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guadeloupe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇶\",\n    aliases: [\"equatorial_guinea\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Equatorial Guinea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇷\",\n    aliases: [\"greece\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Greece\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇸\",\n    aliases: [\"south_georgia_south_sandwich_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: South Georgia & South Sandwich Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇹\",\n    aliases: [\"guatemala\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guatemala\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇺\",\n    aliases: [\"guam\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guam\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇼\",\n    aliases: [\"guinea_bissau\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guinea-Bissau\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇬🇾\",\n    aliases: [\"guyana\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Guyana\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇭🇰\",\n    aliases: [\"hong_kong\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Hong Kong SAR China\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇭🇲\",\n    aliases: [\"heard_mcdonald_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Heard & McDonald Islands\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇭🇳\",\n    aliases: [\"honduras\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Honduras\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇭🇷\",\n    aliases: [\"croatia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Croatia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇭🇹\",\n    aliases: [\"haiti\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Haiti\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇭🇺\",\n    aliases: [\"hungary\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Hungary\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇨\",\n    aliases: [\"canary_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Canary Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇩\",\n    aliases: [\"indonesia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Indonesia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇪\",\n    aliases: [\"ireland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ireland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇱\",\n    aliases: [\"israel\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Israel\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇲\",\n    aliases: [\"isle_of_man\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Isle of Man\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇳\",\n    aliases: [\"india\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: India\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇴\",\n    aliases: [\"british_indian_ocean_territory\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: British Indian Ocean Territory\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇶\",\n    aliases: [\"iraq\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Iraq\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇷\",\n    aliases: [\"iran\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Iran\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇸\",\n    aliases: [\"iceland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Iceland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇮🇹\",\n    aliases: [\"it\"],\n    tags: [\"italy\"],\n    category: \"Flags\",\n    description: \"flag: Italy\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇯🇪\",\n    aliases: [\"jersey\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Jersey\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇯🇲\",\n    aliases: [\"jamaica\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Jamaica\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇯🇴\",\n    aliases: [\"jordan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Jordan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇯🇵\",\n    aliases: [\"jp\"],\n    tags: [\"japan\"],\n    category: \"Flags\",\n    description: \"flag: Japan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇪\",\n    aliases: [\"kenya\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kenya\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇬\",\n    aliases: [\"kyrgyzstan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kyrgyzstan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇭\",\n    aliases: [\"cambodia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cambodia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇮\",\n    aliases: [\"kiribati\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kiribati\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇲\",\n    aliases: [\"comoros\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Comoros\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇳\",\n    aliases: [\"st_kitts_nevis\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Kitts & Nevis\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇵\",\n    aliases: [\"north_korea\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: North Korea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇷\",\n    aliases: [\"kr\"],\n    tags: [\"korea\"],\n    category: \"Flags\",\n    description: \"flag: South Korea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇼\",\n    aliases: [\"kuwait\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kuwait\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇾\",\n    aliases: [\"cayman_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Cayman Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇰🇿\",\n    aliases: [\"kazakhstan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kazakhstan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇦\",\n    aliases: [\"laos\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Laos\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇧\",\n    aliases: [\"lebanon\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Lebanon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇨\",\n    aliases: [\"st_lucia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Lucia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇮\",\n    aliases: [\"liechtenstein\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Liechtenstein\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇰\",\n    aliases: [\"sri_lanka\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Sri Lanka\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇷\",\n    aliases: [\"liberia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Liberia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇸\",\n    aliases: [\"lesotho\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Lesotho\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇹\",\n    aliases: [\"lithuania\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Lithuania\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇺\",\n    aliases: [\"luxembourg\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Luxembourg\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇻\",\n    aliases: [\"latvia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Latvia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇱🇾\",\n    aliases: [\"libya\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Libya\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇦\",\n    aliases: [\"morocco\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Morocco\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇨\",\n    aliases: [\"monaco\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Monaco\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇩\",\n    aliases: [\"moldova\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Moldova\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇪\",\n    aliases: [\"montenegro\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Montenegro\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇫\",\n    aliases: [\"st_martin\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Martin\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇲🇬\",\n    aliases: [\"madagascar\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Madagascar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇭\",\n    aliases: [\"marshall_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Marshall Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇰\",\n    aliases: [\"macedonia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: North Macedonia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇱\",\n    aliases: [\"mali\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mali\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇲\",\n    aliases: [\"myanmar\"],\n    tags: [\"burma\"],\n    category: \"Flags\",\n    description: \"flag: Myanmar (Burma)\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇳\",\n    aliases: [\"mongolia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mongolia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇴\",\n    aliases: [\"macau\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Macao SAR China\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇵\",\n    aliases: [\"northern_mariana_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Northern Mariana Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇶\",\n    aliases: [\"martinique\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Martinique\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇷\",\n    aliases: [\"mauritania\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mauritania\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇸\",\n    aliases: [\"montserrat\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Montserrat\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇹\",\n    aliases: [\"malta\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Malta\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇺\",\n    aliases: [\"mauritius\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mauritius\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇻\",\n    aliases: [\"maldives\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Maldives\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇼\",\n    aliases: [\"malawi\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Malawi\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇽\",\n    aliases: [\"mexico\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mexico\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇾\",\n    aliases: [\"malaysia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Malaysia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇲🇿\",\n    aliases: [\"mozambique\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mozambique\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇦\",\n    aliases: [\"namibia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Namibia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇨\",\n    aliases: [\"new_caledonia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: New Caledonia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇪\",\n    aliases: [\"niger\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Niger\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇫\",\n    aliases: [\"norfolk_island\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Norfolk Island\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇬\",\n    aliases: [\"nigeria\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Nigeria\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇮\",\n    aliases: [\"nicaragua\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Nicaragua\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇱\",\n    aliases: [\"netherlands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Netherlands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇴\",\n    aliases: [\"norway\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Norway\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇵\",\n    aliases: [\"nepal\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Nepal\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇷\",\n    aliases: [\"nauru\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Nauru\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇺\",\n    aliases: [\"niue\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Niue\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇳🇿\",\n    aliases: [\"new_zealand\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: New Zealand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇴🇲\",\n    aliases: [\"oman\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Oman\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇦\",\n    aliases: [\"panama\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Panama\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇪\",\n    aliases: [\"peru\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Peru\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇫\",\n    aliases: [\"french_polynesia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: French Polynesia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇬\",\n    aliases: [\"papua_new_guinea\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Papua New Guinea\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇭\",\n    aliases: [\"philippines\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Philippines\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇰\",\n    aliases: [\"pakistan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Pakistan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇱\",\n    aliases: [\"poland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Poland\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇲\",\n    aliases: [\"st_pierre_miquelon\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Pierre & Miquelon\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇳\",\n    aliases: [\"pitcairn_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Pitcairn Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇷\",\n    aliases: [\"puerto_rico\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Puerto Rico\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇸\",\n    aliases: [\"palestinian_territories\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Palestinian Territories\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇹\",\n    aliases: [\"portugal\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Portugal\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇼\",\n    aliases: [\"palau\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Palau\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇵🇾\",\n    aliases: [\"paraguay\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Paraguay\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇶🇦\",\n    aliases: [\"qatar\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Qatar\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇷🇪\",\n    aliases: [\"reunion\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Réunion\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇷🇴\",\n    aliases: [\"romania\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Romania\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇷🇸\",\n    aliases: [\"serbia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Serbia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇷🇺\",\n    aliases: [\"ru\"],\n    tags: [\"russia\"],\n    category: \"Flags\",\n    description: \"flag: Russia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇷🇼\",\n    aliases: [\"rwanda\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Rwanda\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇦\",\n    aliases: [\"saudi_arabia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Saudi Arabia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇧\",\n    aliases: [\"solomon_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Solomon Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇨\",\n    aliases: [\"seychelles\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Seychelles\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇩\",\n    aliases: [\"sudan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Sudan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇪\",\n    aliases: [\"sweden\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Sweden\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇬\",\n    aliases: [\"singapore\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Singapore\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇭\",\n    aliases: [\"st_helena\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Helena\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇮\",\n    aliases: [\"slovenia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Slovenia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇯\",\n    aliases: [\"svalbard_jan_mayen\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Svalbard & Jan Mayen\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇸🇰\",\n    aliases: [\"slovakia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Slovakia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇱\",\n    aliases: [\"sierra_leone\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Sierra Leone\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇲\",\n    aliases: [\"san_marino\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: San Marino\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇳\",\n    aliases: [\"senegal\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Senegal\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇴\",\n    aliases: [\"somalia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Somalia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇷\",\n    aliases: [\"suriname\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Suriname\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇸\",\n    aliases: [\"south_sudan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: South Sudan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇹\",\n    aliases: [\"sao_tome_principe\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: São Tomé & Príncipe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇻\",\n    aliases: [\"el_salvador\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: El Salvador\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇽\",\n    aliases: [\"sint_maarten\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Sint Maarten\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇾\",\n    aliases: [\"syria\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Syria\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇸🇿\",\n    aliases: [\"swaziland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Eswatini\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇦\",\n    aliases: [\"tristan_da_cunha\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tristan da Cunha\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇹🇨\",\n    aliases: [\"turks_caicos_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Turks & Caicos Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇩\",\n    aliases: [\"chad\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Chad\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇫\",\n    aliases: [\"french_southern_territories\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: French Southern Territories\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇬\",\n    aliases: [\"togo\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Togo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇭\",\n    aliases: [\"thailand\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Thailand\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇯\",\n    aliases: [\"tajikistan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tajikistan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇰\",\n    aliases: [\"tokelau\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tokelau\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇱\",\n    aliases: [\"timor_leste\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Timor-Leste\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇲\",\n    aliases: [\"turkmenistan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Turkmenistan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇳\",\n    aliases: [\"tunisia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tunisia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇴\",\n    aliases: [\"tonga\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tonga\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇷\",\n    aliases: [\"tr\"],\n    tags: [\"turkey\"],\n    category: \"Flags\",\n    description: \"flag: Turkey\",\n    unicode_version: \"8.0\",\n  },\n  {\n    emoji: \"🇹🇹\",\n    aliases: [\"trinidad_tobago\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Trinidad & Tobago\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇻\",\n    aliases: [\"tuvalu\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tuvalu\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇼\",\n    aliases: [\"taiwan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Taiwan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇹🇿\",\n    aliases: [\"tanzania\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Tanzania\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇺🇦\",\n    aliases: [\"ukraine\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Ukraine\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇺🇬\",\n    aliases: [\"uganda\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Uganda\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇺🇲\",\n    aliases: [\"us_outlying_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: U.S. Outlying Islands\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇺🇳\",\n    aliases: [\"united_nations\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: United Nations\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🇺🇸\",\n    aliases: [\"us\"],\n    tags: [\"flag\", \"united\", \"america\"],\n    category: \"Flags\",\n    description: \"flag: United States\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇺🇾\",\n    aliases: [\"uruguay\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Uruguay\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇺🇿\",\n    aliases: [\"uzbekistan\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Uzbekistan\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇦\",\n    aliases: [\"vatican_city\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Vatican City\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇨\",\n    aliases: [\"st_vincent_grenadines\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: St. Vincent & Grenadines\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇪\",\n    aliases: [\"venezuela\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Venezuela\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇬\",\n    aliases: [\"british_virgin_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: British Virgin Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇮\",\n    aliases: [\"us_virgin_islands\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: U.S. Virgin Islands\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇳\",\n    aliases: [\"vietnam\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Vietnam\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇻🇺\",\n    aliases: [\"vanuatu\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Vanuatu\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇼🇫\",\n    aliases: [\"wallis_futuna\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Wallis & Futuna\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇼🇸\",\n    aliases: [\"samoa\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Samoa\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇽🇰\",\n    aliases: [\"kosovo\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Kosovo\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇾🇪\",\n    aliases: [\"yemen\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Yemen\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇾🇹\",\n    aliases: [\"mayotte\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Mayotte\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇿🇦\",\n    aliases: [\"south_africa\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: South Africa\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇿🇲\",\n    aliases: [\"zambia\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Zambia\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🇿🇼\",\n    aliases: [\"zimbabwe\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Zimbabwe\",\n    unicode_version: \"6.0\",\n  },\n  {\n    emoji: \"🏴󠁧󠁢󠁥󠁮󠁧󠁿\",\n    aliases: [\"england\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: England\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\n    aliases: [\"scotland\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Scotland\",\n    unicode_version: \"11.0\",\n  },\n  {\n    emoji: \"🏴󠁧󠁢󠁷󠁬󠁳󠁿\",\n    aliases: [\"wales\"],\n    tags: [],\n    category: \"Flags\",\n    description: \"flag: Wales\",\n    unicode_version: \"11.0\",\n  },\n];\n"
  },
  {
    "path": "web/src/app/emojisMapped.js",
    "content": "import { rawEmojis } from \"./emojis\";\n\n// Format emojis (see emoji.js)\nexport default Object.fromEntries(rawEmojis.flatMap((emoji) => emoji.aliases.map((alias) => [alias, emoji.emoji])));\n"
  },
  {
    "path": "web/src/app/errors.js",
    "content": "/* eslint-disable max-classes-per-file */\n// This is a subset of, and the counterpart to errors.go\n\nconst maybeToJson = async (response) => {\n  try {\n    return await response.json();\n  } catch (e) {\n    return null;\n  }\n};\n\nexport class UnauthorizedError extends Error {\n  constructor() {\n    super(\"Unauthorized\");\n  }\n}\n\nexport class UserExistsError extends Error {\n  static CODE = 40901; // errHTTPConflictUserExists\n\n  constructor() {\n    super(\"Username already exists\");\n  }\n}\n\nexport class TopicReservedError extends Error {\n  static CODE = 40902; // errHTTPConflictTopicReserved\n\n  constructor() {\n    super(\"Topic already reserved\");\n  }\n}\n\nexport class AccountCreateLimitReachedError extends Error {\n  static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation\n\n  constructor() {\n    super(\"Account creation limit reached\");\n  }\n}\n\nexport class IncorrectPasswordError extends Error {\n  static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation\n\n  constructor() {\n    super(\"Password incorrect\");\n  }\n}\n\nexport const throwAppError = async (response) => {\n  if (response.status === 401 || response.status === 403) {\n    console.log(`[Error] HTTP ${response.status}`, response);\n    throw new UnauthorizedError();\n  }\n  const error = await maybeToJson(response);\n  if (error?.code) {\n    console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || \"\"}`, response);\n    if (error.code === UserExistsError.CODE) {\n      throw new UserExistsError();\n    } else if (error.code === TopicReservedError.CODE) {\n      throw new TopicReservedError();\n    } else if (error.code === AccountCreateLimitReachedError.CODE) {\n      throw new AccountCreateLimitReachedError();\n    } else if (error.code === IncorrectPasswordError.CODE) {\n      throw new IncorrectPasswordError();\n    } else if (error?.error) {\n      throw new Error(`Error ${error.code}: ${error.error}`);\n    }\n  }\n  console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);\n  throw new Error(`Unexpected response ${response.status}`);\n};\n\nexport const fetchOrThrow = async (url, options) => {\n  const response = await fetch(url, options);\n  if (response.status !== 200) {\n    await throwAppError(response);\n  }\n  return response; // Promise!\n};\n"
  },
  {
    "path": "web/src/app/events.js",
    "content": "// Event types for ntfy messages\n// These correspond to the server event types in server/types.go\n\nexport const EVENT_OPEN = \"open\";\nexport const EVENT_KEEPALIVE = \"keepalive\";\nexport const EVENT_MESSAGE = \"message\";\nexport const EVENT_MESSAGE_DELETE = \"message_delete\";\nexport const EVENT_MESSAGE_CLEAR = \"message_clear\";\nexport const EVENT_POLL_REQUEST = \"poll_request\";\n\nexport const WEBPUSH_EVENT_MESSAGE = \"message\";\nexport const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = \"subscription_expiring\";\n\n// Check if an event is a notification event (message, delete, or read)\nexport const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;\n"
  },
  {
    "path": "web/src/app/i18n.js",
    "content": "import i18next from \"i18next\";\nimport Backend from \"i18next-http-backend\";\nimport LanguageDetector from \"i18next-browser-languagedetector\";\nimport { initReactI18next } from \"react-i18next\";\n\n// Translations using i18next\n// - Options: https://www.i18next.com/overview/configuration-options\n// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector\n// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend\n//\n// See example project here:\n// https://github.com/i18next/react-i18next/tree/master/example/react\n\nconst initI18n = () =>\n  i18next\n    .use(Backend)\n    .use(LanguageDetector)\n    .use(initReactI18next)\n    .init({\n      fallbackLng: \"en\",\n      debug: true,\n      interpolation: {\n        escapeValue: false, // not needed for react as it escapes by default\n      },\n      backend: {\n        loadPath: \"/static/langs/{{lng}}.json\",\n      },\n    });\n\nexport default initI18n;\n"
  },
  {
    "path": "web/src/app/notificationUtils.js",
    "content": "// This is a separate file since the other utils import `config.js`, which depends on `window`\n// and cannot be used in the service worker\n\nimport emojisMapped from \"./emojisMapped\";\nimport { ACTION_HTTP, ACTION_VIEW } from \"./actions\";\n\nconst toEmojis = (tags) => {\n  if (!tags) return [];\n  return tags.filter((tag) => tag in emojisMapped).map((tag) => emojisMapped[tag]);\n};\n\nexport const formatTitle = (m) => {\n  const emojiList = toEmojis(m.tags);\n  if (emojiList.length > 0) {\n    return `${emojiList.join(\" \")} ${m.title}`;\n  }\n  return m.title;\n};\n\nconst formatTitleWithDefault = (m, fallback) => {\n  if (m.title) {\n    return formatTitle(m);\n  }\n  return fallback;\n};\n\nexport const formatMessage = (m) => {\n  if (m.title) {\n    return m.message || \"\";\n  }\n  const emojiList = toEmojis(m.tags);\n  if (emojiList.length > 0) {\n    return `${emojiList.join(\" \")} ${m.message || \"\"}`;\n  }\n  return m.message || \"\";\n};\n\nconst imageRegex = /\\.(png|jpe?g|gif|webp)$/i;\nexport const isImage = (attachment) => {\n  if (!attachment) return false;\n\n  // if there's a type, only take that into account\n  if (attachment.type) {\n    return attachment.type.startsWith(\"image/\");\n  }\n\n  // otherwise, check the extension\n  return attachment.name?.match(imageRegex) || attachment.url?.match(imageRegex);\n};\n\nexport const icon = \"/static/images/ntfy.png\";\nexport const badge = \"/static/images/mask-icon.svg\";\n\n/**\n * Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.\n * This ensures notifications from different topics with the same sequence ID don't collide.\n */\nexport const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;\n\nexport const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {\n  const image = isImage(message.attachment) ? message.attachment.url : undefined;\n  const sequenceId = message.sequence_id || message.id;\n  const tag = notificationTag(baseUrl, topic, sequenceId);\n  const subscriptionId = `${baseUrl}/${topic}`;\n\n  // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API\n  return [\n    formatTitleWithDefault(message, defaultTitle),\n    {\n      body: formatMessage(message),\n      badge,\n      icon,\n      image,\n      timestamp: message.time * 1000,\n      tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions\n      renotify: true,\n      silent: false,\n      // This is used by the notification onclick event\n      data: {\n        subscriptionId,\n        message,\n        topicRoute,\n      },\n      actions: message.actions\n        ?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP)\n        .map(({ label }) => ({\n          action: label,\n          title: label,\n        })),\n    },\n  ];\n};\n\nexport const messageWithSequenceId = (message) => {\n  if (message.sequenceId) {\n    return message;\n  }\n  return { ...message, sequenceId: message.sequence_id || message.id };\n};\n"
  },
  {
    "path": "web/src/app/utils.js",
    "content": "import { Base64 } from \"js-base64\";\nimport beep from \"../sounds/beep.mp3\";\nimport juntos from \"../sounds/juntos.mp3\";\nimport pristine from \"../sounds/pristine.mp3\";\nimport ding from \"../sounds/ding.mp3\";\nimport dadum from \"../sounds/dadum.mp3\";\nimport pop from \"../sounds/pop.mp3\";\nimport popSwoosh from \"../sounds/pop-swoosh.mp3\";\nimport config from \"./config\";\nimport emojisMapped from \"./emojisMapped\";\nimport { THEME } from \"./Prefs\";\n\nexport const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;\nexport const shortUrl = (url) => url.replaceAll(/https?:\\/\\//g, \"\");\nexport const expandUrl = (url) => [`https://${url}`, `http://${url}`];\nexport const expandSecureUrl = (url) => `https://${url}`;\nexport const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;\nexport const topicUrlWs = (baseUrl, topic) =>\n  `${topicUrl(baseUrl, topic)}/ws`.replaceAll(\"https://\", \"wss://\").replaceAll(\"http://\", \"ws://\");\nexport const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;\nexport const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;\nexport const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;\nexport const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;\nexport const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));\nexport const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`;\nexport const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;\nexport const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;\nexport const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;\nexport const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;\nexport const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;\nexport const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;\nexport const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;\nexport const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;\nexport const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;\nexport const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;\nexport const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;\n\nexport const validUrl = (url) => url.match(/^https?:\\/\\/.+/);\n\nexport const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);\n\nexport const validTopic = (topic) => {\n  if (disallowedTopic(topic)) {\n    return false;\n  }\n  return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!\n};\n\nexport const topicDisplayName = (subscription) => {\n  if (subscription.displayName) {\n    return subscription.displayName;\n  }\n  if (subscription.baseUrl === config.base_url) {\n    return subscription.topic;\n  }\n  return topicShortUrl(subscription.baseUrl, subscription.topic);\n};\n\nexport const unmatchedTags = (tags) => {\n  if (!tags) return [];\n  return tags.filter((tag) => !(tag in emojisMapped));\n};\n\nexport const encodeBase64 = (s) => Base64.encode(s);\n\nexport const encodeBase64Url = (s) => Base64.encodeURI(s);\n\nexport const bearerAuth = (token) => `Bearer ${token}`;\n\nexport const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;\n\nexport const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) });\n\nexport const maybeWithBearerAuth = (headers, token) => {\n  if (token) {\n    return withBearerAuth(headers, token);\n  }\n  return headers;\n};\n\nexport const withBasicAuth = (headers, username, password) => ({\n  ...headers,\n  Authorization: basicAuth(username, password),\n});\n\nexport const maybeWithAuth = (headers, user) => {\n  if (user?.password) {\n    return withBasicAuth(headers, user.username, user.password);\n  }\n  if (user?.token) {\n    return withBearerAuth(headers, user.token);\n  }\n  return headers;\n};\n\nexport const maybeActionErrors = (notification) => {\n  const actionErrors = (notification.actions ?? [])\n    .map((action) => action.error)\n    .filter((action) => !!action)\n    .join(\"\\n\");\n  if (actionErrors.length === 0) {\n    return undefined;\n  }\n  return actionErrors;\n};\n\nexport const shuffle = (arr) => {\n  const returnArr = [...arr];\n\n  for (let index = returnArr.length - 1; index > 0; index -= 1) {\n    const j = Math.floor(Math.random() * (index + 1));\n    [returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]];\n  }\n\n  return returnArr;\n};\n\nexport const splitNoEmpty = (s, delimiter) =>\n  s\n    .split(delimiter)\n    .map((x) => x.trim())\n    .filter((x) => x !== \"\");\n\n/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */\nexport const hashCode = (s) => {\n  let hash = 0;\n  for (let i = 0; i < s.length; i += 1) {\n    const char = s.charCodeAt(i);\n    // eslint-disable-next-line no-bitwise\n    hash = (hash << 5) - hash + char;\n    // eslint-disable-next-line no-bitwise\n    hash &= hash; // Convert to 32bit integer\n  }\n  return hash;\n};\n\n/**\n * convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`),\n * which is expected by `<html lang>` and `Intl.DateTimeFormat`\n */\nexport const getKebabCaseLangStr = (language) => language.replace(/_/g, \"-\");\n\nexport const formatShortDateTime = (timestamp, language) =>\n  new Intl.DateTimeFormat(getKebabCaseLangStr(language), {\n    dateStyle: \"short\",\n    timeStyle: \"short\",\n  }).format(new Date(timestamp * 1000));\n\nexport const formatShortDate = (timestamp, language) =>\n  new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: \"short\" }).format(new Date(timestamp * 1000));\n\nexport const formatBytes = (bytes, decimals = 2) => {\n  if (bytes === 0) return \"0 bytes\";\n  const k = 1024;\n  const dm = decimals < 0 ? 0 : decimals;\n  const sizes = [\"bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;\n};\n\nexport const formatNumber = (n) => {\n  if (n === 0) {\n    return n;\n  }\n  if (n % 1000 === 0) {\n    return `${n / 1000}k`;\n  }\n  return n.toLocaleString();\n};\n\nexport const formatPrice = (n) => {\n  if (n % 100 === 0) {\n    return `$${n / 100}`;\n  }\n  return `$${(n / 100).toPrecision(2)}`;\n};\n\nexport const openUrl = (url) => {\n  window.open(url, \"_blank\", \"noopener,noreferrer\");\n};\n\nexport const sounds = {\n  ding: {\n    file: ding,\n    label: \"Ding\",\n  },\n  juntos: {\n    file: juntos,\n    label: \"Juntos\",\n  },\n  pristine: {\n    file: pristine,\n    label: \"Pristine\",\n  },\n  dadum: {\n    file: dadum,\n    label: \"Dadum\",\n  },\n  pop: {\n    file: pop,\n    label: \"Pop\",\n  },\n  \"pop-swoosh\": {\n    file: popSwoosh,\n    label: \"Pop swoosh\",\n  },\n  beep: {\n    file: beep,\n    label: \"Beep\",\n  },\n};\n\nexport const playSound = async (id) => {\n  const audio = new Audio(sounds[id].file);\n  return audio.play();\n};\n\n// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch\n// eslint-disable-next-line func-style\nexport async function* fetchLinesIterator(fileURL, headers) {\n  const utf8Decoder = new TextDecoder(\"utf-8\");\n  const response = await fetch(fileURL, {\n    headers,\n  });\n  const reader = response.body.getReader();\n  let { value: chunk, done: readerDone } = await reader.read();\n  chunk = chunk ? utf8Decoder.decode(chunk) : \"\";\n\n  const re = /\\n|\\r|\\r\\n/gm;\n  let startIndex = 0;\n\n  for (;;) {\n    const result = re.exec(chunk);\n    if (!result) {\n      if (readerDone) {\n        break;\n      }\n      const remainder = chunk.substr(startIndex);\n      // eslint-disable-next-line no-await-in-loop\n      ({ value: chunk, done: readerDone } = await reader.read());\n      chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : \"\");\n      startIndex = 0;\n      re.lastIndex = 0;\n      // eslint-disable-next-line no-continue\n      continue;\n    }\n    yield chunk.substring(startIndex, result.index);\n    startIndex = re.lastIndex;\n  }\n  if (startIndex < chunk.length) {\n    yield chunk.substr(startIndex); // last line didn't end in a newline char\n  }\n}\n\nexport const randomAlphanumericString = (len) => {\n  const alphabet = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n  let id = \"\";\n  for (let i = 0; i < len; i += 1) {\n    // eslint-disable-next-line no-bitwise\n    id += alphabet[(Math.random() * alphabet.length) | 0];\n  }\n  return id;\n};\n\nexport const urlB64ToUint8Array = (base64String) => {\n  const padding = \"=\".repeat((4 - (base64String.length % 4)) % 4);\n  const base64 = (base64String + padding).replace(/-/g, \"+\").replace(/_/g, \"/\");\n\n  const rawData = window.atob(base64);\n  const outputArray = new Uint8Array(rawData.length);\n\n  for (let i = 0; i < rawData.length; i += 1) {\n    outputArray[i] = rawData.charCodeAt(i);\n  }\n  return outputArray;\n};\n\nexport const darkModeEnabled = (prefersDarkMode, themePreference) => {\n  switch (themePreference) {\n    case THEME.DARK:\n      return true;\n\n    case THEME.LIGHT:\n      return false;\n\n    case THEME.SYSTEM:\n    default:\n      return prefersDarkMode;\n  }\n};\n\n// Canvas-based favicon with a red notification dot when there are unread messages\nlet faviconCanvas;\nlet faviconOriginalIcon;\n\nconst loadFaviconIcon = () =>\n  new Promise((resolve) => {\n    if (faviconOriginalIcon) {\n      resolve(faviconOriginalIcon);\n      return;\n    }\n    const img = new Image();\n    img.onload = () => {\n      faviconOriginalIcon = img;\n      resolve(img);\n    };\n    img.onerror = () => resolve(null);\n    // Use PNG instead of ICO — .ico files can't be reliably drawn to canvas in all browsers\n    img.src = \"/static/images/ntfy.png\";\n  });\n\nexport const updateFavicon = async (count) => {\n  const size = 32;\n  const img = await loadFaviconIcon();\n  if (!img) {\n    return;\n  }\n\n  if (!faviconCanvas) {\n    faviconCanvas = document.createElement(\"canvas\");\n    faviconCanvas.width = size;\n    faviconCanvas.height = size;\n  }\n\n  const ctx = faviconCanvas.getContext(\"2d\");\n  ctx.clearRect(0, 0, size, size);\n  ctx.drawImage(img, 0, 0, size, size);\n\n  if (count > 0) {\n    const dotRadius = 5;\n    const borderWidth = 2;\n    const dotX = size - dotRadius - borderWidth + 1;\n    const dotY = size - dotRadius - borderWidth + 1;\n\n    // Transparent border: erase a ring around the dot so the icon doesn't bleed into it\n    ctx.save();\n    ctx.globalCompositeOperation = \"destination-out\";\n    ctx.beginPath();\n    ctx.arc(dotX, dotY, dotRadius + borderWidth, 0, 2 * Math.PI);\n    ctx.fill();\n    ctx.restore();\n\n    // Red dot\n    ctx.beginPath();\n    ctx.arc(dotX, dotY, dotRadius, 0, 2 * Math.PI);\n    ctx.fillStyle = \"#dc3545\";\n    ctx.fill();\n  }\n\n  const link = document.querySelector(\"link[rel='icon']\");\n  if (link) {\n    link.href = faviconCanvas.toDataURL(\"image/png\");\n  }\n};\n\nexport const copyToClipboard = (text) => {\n  if (navigator.clipboard && window.isSecureContext) {\n    return navigator.clipboard.writeText(text);\n  }\n  // Fallback to the older method if clipboard API is not supported (or on HTTP)\n  const textarea = document.createElement(\"textarea\");\n  textarea.value = text;\n  textarea.setAttribute(\"readonly\", \"\"); // Avoid mobile keyboards from popping up\n  textarea.style.position = \"fixed\"; // Avoid scroll jump\n  textarea.style.left = \"-9999px\";\n  document.body.appendChild(textarea);\n  textarea.focus();\n  textarea.select();\n  document.execCommand(\"copy\");\n  document.body.removeChild(textarea);\n  return Promise.resolve();\n};\n"
  },
  {
    "path": "web/src/components/Account.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useState } from \"react\";\nimport {\n  Alert,\n  CardActions,\n  CardContent,\n  Chip,\n  FormControl,\n  FormControlLabel,\n  LinearProgress,\n  Link,\n  Portal,\n  Radio,\n  RadioGroup,\n  Select,\n  Snackbar,\n  Stack,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableRow,\n  useMediaQuery,\n  Tooltip,\n  Typography,\n  Container,\n  Card,\n  Button,\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  TextField,\n  IconButton,\n  MenuItem,\n  DialogContentText,\n  useTheme,\n} from \"@mui/material\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport DeleteOutlineIcon from \"@mui/icons-material/DeleteOutline\";\nimport InfoOutlinedIcon from \"@mui/icons-material/InfoOutlined\";\nimport humanizeDuration from \"humanize-duration\";\nimport CelebrationIcon from \"@mui/icons-material/Celebration\";\nimport CloseIcon from \"@mui/icons-material/Close\";\nimport { ContentCopy, Public } from \"@mui/icons-material\";\nimport AddIcon from \"@mui/icons-material/Add\";\nimport routes from \"./routes\";\nimport { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from \"../app/utils\";\nimport accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from \"../app/AccountApi\";\nimport { Pref, PrefGroup } from \"./Pref\";\nimport db from \"../app/db\";\nimport UpgradeDialog from \"./UpgradeDialog\";\nimport { AccountContext } from \"./App\";\nimport DialogFooter from \"./DialogFooter\";\nimport { Paragraph } from \"./styles\";\nimport { IncorrectPasswordError, UnauthorizedError } from \"../app/errors\";\nimport { ProChip } from \"./SubscriptionPopup\";\nimport session from \"../app/Session\";\n\nconst Account = () => {\n  if (!session.exists()) {\n    window.location.href = routes.app;\n    return <></>;\n  }\n  return (\n    <Container maxWidth=\"md\" sx={{ marginTop: 3, marginBottom: 3 }}>\n      <Stack spacing={3}>\n        <Basics />\n        <Stats />\n        <Tokens />\n        <Delete />\n      </Stack>\n    </Container>\n  );\n};\n\nconst Basics = () => {\n  const { t } = useTranslation();\n  return (\n    <Card sx={{ p: 3 }} aria-label={t(\"account_basics_title\")}>\n      <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n        {t(\"account_basics_title\")}\n      </Typography>\n      <PrefGroup>\n        <Username />\n        <ChangePassword />\n        <PhoneNumbers />\n        <AccountType />\n      </PrefGroup>\n    </Card>\n  );\n};\n\nconst Username = () => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const labelId = \"prefUsername\";\n\n  return (\n    <Pref labelId={labelId} title={t(\"account_basics_username_title\")} description={t(\"account_basics_username_description\")}>\n      <div aria-labelledby={labelId}>\n        {session.username()}\n        {account?.role === Role.ADMIN && (\n          <>\n            {\" \"}\n            <Tooltip title={t(\"account_basics_username_admin_tooltip\")}>\n              <span style={{ cursor: \"default\" }}>👑</span>\n            </Tooltip>\n          </>\n        )}\n      </div>\n    </Pref>\n  );\n};\n\nconst ChangePassword = () => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const { account } = useContext(AccountContext);\n  const labelId = \"prefChangePassword\";\n\n  const handleDialogOpen = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n\n  const handleDialogClose = () => {\n    setDialogOpen(false);\n  };\n\n  return (\n    <Pref labelId={labelId} title={t(\"account_basics_password_title\")} description={t(\"account_basics_password_description\")}>\n      <div aria-labelledby={labelId}>\n        <Typography color=\"gray\" sx={{ float: \"left\", fontSize: \"0.7rem\", lineHeight: \"3.5\" }}>\n          ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤\n        </Typography>\n        {!account?.provisioned ? (\n          <Tooltip title={t(\"account_basics_password_description\")}>\n            <IconButton onClick={handleDialogOpen} aria-label={t(\"account_basics_password_description\")}>\n              <EditIcon />\n            </IconButton>\n          </Tooltip>\n        ) : (\n          <Tooltip title={t(\"account_basics_cannot_edit_or_delete_provisioned_user\")}>\n            <span>\n              <IconButton disabled>\n                <EditIcon />\n              </IconButton>\n            </span>\n          </Tooltip>\n        )}\n      </div>\n      <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />\n    </Pref>\n  );\n};\n\nconst ChangePasswordDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [currentPassword, setCurrentPassword] = useState(\"\");\n  const [newPassword, setNewPassword] = useState(\"\");\n  const [confirmPassword, setConfirmPassword] = useState(\"\");\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleDialogSubmit = async () => {\n    try {\n      console.debug(`[Account] Changing password`);\n      await accountApi.changePassword(currentPassword, newPassword);\n      props.onClose();\n    } catch (e) {\n      console.log(`[Account] Error changing password`, e);\n      if (e instanceof IncorrectPasswordError) {\n        setError(t(\"account_basics_password_dialog_current_password_incorrect\"));\n      } else if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>\n      <DialogTitle>{t(\"account_basics_password_dialog_title\")}</DialogTitle>\n      <DialogContent>\n        <TextField\n          margin=\"dense\"\n          id=\"current-password\"\n          label={t(\"account_basics_password_dialog_current_password_label\")}\n          aria-label={t(\"account_basics_password_dialog_current_password_label\")}\n          type=\"password\"\n          value={currentPassword}\n          onChange={(ev) => setCurrentPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n        <TextField\n          margin=\"dense\"\n          id=\"new-password\"\n          label={t(\"account_basics_password_dialog_new_password_label\")}\n          aria-label={t(\"account_basics_password_dialog_new_password_label\")}\n          type=\"password\"\n          value={newPassword}\n          onChange={(ev) => setNewPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n        <TextField\n          margin=\"dense\"\n          id=\"confirm\"\n          label={t(\"account_basics_password_dialog_confirm_password_label\")}\n          aria-label={t(\"account_basics_password_dialog_confirm_password_label\")}\n          type=\"password\"\n          value={confirmPassword}\n          onChange={(ev) => setConfirmPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button\n          onClick={handleDialogSubmit}\n          disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}\n        >\n          {t(\"account_basics_password_dialog_button_submit\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nconst AccountType = () => {\n  const { t, i18n } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);\n  const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);\n  const [showPortalError, setShowPortalError] = useState(false);\n\n  if (!account) {\n    return <></>;\n  }\n\n  const handleUpgradeClick = () => {\n    setUpgradeDialogKey((k) => k + 1);\n    setUpgradeDialogOpen(true);\n  };\n\n  const handleManageBilling = async () => {\n    try {\n      const response = await accountApi.createBillingPortalSession();\n      window.open(response.redirect_url, \"billing_portal\");\n    } catch (e) {\n      console.log(`[Account] Error opening billing portal`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setShowPortalError(true);\n      }\n    }\n  };\n\n  let accountType;\n  if (account.role === Role.ADMIN) {\n    const tierSuffix = account.tier\n      ? t(\"account_basics_tier_admin_suffix_with_tier\", {\n          tier: account.tier.name,\n        })\n      : t(\"account_basics_tier_admin_suffix_no_tier\");\n    accountType = `${t(\"account_basics_tier_admin\")} ${tierSuffix}`;\n  } else if (!account.tier) {\n    accountType = config.enable_payments ? t(\"account_basics_tier_free\") : t(\"account_basics_tier_basic\");\n  } else {\n    accountType = account.tier.name;\n    if (account.billing?.interval === SubscriptionInterval.MONTH) {\n      accountType += ` (${t(\"account_basics_tier_interval_monthly\")})`;\n    } else if (account.billing?.interval === SubscriptionInterval.YEAR) {\n      accountType += ` (${t(\"account_basics_tier_interval_yearly\")})`;\n    }\n  }\n\n  return (\n    <Pref\n      alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}\n      title={t(\"account_basics_tier_title\")}\n      description={t(\"account_basics_tier_description\")}\n    >\n      <div>\n        {accountType}\n        {account.billing?.paid_until && !account.billing?.cancel_at && (\n          <Tooltip\n            title={t(\"account_basics_tier_paid_until\", {\n              date: formatShortDate(account.billing?.paid_until, i18n.language),\n            })}\n          >\n            <span>\n              <InfoIcon />\n            </span>\n          </Tooltip>\n        )}\n        {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && (\n          <Button\n            variant=\"outlined\"\n            size=\"small\"\n            startIcon={<CelebrationIcon sx={{ color: \"#55b86e\" }} />}\n            onClick={handleUpgradeClick}\n            sx={{ ml: 1 }}\n          >\n            {t(\"account_basics_tier_upgrade_button\")}\n          </Button>\n        )}\n        {config.enable_payments && account.role === Role.USER && account.billing?.subscription && (\n          <Button variant=\"outlined\" size=\"small\" onClick={handleUpgradeClick} sx={{ ml: 1 }}>\n            {t(\"account_basics_tier_change_button\")}\n          </Button>\n        )}\n        {config.enable_payments && account.role === Role.USER && account.billing?.customer && (\n          <Button variant=\"outlined\" size=\"small\" onClick={handleManageBilling} sx={{ ml: 1 }}>\n            {t(\"account_basics_tier_manage_billing_button\")}\n          </Button>\n        )}\n        {config.enable_payments && (\n          <UpgradeDialog\n            key={`upgradeDialogFromAccount${upgradeDialogKey}`}\n            open={upgradeDialogOpen}\n            onCancel={() => setUpgradeDialogOpen(false)}\n          />\n        )}\n      </div>\n      {account.billing?.status === SubscriptionStatus.PAST_DUE && (\n        <Alert severity=\"error\" sx={{ mt: 1 }}>\n          {t(\"account_basics_tier_payment_overdue\")}\n        </Alert>\n      )}\n      {account.billing?.cancel_at > 0 && (\n        <Alert severity=\"warning\" sx={{ mt: 1 }}>\n          {t(\"account_basics_tier_canceled_subscription\", {\n            date: formatShortDate(account.billing.cancel_at, i18n.language),\n          })}\n        </Alert>\n      )}\n      <Portal>\n        <Snackbar\n          open={showPortalError}\n          autoHideDuration={3000}\n          onClose={() => setShowPortalError(false)}\n          message={t(\"account_usage_cannot_create_portal_session\")}\n        />\n      </Portal>\n    </Pref>\n  );\n};\n\nconst PhoneNumbers = () => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const [snackOpen, setSnackOpen] = useState(false);\n  const labelId = \"prefPhoneNumbers\";\n\n  const handleDialogOpen = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n\n  const handleDialogClose = () => {\n    setDialogOpen(false);\n  };\n\n  const handleCopy = (phoneNumber) => {\n    copyToClipboard(phoneNumber);\n    setSnackOpen(true);\n  };\n\n  const handleDelete = async (phoneNumber) => {\n    try {\n      await accountApi.deletePhoneNumber(phoneNumber);\n    } catch (e) {\n      console.log(`[Account] Error deleting phone number`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      }\n    }\n  };\n\n  if (!config.enable_calls) {\n    return null;\n  }\n\n  if (account?.limits.calls === 0) {\n    return (\n      <Pref\n        title={\n          <>\n            {t(\"account_basics_phone_numbers_title\")}\n            {config.enable_payments && <ProChip />}\n          </>\n        }\n        description={t(\"account_basics_phone_numbers_description\")}\n      >\n        <em>{t(\"account_usage_calls_none\")}</em>\n      </Pref>\n    );\n  }\n\n  return (\n    <Pref labelId={labelId} title={t(\"account_basics_phone_numbers_title\")} description={t(\"account_basics_phone_numbers_description\")}>\n      <div aria-labelledby={labelId}>\n        {account?.phone_numbers?.map((phoneNumber) => (\n          <Chip\n            label={\n              <Tooltip title={t(\"common_copy_to_clipboard\")}>\n                <span>{phoneNumber}</span>\n              </Tooltip>\n            }\n            variant=\"outlined\"\n            onClick={() => handleCopy(phoneNumber)}\n            onDelete={() => handleDelete(phoneNumber)}\n          />\n        ))}\n        {!account?.phone_numbers && <em>{t(\"account_basics_phone_numbers_no_phone_numbers_yet\")}</em>}\n        <IconButton onClick={handleDialogOpen}>\n          <AddIcon />\n        </IconButton>\n      </div>\n      <AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />\n      <Portal>\n        <Snackbar\n          open={snackOpen}\n          autoHideDuration={3000}\n          onClose={() => setSnackOpen(false)}\n          message={t(\"account_basics_phone_numbers_copied_to_clipboard\")}\n        />\n      </Portal>\n    </Pref>\n  );\n};\n\nconst AddPhoneNumberDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [phoneNumber, setPhoneNumber] = useState(\"\");\n  const [channel, setChannel] = useState(\"sms\");\n  const [code, setCode] = useState(\"\");\n  const [sending, setSending] = useState(false);\n  const [verificationCodeSent, setVerificationCodeSent] = useState(false);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const verifyPhone = async () => {\n    try {\n      setSending(true);\n      await accountApi.verifyPhoneNumber(phoneNumber, channel);\n      setVerificationCodeSent(true);\n    } catch (e) {\n      console.log(`[Account] Error sending verification`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    } finally {\n      setSending(false);\n    }\n  };\n\n  const checkVerifyPhone = async () => {\n    try {\n      setSending(true);\n      await accountApi.addPhoneNumber(phoneNumber, code);\n      props.onClose();\n    } catch (e) {\n      console.log(`[Account] Error confirming verification`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    } finally {\n      setSending(false);\n    }\n  };\n\n  const handleDialogSubmit = async () => {\n    if (!verificationCodeSent) {\n      await verifyPhone();\n    } else {\n      await checkVerifyPhone();\n    }\n  };\n\n  const handleCancel = () => {\n    if (verificationCodeSent) {\n      setVerificationCodeSent(false);\n      setCode(\"\");\n    } else {\n      props.onClose();\n    }\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>\n      <DialogTitle>{t(\"account_basics_phone_numbers_dialog_title\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"account_basics_phone_numbers_dialog_description\")}</DialogContentText>\n        {!verificationCodeSent && (\n          <div style={{ display: \"flex\" }}>\n            <TextField\n              margin=\"dense\"\n              label={t(\"account_basics_phone_numbers_dialog_number_label\")}\n              aria-label={t(\"account_basics_phone_numbers_dialog_number_label\")}\n              placeholder={t(\"account_basics_phone_numbers_dialog_number_placeholder\")}\n              type=\"tel\"\n              value={phoneNumber}\n              onChange={(ev) => setPhoneNumber(ev.target.value)}\n              inputProps={{ inputMode: \"tel\", pattern: \"+[0-9]*\" }}\n              variant=\"standard\"\n              sx={{ flexGrow: 1 }}\n            />\n            <FormControl sx={{ flexWrap: \"nowrap\" }}>\n              <RadioGroup row sx={{ flexGrow: 1, marginTop: \"8px\", marginLeft: \"5px\" }}>\n                <FormControlLabel\n                  value=\"sms\"\n                  control={<Radio checked={channel === \"sms\"} onChange={(e) => setChannel(e.target.value)} />}\n                  label={t(\"account_basics_phone_numbers_dialog_channel_sms\")}\n                />\n                <FormControlLabel\n                  value=\"call\"\n                  control={<Radio checked={channel === \"call\"} onChange={(e) => setChannel(e.target.value)} />}\n                  label={t(\"account_basics_phone_numbers_dialog_channel_call\")}\n                  sx={{ marginRight: 0 }}\n                />\n              </RadioGroup>\n            </FormControl>\n          </div>\n        )}\n        {verificationCodeSent && (\n          <TextField\n            margin=\"dense\"\n            label={t(\"account_basics_phone_numbers_dialog_code_label\")}\n            aria-label={t(\"account_basics_phone_numbers_dialog_code_label\")}\n            placeholder={t(\"account_basics_phone_numbers_dialog_code_placeholder\")}\n            type=\"text\"\n            value={code}\n            onChange={(ev) => setCode(ev.target.value)}\n            fullWidth\n            inputProps={{ inputMode: \"numeric\", pattern: \"[0-9]*\" }}\n            variant=\"standard\"\n          />\n        )}\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={handleCancel}>{verificationCodeSent ? t(\"common_back\") : t(\"common_cancel\")}</Button>\n        <Button onClick={handleDialogSubmit} disabled={sending || !/^\\+\\d+$/.test(phoneNumber)}>\n          {!verificationCodeSent && channel === \"sms\" && t(\"account_basics_phone_numbers_dialog_verify_button_sms\")}\n          {!verificationCodeSent && channel === \"call\" && t(\"account_basics_phone_numbers_dialog_verify_button_call\")}\n          {verificationCodeSent && t(\"account_basics_phone_numbers_dialog_check_verification_button\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nconst Stats = () => {\n  const { t, i18n } = useTranslation();\n  const { account } = useContext(AccountContext);\n\n  if (!account) {\n    return <></>;\n  }\n\n  const normalize = (value, max) => Math.min((value / max) * 100, 100);\n\n  return (\n    <Card sx={{ p: 3 }} aria-label={t(\"account_usage_title\")}>\n      <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n        {t(\"account_usage_title\")}\n      </Typography>\n      <PrefGroup>\n        {(account.role === Role.ADMIN || account.limits.reservations > 0) && (\n          <Pref title={t(\"account_usage_reservations_title\")}>\n            <div>\n              <Typography variant=\"body2\" sx={{ float: \"left\" }}>\n                {account.stats.reservations.toLocaleString()}\n              </Typography>\n              <Typography variant=\"body2\" sx={{ float: \"right\" }}>\n                {account.role === Role.USER\n                  ? t(\"account_usage_of_limit\", {\n                      limit: account.limits.reservations.toLocaleString(),\n                    })\n                  : t(\"account_usage_unlimited\")}\n              </Typography>\n            </div>\n            <LinearProgress\n              variant=\"determinate\"\n              value={\n                account.role === Role.USER && account.limits.reservations > 0\n                  ? normalize(account.stats.reservations, account.limits.reservations)\n                  : 100\n              }\n            />\n          </Pref>\n        )}\n        <Pref\n          title={\n            <>\n              {t(\"account_usage_messages_title\")}\n              <Tooltip title={t(\"account_usage_limits_reset_daily\")}>\n                <span>\n                  <InfoIcon />\n                </span>\n              </Tooltip>\n            </>\n          }\n        >\n          <div>\n            <Typography variant=\"body2\" sx={{ float: \"left\" }}>\n              {account.stats.messages.toLocaleString()}\n            </Typography>\n            <Typography variant=\"body2\" sx={{ float: \"right\" }}>\n              {account.role === Role.USER\n                ? t(\"account_usage_of_limit\", {\n                    limit: account.limits.messages.toLocaleString(),\n                  })\n                : t(\"account_usage_unlimited\")}\n            </Typography>\n          </div>\n          <LinearProgress\n            variant=\"determinate\"\n            value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}\n          />\n        </Pref>\n        {config.enable_emails && (\n          <Pref\n            title={\n              <>\n                {t(\"account_usage_emails_title\")}\n                <Tooltip title={t(\"account_usage_limits_reset_daily\")}>\n                  <span>\n                    <InfoIcon />\n                  </span>\n                </Tooltip>\n              </>\n            }\n          >\n            <div>\n              <Typography variant=\"body2\" sx={{ float: \"left\" }}>\n                {account.stats.emails.toLocaleString()}\n              </Typography>\n              <Typography variant=\"body2\" sx={{ float: \"right\" }}>\n                {account.role === Role.USER\n                  ? t(\"account_usage_of_limit\", {\n                      limit: account.limits.emails.toLocaleString(),\n                    })\n                  : t(\"account_usage_unlimited\")}\n              </Typography>\n            </div>\n            <LinearProgress\n              variant=\"determinate\"\n              value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}\n            />\n          </Pref>\n        )}\n        {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && (\n          <Pref\n            title={\n              <>\n                {t(\"account_usage_calls_title\")}\n                <Tooltip title={t(\"account_usage_limits_reset_daily\")}>\n                  <span>\n                    <InfoIcon />\n                  </span>\n                </Tooltip>\n              </>\n            }\n          >\n            <div>\n              <Typography variant=\"body2\" sx={{ float: \"left\" }}>\n                {account.stats.calls.toLocaleString()}\n              </Typography>\n              <Typography variant=\"body2\" sx={{ float: \"right\" }}>\n                {account.role === Role.USER\n                  ? t(\"account_usage_of_limit\", {\n                      limit: account.limits.calls.toLocaleString(),\n                    })\n                  : t(\"account_usage_unlimited\")}\n              </Typography>\n            </div>\n            <LinearProgress\n              variant=\"determinate\"\n              value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}\n            />\n          </Pref>\n        )}\n        <Pref\n          alignTop\n          title={t(\"account_usage_attachment_storage_title\")}\n          description={t(\"account_usage_attachment_storage_description\", {\n            filesize: formatBytes(account.limits.attachment_file_size),\n            expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {\n              language: i18n.resolvedLanguage,\n              fallbacks: [\"en\"],\n            }),\n          })}\n        >\n          <div>\n            <Typography variant=\"body2\" sx={{ float: \"left\" }}>\n              {formatBytes(account.stats.attachment_total_size)}\n            </Typography>\n            <Typography variant=\"body2\" sx={{ float: \"right\" }}>\n              {account.role === Role.USER\n                ? t(\"account_usage_of_limit\", {\n                    limit: formatBytes(account.limits.attachment_total_size),\n                  })\n                : t(\"account_usage_unlimited\")}\n            </Typography>\n          </div>\n          <LinearProgress\n            variant=\"determinate\"\n            value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}\n          />\n        </Pref>\n        {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && (\n          <Pref\n            title={\n              <>\n                {t(\"account_usage_reservations_title\")}\n                {config.enable_payments && <ProChip />}\n              </>\n            }\n          >\n            <em>{t(\"account_usage_reservations_none\")}</em>\n          </Pref>\n        )}\n        {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && (\n          <Pref\n            title={\n              <>\n                {t(\"account_usage_calls_title\")}\n                {config.enable_payments && <ProChip />}\n              </>\n            }\n          >\n            <em>{t(\"account_usage_calls_none\")}</em>\n          </Pref>\n        )}\n      </PrefGroup>\n      {account.role === Role.USER && account.limits.basis === LimitBasis.IP && (\n        <Typography variant=\"body1\">{t(\"account_usage_basis_ip_description\")}</Typography>\n      )}\n    </Card>\n  );\n};\n\nconst InfoIcon = () => (\n  <InfoOutlinedIcon\n    sx={{\n      verticalAlign: \"middle\",\n      width: \"18px\",\n      marginLeft: \"4px\",\n      color: \"gray\",\n    }}\n  />\n);\n\nconst Tokens = () => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const tokens = account?.tokens || [];\n\n  const handleCreateClick = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n\n  const handleDialogClose = () => {\n    setDialogOpen(false);\n  };\n\n  return (\n    <Card sx={{ padding: 1 }} aria-label={t(\"prefs_users_title\")}>\n      <CardContent sx={{ paddingBottom: 1 }}>\n        <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n          {t(\"account_tokens_title\")}\n        </Typography>\n        <Paragraph>\n          <Trans\n            i18nKey=\"account_tokens_description\"\n            components={{\n              Link: <Link href=\"/docs/publish/#access-tokens\" />,\n            }}\n          />\n        </Paragraph>\n        <div style={{ width: \"100%\", overflowX: \"auto\" }}>{tokens?.length > 0 && <TokensTable tokens={tokens} />}</div>\n      </CardContent>\n      <CardActions>\n        <Button onClick={handleCreateClick}>{t(\"account_tokens_table_create_token_button\")}</Button>\n      </CardActions>\n      <TokenDialog key={`tokenDialogCreate${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />\n    </Card>\n  );\n};\n\nconst TokensTable = (props) => {\n  const { t, i18n } = useTranslation();\n  const [snackOpen, setSnackOpen] = useState(false);\n  const [upsertDialogKey, setUpsertDialogKey] = useState(0);\n  const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n  const [selectedToken, setSelectedToken] = useState(null);\n\n  const tokens = (props.tokens || []).sort((a, b) => {\n    if (a.token === session.token()) {\n      return -1;\n    }\n    if (b.token === session.token()) {\n      return 1;\n    }\n    return a.token.localeCompare(b.token);\n  });\n\n  const handleEditClick = (token) => {\n    setUpsertDialogKey((prev) => prev + 1);\n    setSelectedToken(token);\n    setUpsertDialogOpen(true);\n  };\n\n  const handleDialogClose = () => {\n    setUpsertDialogOpen(false);\n    setDeleteDialogOpen(false);\n    setSelectedToken(null);\n  };\n\n  const handleDeleteClick = async (token) => {\n    setSelectedToken(token);\n    setDeleteDialogOpen(true);\n  };\n\n  const handleCopy = async (token) => {\n    copyToClipboard(token);\n    setSnackOpen(true);\n  };\n\n  return (\n    <Table size=\"small\" aria-label={t(\"account_tokens_title\")}>\n      <TableHead>\n        <TableRow>\n          <TableCell sx={{ paddingLeft: 0 }}>{t(\"account_tokens_table_token_header\")}</TableCell>\n          <TableCell>{t(\"account_tokens_table_label_header\")}</TableCell>\n          <TableCell>{t(\"account_tokens_table_expires_header\")}</TableCell>\n          <TableCell>{t(\"account_tokens_table_last_access_header\")}</TableCell>\n          <TableCell />\n        </TableRow>\n      </TableHead>\n      <TableBody>\n        {tokens.map((token) => (\n          <TableRow key={token.token} sx={{ \"&:last-child td, &:last-child th\": { border: 0 } }}>\n            <TableCell\n              component=\"th\"\n              scope=\"row\"\n              sx={{ paddingLeft: 0, whiteSpace: \"nowrap\" }}\n              aria-label={t(\"account_tokens_table_token_header\")}\n            >\n              <span>\n                <span style={{ fontFamily: \"Monospace\", fontSize: \"0.9rem\" }}>{token.token.slice(0, 12)}</span>\n                ...\n                <Tooltip title={t(\"common_copy_to_clipboard\")} placement=\"right\">\n                  <IconButton onClick={() => handleCopy(token.token)}>\n                    <ContentCopy />\n                  </IconButton>\n                </Tooltip>\n              </span>\n            </TableCell>\n            <TableCell aria-label={t(\"account_tokens_table_label_header\")}>\n              {token.token === session.token() && <em>{t(\"account_tokens_table_current_session\")}</em>}\n              {token.token !== session.token() && (token.label || \"-\")}\n            </TableCell>\n            <TableCell sx={{ whiteSpace: \"nowrap\" }} aria-label={t(\"account_tokens_table_expires_header\")}>\n              {token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t(\"account_tokens_table_never_expires\")}</em>}\n            </TableCell>\n            <TableCell sx={{ whiteSpace: \"nowrap\" }} aria-label={t(\"account_tokens_table_last_access_header\")}>\n              <div style={{ display: \"flex\", alignItems: \"center\" }}>\n                <span>{formatShortDateTime(token.last_access, i18n.language)}</span>\n                <Tooltip\n                  title={t(\"account_tokens_table_last_origin_tooltip\", {\n                    ip: token.last_origin,\n                  })}\n                >\n                  <IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>\n                    <Public />\n                  </IconButton>\n                </Tooltip>\n              </div>\n            </TableCell>\n            <TableCell align=\"right\" sx={{ whiteSpace: \"nowrap\" }}>\n              {token.token !== session.token() && !token.provisioned && (\n                <>\n                  <Tooltip title={t(\"account_tokens_dialog_title_edit\")}>\n                    <IconButton onClick={() => handleEditClick(token)} aria-label={t(\"account_tokens_dialog_title_edit\")}>\n                      <EditIcon />\n                    </IconButton>\n                  </Tooltip>\n                  <Tooltip title={t(\"account_tokens_dialog_title_delete\")}>\n                    <IconButton onClick={() => handleDeleteClick(token)} aria-label={t(\"account_tokens_dialog_title_delete\")}>\n                      <CloseIcon />\n                    </IconButton>\n                  </Tooltip>\n                </>\n              )}\n              {token.token === session.token() && (\n                <Tooltip title={t(\"account_tokens_table_cannot_delete_or_edit\")}>\n                  <span>\n                    <IconButton disabled>\n                      <EditIcon />\n                    </IconButton>\n                    <IconButton disabled>\n                      <CloseIcon />\n                    </IconButton>\n                  </span>\n                </Tooltip>\n              )}\n              {token.provisioned && (\n                <Tooltip title={t(\"account_tokens_table_cannot_delete_or_edit_provisioned_token\")}>\n                  <span>\n                    <IconButton disabled>\n                      <EditIcon />\n                    </IconButton>\n                    <IconButton disabled>\n                      <CloseIcon />\n                    </IconButton>\n                  </span>\n                </Tooltip>\n              )}\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n      <Portal>\n        <Snackbar\n          open={snackOpen}\n          autoHideDuration={3000}\n          onClose={() => setSnackOpen(false)}\n          message={t(\"account_tokens_table_copied_to_clipboard\")}\n        />\n      </Portal>\n      <TokenDialog key={`tokenDialogEdit${upsertDialogKey}`} open={upsertDialogOpen} token={selectedToken} onClose={handleDialogClose} />\n      <TokenDeleteDialog open={deleteDialogOpen} token={selectedToken} onClose={handleDialogClose} />\n    </Table>\n  );\n};\n\nconst TokenDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [label, setLabel] = useState(props.token?.label || \"\");\n  const [expires, setExpires] = useState(props.token ? -1 : 0);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n  const editMode = !!props.token;\n\n  const handleSubmit = async () => {\n    try {\n      if (editMode) {\n        await accountApi.updateToken(props.token.token, label, expires);\n      } else {\n        await accountApi.createToken(label, expires);\n      }\n      props.onClose();\n    } catch (e) {\n      console.log(`[Account] Error creating token`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} maxWidth=\"sm\" fullWidth fullScreen={fullScreen}>\n      <DialogTitle>{editMode ? t(\"account_tokens_dialog_title_edit\") : t(\"account_tokens_dialog_title_create\")}</DialogTitle>\n      <DialogContent>\n        <TextField\n          margin=\"dense\"\n          id=\"token-label\"\n          label={t(\"account_tokens_dialog_label\")}\n          aria-label={t(\"account_delete_dialog_label\")}\n          type=\"text\"\n          value={label}\n          onChange={(ev) => setLabel(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n        <FormControl fullWidth variant=\"standard\" sx={{ mt: 1 }}>\n          <Select value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t(\"account_tokens_dialog_expires_label\")}>\n            {editMode && <MenuItem value={-1}>{t(\"account_tokens_dialog_expires_unchanged\")}</MenuItem>}\n            <MenuItem value={0}>{t(\"account_tokens_dialog_expires_never\")}</MenuItem>\n            <MenuItem value={21600}>{t(\"account_tokens_dialog_expires_x_hours\", { hours: 6 })}</MenuItem>\n            <MenuItem value={43200}>{t(\"account_tokens_dialog_expires_x_hours\", { hours: 12 })}</MenuItem>\n            <MenuItem value={259200}>{t(\"account_tokens_dialog_expires_x_days\", { days: 3 })}</MenuItem>\n            <MenuItem value={604800}>{t(\"account_tokens_dialog_expires_x_days\", { days: 7 })}</MenuItem>\n            <MenuItem value={2592000}>{t(\"account_tokens_dialog_expires_x_days\", { days: 30 })}</MenuItem>\n            <MenuItem value={7776000}>{t(\"account_tokens_dialog_expires_x_days\", { days: 90 })}</MenuItem>\n            <MenuItem value={15552000}>{t(\"account_tokens_dialog_expires_x_days\", { days: 180 })}</MenuItem>\n          </Select>\n        </FormControl>\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"account_tokens_dialog_button_cancel\")}</Button>\n        <Button onClick={handleSubmit}>\n          {editMode ? t(\"account_tokens_dialog_button_update\") : t(\"account_tokens_dialog_button_create\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nconst TokenDeleteDialog = (props) => {\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n\n  const handleSubmit = async () => {\n    try {\n      await accountApi.deleteToken(props.token.token);\n      props.onClose();\n    } catch (e) {\n      console.log(`[Account] Error deleting token`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose}>\n      <DialogTitle>{t(\"account_tokens_delete_dialog_title\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>\n          <Trans i18nKey=\"account_tokens_delete_dialog_description\" />\n        </DialogContentText>\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSubmit} color=\"error\">\n          {t(\"account_tokens_delete_dialog_submit_button\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nconst Delete = () => {\n  const { t } = useTranslation();\n  return (\n    <Card sx={{ p: 3 }} aria-label={t(\"account_delete_title\")}>\n      <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n        {t(\"account_delete_title\")}\n      </Typography>\n      <PrefGroup>\n        <DeleteAccount />\n      </PrefGroup>\n    </Card>\n  );\n};\n\nconst DeleteAccount = () => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const { account } = useContext(AccountContext);\n\n  const handleDialogOpen = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n\n  const handleDialogClose = () => {\n    setDialogOpen(false);\n  };\n\n  return (\n    <Pref title={t(\"account_delete_title\")} description={t(\"account_delete_description\")}>\n      <div>\n        {!account?.provisioned ? (\n          <Button fullWidth={false} variant=\"outlined\" color=\"error\" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>\n            {t(\"account_delete_title\")}\n          </Button>\n        ) : (\n          <Tooltip title={t(\"account_basics_cannot_edit_or_delete_provisioned_user\")}>\n            <span>\n              <Button fullWidth={false} variant=\"outlined\" color=\"error\" startIcon={<DeleteOutlineIcon />} disabled>\n                {t(\"account_delete_title\")}\n              </Button>\n            </span>\n          </Tooltip>\n        )}\n      </div>\n      <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />\n    </Pref>\n  );\n};\n\nconst DeleteAccountDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [error, setError] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleSubmit = async () => {\n    try {\n      await accountApi.delete(password);\n      await db().delete();\n      console.debug(`[Account] Account deleted`);\n      await session.resetAndRedirect(routes.app);\n    } catch (e) {\n      console.log(`[Account] Error deleting account`, e);\n      if (e instanceof IncorrectPasswordError) {\n        setError(t(\"account_basics_password_dialog_current_password_incorrect\"));\n      } else if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>\n      <DialogTitle>{t(\"account_delete_title\")}</DialogTitle>\n      <DialogContent>\n        <Typography variant=\"body1\">{t(\"account_delete_dialog_description\")}</Typography>\n        <TextField\n          margin=\"dense\"\n          id=\"account-delete-confirm\"\n          label={t(\"account_delete_dialog_label\")}\n          aria-label={t(\"account_delete_dialog_label\")}\n          type=\"password\"\n          value={password}\n          onChange={(ev) => setPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n        {account?.billing?.subscription && (\n          <Alert severity=\"warning\" sx={{ mt: 1 }}>\n            {t(\"account_delete_dialog_billing_warning\")}\n          </Alert>\n        )}\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"account_delete_dialog_button_cancel\")}</Button>\n        <Button onClick={handleSubmit} color=\"error\" disabled={password.length === 0}>\n          {t(\"account_delete_dialog_button_submit\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nexport default Account;\n"
  },
  {
    "path": "web/src/components/ActionBar.jsx",
    "content": "import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon, useTheme } from \"@mui/material\";\nimport MenuIcon from \"@mui/icons-material/Menu\";\nimport * as React from \"react\";\nimport { useState } from \"react\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport MoreVertIcon from \"@mui/icons-material/MoreVert\";\nimport NotificationsIcon from \"@mui/icons-material/Notifications\";\nimport NotificationsOffIcon from \"@mui/icons-material/NotificationsOff\";\nimport { useTranslation } from \"react-i18next\";\nimport AccountCircleIcon from \"@mui/icons-material/AccountCircle\";\nimport { Logout, Person, Settings } from \"@mui/icons-material\";\nimport session from \"../app/Session\";\nimport logo from \"../img/ntfy.svg\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport routes from \"./routes\";\nimport db from \"../app/db\";\nimport { topicDisplayName } from \"../app/utils\";\nimport Navigation from \"./Navigation\";\nimport accountApi from \"../app/AccountApi\";\nimport PopupMenu from \"./PopupMenu\";\nimport { SubscriptionPopup } from \"./SubscriptionPopup\";\nimport { useIsLaunchedPWA } from \"./hooks\";\n\nconst ActionBar = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const location = useLocation();\n  const isLaunchedPWA = useIsLaunchedPWA();\n\n  let title = \"ntfy\";\n  if (props.selected) {\n    title = topicDisplayName(props.selected);\n  } else if (location.pathname === routes.settings) {\n    title = t(\"action_bar_settings\");\n  } else if (location.pathname === routes.account) {\n    title = t(\"action_bar_account\");\n  }\n\n  const getActionBarBackground = () => {\n    if (isLaunchedPWA) {\n      return \"#317f6f\";\n    }\n\n    switch (theme.palette.mode) {\n      case \"dark\":\n        return \"linear-gradient(150deg, #203631 0%, #2a6e60 100%)\";\n\n      case \"light\":\n      default:\n        return \"linear-gradient(150deg, #338574 0%, #56bda8 100%)\";\n    }\n  };\n\n  return (\n    <AppBar\n      position=\"fixed\"\n      sx={{\n        width: \"100%\",\n        zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)\n        ml: { sm: `${Navigation.width}px` },\n      }}\n    >\n      <Toolbar\n        sx={{\n          pr: \"24px\",\n          background: getActionBarBackground(),\n        }}\n      >\n        <IconButton\n          color=\"inherit\"\n          edge=\"start\"\n          aria-label={t(\"action_bar_show_menu\")}\n          onClick={props.onMobileDrawerToggle}\n          sx={{ mr: 2, display: { sm: \"none\" } }}\n        >\n          <MenuIcon />\n        </IconButton>\n        <Box\n          component=\"img\"\n          src={logo}\n          alt={t(\"action_bar_logo_alt\")}\n          sx={{\n            display: { xs: \"none\", sm: \"block\" },\n            marginRight: \"10px\",\n            height: \"28px\",\n          }}\n        />\n        <Typography variant=\"h6\" noWrap component=\"div\" sx={{ flexGrow: 1 }}>\n          {title}\n        </Typography>\n        {props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}\n        <ProfileIcon />\n      </Toolbar>\n    </AppBar>\n  );\n};\n\nconst SettingsIcons = (props) => {\n  const { t } = useTranslation();\n  const [anchorEl, setAnchorEl] = useState(null);\n  const { subscription } = props;\n\n  const handleToggleMute = async () => {\n    const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future\n    await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);\n  };\n\n  return (\n    <>\n      <IconButton color=\"inherit\" size=\"large\" edge=\"end\" onClick={handleToggleMute} aria-label={t(\"action_bar_toggle_mute\")}>\n        {subscription.mutedUntil ? <NotificationsOffIcon /> : <NotificationsIcon />}\n      </IconButton>\n      <IconButton\n        color=\"inherit\"\n        size=\"large\"\n        edge=\"end\"\n        onClick={(ev) => setAnchorEl(ev.currentTarget)}\n        aria-label={t(\"action_bar_toggle_action_menu\")}\n      >\n        <MoreVertIcon />\n      </IconButton>\n      <SubscriptionPopup subscription={subscription} anchor={anchorEl} placement=\"right\" onClose={() => setAnchorEl(null)} />\n    </>\n  );\n};\n\nconst ProfileIcon = () => {\n  const { t } = useTranslation();\n  const [anchorEl, setAnchorEl] = useState(null);\n  const open = Boolean(anchorEl);\n  const navigate = useNavigate();\n\n  const handleClick = (event) => {\n    setAnchorEl(event.currentTarget);\n  };\n\n  const handleClose = () => {\n    setAnchorEl(null);\n  };\n\n  const handleLogout = async () => {\n    try {\n      await accountApi.logout();\n      await db().delete();\n    } finally {\n      await session.resetAndRedirect(routes.app);\n    }\n  };\n\n  return (\n    <>\n      {session.exists() && (\n        <IconButton color=\"inherit\" size=\"large\" edge=\"end\" onClick={handleClick} aria-label={t(\"action_bar_profile_title\")}>\n          <AccountCircleIcon />\n        </IconButton>\n      )}\n      {!session.exists() && config.enable_login && (\n        <Button color=\"inherit\" variant=\"text\" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t(\"action_bar_sign_in\")}>\n          {t(\"action_bar_sign_in\")}\n        </Button>\n      )}\n      {!session.exists() && config.enable_signup && (\n        <Button color=\"inherit\" variant=\"outlined\" onClick={() => navigate(routes.signup)} aria-label={t(\"action_bar_sign_up\")}>\n          {t(\"action_bar_sign_up\")}\n        </Button>\n      )}\n      <PopupMenu horizontal=\"right\" anchorEl={anchorEl} open={open} onClose={handleClose}>\n        <MenuItem onClick={() => navigate(routes.account)}>\n          <ListItemIcon>\n            <Person />\n          </ListItemIcon>\n          <b>{session.username()}</b>\n        </MenuItem>\n        <Divider />\n        <MenuItem onClick={() => navigate(routes.settings)}>\n          <ListItemIcon>\n            <Settings fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_profile_settings\")}\n        </MenuItem>\n        <MenuItem onClick={handleLogout}>\n          <ListItemIcon>\n            <Logout fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_profile_logout\")}\n        </MenuItem>\n      </PopupMenu>\n    </>\n  );\n};\n\nexport default ActionBar;\n"
  },
  {
    "path": "web/src/components/App.jsx",
    "content": "import * as React from \"react\";\nimport { createContext, Suspense, useContext, useEffect, useState, useMemo } from \"react\";\nimport { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from \"@mui/material\";\nimport { useLiveQuery } from \"dexie-react-hooks\";\nimport { BrowserRouter, Outlet, Route, Routes, useParams } from \"react-router-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { AllSubscriptions, SingleSubscription } from \"./Notifications\";\nimport { darkTheme, lightTheme } from \"./theme\";\nimport Navigation from \"./Navigation\";\nimport ActionBar from \"./ActionBar\";\nimport Preferences from \"./Preferences\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport userManager from \"../app/UserManager\";\nimport { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from \"../app/utils\";\nimport ErrorBoundary from \"./ErrorBoundary\";\nimport routes from \"./routes\";\nimport { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from \"./hooks\";\nimport PublishDialog from \"./PublishDialog\";\nimport Messaging from \"./Messaging\";\nimport Login from \"./Login\";\nimport Signup from \"./Signup\";\nimport Account from \"./Account\";\nimport initI18n from \"../app/i18n\"; // Translations!\nimport prefs from \"../app/Prefs\";\nimport RTLCacheProvider from \"./RTLCacheProvider\";\nimport session from \"../app/Session\";\n\ninitI18n();\n\nexport const AccountContext = createContext(null);\n\nconst App = () => {\n  const { i18n } = useTranslation();\n  const languageDir = i18n.dir();\n  const [account, setAccount] = useState(null);\n  const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);\n  const prefersDarkMode = useMediaQuery(\"(prefers-color-scheme: dark)\");\n  const themePreference = useLiveQuery(() => prefs.theme());\n  const theme = React.useMemo(\n    () => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),\n    [prefersDarkMode, themePreference, languageDir]\n  );\n\n  useEffect(() => {\n    document.documentElement.setAttribute(\"lang\", getKebabCaseLangStr(i18n.language));\n    document.dir = languageDir;\n  }, [i18n.language, languageDir]);\n\n  useEffect(() => {\n    if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {\n      window.location.href = routes.login;\n    }\n  }, []);\n\n  return (\n    <Suspense fallback={<Loader />}>\n      <RTLCacheProvider>\n        <BrowserRouter>\n          <ThemeProvider theme={theme}>\n            <AccountContext.Provider value={accountMemo}>\n              <CssBaseline />\n              <ErrorBoundary>\n                <Routes>\n                  <Route path={routes.login} element={<Login />} />\n                  <Route path={routes.signup} element={<Signup />} />\n                  <Route element={<Layout />}>\n                    <Route path={routes.app} element={<AllSubscriptions />} />\n                    <Route path={routes.account} element={<Account />} />\n                    <Route path={routes.settings} element={<Preferences />} />\n                    <Route path={routes.subscription} element={<SingleSubscription />} />\n                    <Route path={routes.subscriptionExternal} element={<SingleSubscription />} />\n                  </Route>\n                </Routes>\n              </ErrorBoundary>\n            </AccountContext.Provider>\n          </ThemeProvider>\n        </BrowserRouter>\n      </RTLCacheProvider>\n    </Suspense>\n  );\n};\n\nconst updateTitle = (newNotificationsCount) => {\n  document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : \"ntfy\";\n  window.navigator.setAppBadge?.(newNotificationsCount);\n  updateFavicon(newNotificationsCount);\n};\n\nconst Layout = () => {\n  const params = useParams();\n  const { account, setAccount } = useContext(AccountContext);\n  const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);\n  const [sendDialogOpenMode, setSendDialogOpenMode] = useState(\"\");\n  const users = useLiveQuery(() => userManager.all());\n  const subscriptions = useLiveQuery(() => subscriptionManager.all());\n  const webPushTopics = useWebPushTopics();\n  const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);\n  const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;\n  const [selected] = (subscriptionsWithoutInternal || []).filter(\n    (s) =>\n      (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||\n      (config.base_url === s.baseUrl && params.topic === s.topic)\n  );\n\n  useConnectionListeners(account, subscriptions, users, webPushTopics);\n  useAccountListener(setAccount);\n  useBackgroundProcesses();\n  useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);\n\n  return (\n    <Box sx={{ display: \"flex\" }}>\n      <ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />\n      <Navigation\n        subscriptions={subscriptionsWithoutInternal}\n        selectedSubscription={selected}\n        mobileDrawerOpen={mobileDrawerOpen}\n        onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}\n        onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}\n      />\n      <Main>\n        <Toolbar />\n        <Outlet\n          context={{\n            subscriptions: subscriptionsWithoutInternal,\n            selected,\n          }}\n        />\n      </Main>\n      <Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />\n    </Box>\n  );\n};\n\nconst Main = (props) => (\n  <Box\n    id=\"main\"\n    component=\"main\"\n    sx={{\n      display: \"flex\",\n      flexGrow: 1,\n      flexDirection: \"column\",\n      padding: { xs: 0, md: 3 },\n      width: { sm: `calc(100% - ${Navigation.width}px)` },\n      height: \"100dvh\",\n      overflow: \"auto\",\n      backgroundColor: ({ palette }) => (palette.mode === \"light\" ? palette.grey[100] : palette.grey[900]),\n    }}\n  >\n    {props.children}\n  </Box>\n);\n\nconst Loader = () => (\n  <Backdrop\n    open\n    sx={{\n      zIndex: 100000,\n      backgroundColor: ({ palette }) => (palette.mode === \"light\" ? palette.grey[100] : palette.grey[900]),\n    }}\n  >\n    <CircularProgress color=\"success\" disableShrink />\n  </Backdrop>\n);\n\nexport default App;\n"
  },
  {
    "path": "web/src/components/AttachmentIcon.jsx",
    "content": "import * as React from \"react\";\nimport { Box, Link } from \"@mui/material\";\nimport { useTranslation } from \"react-i18next\";\nimport fileDocument from \"../img/file-document.svg\";\nimport fileImage from \"../img/file-image.svg\";\nimport fileVideo from \"../img/file-video.svg\";\nimport fileAudio from \"../img/file-audio.svg\";\nimport fileApp from \"../img/file-app.svg\";\n\nconst AttachmentIcon = (props) => {\n  const { t } = useTranslation();\n  const { type } = props;\n  let imageFile;\n  let imageLabel;\n  if (!type) {\n    imageFile = fileDocument;\n    imageLabel = t(\"notifications_attachment_file_image\");\n  } else if (type.startsWith(\"image/\")) {\n    imageFile = fileImage;\n    imageLabel = t(\"notifications_attachment_file_video\");\n  } else if (type.startsWith(\"video/\")) {\n    imageFile = fileVideo;\n    imageLabel = t(\"notifications_attachment_file_video\");\n  } else if (type.startsWith(\"audio/\")) {\n    imageFile = fileAudio;\n    imageLabel = t(\"notifications_attachment_file_audio\");\n  } else if (type === \"application/vnd.android.package-archive\") {\n    imageFile = fileApp;\n    imageLabel = t(\"notifications_attachment_file_app\");\n  } else {\n    imageFile = fileDocument;\n    imageLabel = t(\"notifications_attachment_file_document\");\n  }\n  return (\n    <Link href={props.href} target=\"_blank\">\n      <Box\n        component=\"img\"\n        src={imageFile}\n        alt={imageLabel}\n        loading=\"lazy\"\n        sx={{\n          width: \"28px\",\n          height: \"28px\",\n        }}\n      />\n    </Link>\n  );\n};\n\nexport default AttachmentIcon;\n"
  },
  {
    "path": "web/src/components/AvatarBox.jsx",
    "content": "import * as React from \"react\";\nimport { Avatar, Box, styled } from \"@mui/material\";\nimport logo from \"../img/ntfy-filled.svg\";\n\nconst AvatarBoxContainer = styled(Box)`\n  display: flex;\n  flex-grow: 1;\n  justify-content: center;\n  flex-direction: column;\n  align-content: center;\n  align-items: center;\n  height: 100dvh;\n  max-width: min(400px, 90dvw);\n  margin: auto;\n`;\nconst AvatarBox = (props) => (\n  <AvatarBoxContainer>\n    <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant=\"rounded\" />\n    {props.children}\n  </AvatarBoxContainer>\n);\n\nexport default AvatarBox;\n"
  },
  {
    "path": "web/src/components/DialogFooter.jsx",
    "content": "import * as React from \"react\";\nimport { Box, DialogContentText, DialogActions } from \"@mui/material\";\n\nconst DialogFooter = (props) => (\n  <Box\n    sx={{\n      display: \"flex\",\n      flexDirection: \"row\",\n      justifyContent: \"space-between\",\n      paddingLeft: \"24px\",\n      paddingBottom: \"8px\",\n    }}\n  >\n    <DialogContentText\n      component=\"div\"\n      aria-live=\"polite\"\n      sx={{\n        margin: \"0px\",\n        paddingTop: \"12px\",\n        paddingBottom: \"4px\",\n      }}\n    >\n      {props.status}\n    </DialogContentText>\n    <DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>\n  </Box>\n);\n\nexport default DialogFooter;\n"
  },
  {
    "path": "web/src/components/EmojiPicker.jsx",
    "content": "import * as React from \"react\";\nimport { useRef, useState } from \"react\";\nimport { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from \"@mui/material\";\nimport { Close } from \"@mui/icons-material\";\nimport { useTranslation } from \"react-i18next\";\nimport { splitNoEmpty } from \"../app/utils\";\nimport { rawEmojis } from \"../app/emojis\";\n\n// Create emoji list by category and create a search base (string with all search words)\n//\n// This also filters emojis that are not supported by Desktop Chrome.\n// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.\n\nconst emojisByCategory = {};\nconst isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);\nconst maxSupportedVersionForDesktopChrome = 11;\nrawEmojis.forEach((emoji) => {\n  if (!emojisByCategory[emoji.category]) {\n    emojisByCategory[emoji.category] = [];\n  }\n  try {\n    const unicodeVersion = parseFloat(emoji.unicode_version);\n    const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;\n    if (supportedEmoji) {\n      const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(\" \")} ${emoji.tags.join(\" \")}`;\n      const emojiWithSearchBase = { ...emoji, searchBase };\n      emojisByCategory[emoji.category].push(emojiWithSearchBase);\n    }\n  } catch (e) {\n    // Nothing. Ignore.\n  }\n});\n\nconst EmojiPicker = (props) => {\n  const { t } = useTranslation();\n  const open = Boolean(props.anchorEl);\n  const [search, setSearch] = useState(\"\");\n  const searchRef = useRef(null);\n  const searchFields = splitNoEmpty(search.toLowerCase(), \" \");\n\n  const handleSearchClear = () => {\n    setSearch(\"\");\n    searchRef.current?.focus();\n  };\n\n  return (\n    <Popper open={open} anchorEl={props.anchorEl} placement=\"bottom-start\" sx={{ zIndex: 10005 }} transition>\n      {({ TransitionProps }) => (\n        <ClickAwayListener onClickAway={props.onClose}>\n          <Fade {...TransitionProps} timeout={350}>\n            <Box\n              sx={{\n                boxShadow: 3,\n                padding: 2,\n                paddingRight: 0,\n                paddingBottom: 1,\n                width: \"380px\",\n                maxHeight: \"300px\",\n                backgroundColor: \"background.paper\",\n                overflowY: \"scroll\",\n              }}\n            >\n              <TextField\n                inputRef={searchRef}\n                margin=\"dense\"\n                size=\"small\"\n                placeholder={t(\"emoji_picker_search_placeholder\")}\n                value={search}\n                onChange={(ev) => setSearch(ev.target.value)}\n                type=\"text\"\n                variant=\"standard\"\n                fullWidth\n                sx={{ marginTop: 0, marginBottom: \"12px\", paddingRight: 2 }}\n                inputProps={{\n                  role: \"searchbox\",\n                  \"aria-label\": t(\"emoji_picker_search_placeholder\"),\n                }}\n                InputProps={{\n                  endAdornment: (\n                    <InputAdornment position=\"end\" sx={{ display: search ? \"\" : \"none\" }}>\n                      <IconButton size=\"small\" onClick={handleSearchClear} edge=\"end\" aria-label={t(\"emoji_picker_search_clear\")}>\n                        <Close />\n                      </IconButton>\n                    </InputAdornment>\n                  ),\n                }}\n              />\n              <Box\n                sx={{\n                  display: \"flex\",\n                  flexWrap: \"wrap\",\n                  paddingRight: 0,\n                  marginTop: 1,\n                }}\n              >\n                {Object.keys(emojisByCategory).map((category) => (\n                  <Category\n                    key={category}\n                    title={category}\n                    emojis={emojisByCategory[category]}\n                    search={searchFields}\n                    onPick={props.onEmojiPick}\n                  />\n                ))}\n              </Box>\n            </Box>\n          </Fade>\n        </ClickAwayListener>\n      )}\n    </Popper>\n  );\n};\n\nconst Category = (props) => {\n  const showTitle = props.search.length === 0;\n  return (\n    <>\n      {showTitle && (\n        <Typography variant=\"body1\" sx={{ width: \"100%\", marginBottom: 1 }}>\n          {props.title}\n        </Typography>\n      )}\n      {props.emojis.map((emoji) => (\n        <Emoji key={emoji.aliases[0]} emoji={emoji} search={props.search} onClick={() => props.onPick(emoji.aliases[0])} />\n      ))}\n    </>\n  );\n};\n\nconst emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word));\n\nconst Emoji = (props) => {\n  const { emoji } = props;\n  const matches = emojiMatches(emoji, props.search);\n  const title = `${emoji.description} (${emoji.aliases[0]})`;\n  return (\n    <EmojiDiv onClick={props.onClick} title={title} aria-label={title} style={{ display: matches ? \"\" : \"none\" }}>\n      {props.emoji.emoji}\n    </EmojiDiv>\n  );\n};\n\nconst EmojiDiv = styled(\"div\")({\n  fontSize: \"30px\",\n  width: \"30px\",\n  height: \"30px\",\n  marginTop: \"8px\",\n  marginBottom: \"8px\",\n  marginRight: \"8px\",\n  lineHeight: \"30px\",\n  cursor: \"pointer\",\n  opacity: 0.85,\n  \"&:hover\": {\n    opacity: 1,\n  },\n});\n\nexport default EmojiPicker;\n"
  },
  {
    "path": "web/src/components/ErrorBoundary.jsx",
    "content": "import * as React from \"react\";\nimport StackTrace from \"stacktrace-js\";\nimport { CircularProgress, Link, Button } from \"@mui/material\";\nimport { Trans, withTranslation } from \"react-i18next\";\nimport { copyToClipboard } from \"../app/utils\";\n\nclass ErrorBoundaryImpl extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      error: false,\n      originalStack: null,\n      niceStack: null,\n      unsupportedIndexedDB: false,\n    };\n  }\n\n  componentDidCatch(error, info) {\n    console.error(\"[ErrorBoundary] Error caught\", error, info);\n\n    // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see\n    // - https://github.com/dexie/Dexie.js/issues/312\n    // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982\n    const isUnsupportedIndexedDB =\n      error?.name === \"InvalidStateError\" || (error?.name === \"DatabaseClosedError\" && error?.message?.indexOf(\"InvalidStateError\") !== -1);\n\n    if (isUnsupportedIndexedDB) {\n      this.handleUnsupportedIndexedDB();\n    } else {\n      this.handleError(error, info);\n    }\n  }\n\n  handleError(error, info) {\n    // Immediately render original stack trace\n    const prettierOriginalStack = info.componentStack\n      .trim()\n      .split(\"\\n\")\n      .map((line) => `  at ${line}`)\n      .join(\"\\n\");\n    this.setState({\n      error: true,\n      originalStack: `${error.toString()}\\n${prettierOriginalStack}`,\n    });\n\n    // Fetch additional info and a better stack trace\n    StackTrace.fromError(error).then((stack) => {\n      console.error(\"[ErrorBoundary] Stacktrace fetched\", stack);\n      const stackString = stack.map((el) => `  at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join(\"\\n\");\n      const niceStack = `${error.toString()}\\n${stackString}`;\n      this.setState({ niceStack });\n    });\n  }\n\n  handleUnsupportedIndexedDB() {\n    this.setState({\n      error: true,\n      unsupportedIndexedDB: true,\n    });\n  }\n\n  copyStack() {\n    let stack = \"\";\n    if (this.state.niceStack) {\n      stack += `${this.state.niceStack}\\n\\n`;\n    }\n    stack += `${this.state.originalStack}\\n`;\n    copyToClipboard(stack);\n  }\n\n  renderUnsupportedIndexedDB() {\n    const { t } = this.props;\n    return (\n      <div style={{ margin: \"20px\" }}>\n        <h2>{t(\"error_boundary_unsupported_indexeddb_title\")} 😮</h2>\n        <p style={{ maxWidth: \"600px\" }}>\n          <Trans\n            i18nKey=\"error_boundary_unsupported_indexeddb_description\"\n            components={{\n              githubLink: <Link href=\"https://github.com/binwiederhier/ntfy/issues/208\" />,\n              discordLink: <Link href=\"https://discord.gg/cT7ECsZj9w\" />,\n              matrixLink: <Link href=\"https://matrix.to/#/#ntfy:matrix.org\" />,\n            }}\n          />\n        </p>\n      </div>\n    );\n  }\n\n  renderError() {\n    const { t } = this.props;\n    return (\n      <div style={{ margin: \"20px\" }}>\n        <h2>{t(\"error_boundary_title\")} 😮</h2>\n        <p>\n          <Trans\n            i18nKey=\"error_boundary_description\"\n            components={{\n              githubLink: <Link href=\"https://github.com/binwiederhier/ntfy/issues\" />,\n              discordLink: <Link href=\"https://discord.gg/cT7ECsZj9w\" />,\n              matrixLink: <Link href=\"https://matrix.to/#/#ntfy:matrix.org\" />,\n            }}\n          />\n        </p>\n        <div style={{ display: \"flex\", gap: 5 }}>\n          <Button variant=\"outlined\" onClick={() => this.copyStack()}>\n            {t(\"error_boundary_button_copy_stack_trace\")}\n          </Button>\n\n          <Button variant=\"outlined\" onClick={() => window.location.reload()}>\n            {t(\"error_boundary_button_reload_ntfy\")}\n          </Button>\n        </div>\n        <h3>{t(\"error_boundary_stack_trace\")}</h3>\n        {this.state.niceStack ? (\n          <pre>{this.state.niceStack}</pre>\n        ) : (\n          <>\n            <CircularProgress size=\"20px\" sx={{ verticalAlign: \"text-bottom\" }} /> {t(\"error_boundary_gathering_info\")}\n          </>\n        )}\n        <pre>{this.state.originalStack}</pre>\n      </div>\n    );\n  }\n\n  render() {\n    if (this.state.error) {\n      if (this.state.unsupportedIndexedDB) {\n        return this.renderUnsupportedIndexedDB();\n      }\n      return this.renderError();\n    }\n    return this.props.children;\n  }\n}\n\nconst ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t\nexport default ErrorBoundary;\n"
  },
  {
    "path": "web/src/components/Login.jsx",
    "content": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { Typography, TextField, Button, Box, IconButton, InputAdornment } from \"@mui/material\";\nimport WarningAmberIcon from \"@mui/icons-material/WarningAmber\";\nimport { NavLink } from \"react-router-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { Visibility, VisibilityOff } from \"@mui/icons-material\";\nimport accountApi from \"../app/AccountApi\";\nimport AvatarBox from \"./AvatarBox\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport { UnauthorizedError } from \"../app/errors\";\n\nconst Login = () => {\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [showPassword, setShowPassword] = useState(false);\n\n  const handleSubmit = async (event) => {\n    event.preventDefault();\n    const user = { username, password };\n    try {\n      const token = await accountApi.login(user);\n      console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);\n      await session.store(user.username, token);\n      window.location.href = routes.app;\n    } catch (e) {\n      console.log(`[Login] User auth for user ${user.username} failed`, e);\n      if (e instanceof UnauthorizedError) {\n        setError(t(\"Login failed: Invalid username or password\"));\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n  if (!config.enable_login) {\n    return (\n      <AvatarBox>\n        <Typography sx={{ typography: \"h6\" }}>{t(\"login_disabled\")}</Typography>\n      </AvatarBox>\n    );\n  }\n  return (\n    <AvatarBox>\n      <Typography sx={{ typography: \"h6\" }}>{t(\"login_title\")}</Typography>\n      <Box component=\"form\" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>\n        <TextField\n          margin=\"dense\"\n          required\n          fullWidth\n          id=\"username\"\n          label={t(\"signup_form_username\")}\n          name=\"username\"\n          value={username}\n          onChange={(ev) => setUsername(ev.target.value.trim())}\n          autoFocus\n        />\n        <TextField\n          margin=\"dense\"\n          required\n          fullWidth\n          name=\"password\"\n          label={t(\"signup_form_password\")}\n          type={showPassword ? \"text\" : \"password\"}\n          id=\"password\"\n          value={password}\n          onChange={(ev) => setPassword(ev.target.value.trim())}\n          autoComplete=\"current-password\"\n          InputProps={{\n            endAdornment: (\n              <InputAdornment position=\"end\">\n                <IconButton\n                  aria-label={t(\"signup_form_toggle_password_visibility\")}\n                  onClick={() => setShowPassword(!showPassword)}\n                  onMouseDown={(ev) => ev.preventDefault()}\n                  edge=\"end\"\n                >\n                  {showPassword ? <VisibilityOff /> : <Visibility />}\n                </IconButton>\n              </InputAdornment>\n            ),\n          }}\n        />\n        <Button type=\"submit\" fullWidth variant=\"contained\" disabled={username === \"\" || password === \"\"} sx={{ mt: 2, mb: 2 }}>\n          {t(\"login_form_button_submit\")}\n        </Button>\n        {error && (\n          <Box\n            sx={{\n              mb: 1,\n              display: \"flex\",\n              flexGrow: 1,\n              justifyContent: \"center\",\n            }}\n          >\n            <WarningAmberIcon color=\"error\" sx={{ mr: 1 }} />\n            <Typography sx={{ color: \"error.main\" }}>{error}</Typography>\n          </Box>\n        )}\n        <Box sx={{ width: \"100%\" }}>\n          {/* This is where the password reset link would go */}\n          {config.enable_signup && (\n            <div style={{ float: \"right\" }}>\n              <NavLink to={routes.signup} variant=\"body1\">\n                {t(\"login_link_signup\")}\n              </NavLink>\n            </div>\n          )}\n        </Box>\n      </Box>\n    </AvatarBox>\n  );\n};\n\nexport default Login;\n"
  },
  {
    "path": "web/src/components/Messaging.jsx",
    "content": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { Paper, IconButton, TextField, Portal, Snackbar } from \"@mui/material\";\nimport SendIcon from \"@mui/icons-material/Send\";\nimport KeyboardArrowUpIcon from \"@mui/icons-material/KeyboardArrowUp\";\nimport { useTranslation } from \"react-i18next\";\nimport PublishDialog from \"./PublishDialog\";\nimport api from \"../app/Api\";\nimport Navigation from \"./Navigation\";\n\nconst Messaging = (props) => {\n  const [message, setMessage] = useState(\"\");\n  const [attachFile, setAttachFile] = useState(null);\n  const [dialogKey, setDialogKey] = useState(0);\n\n  const { dialogOpenMode } = props;\n  const subscription = props.selected;\n\n  const handleOpenDialogClick = () => {\n    props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);\n  };\n\n  const handleDialogClose = () => {\n    props.onDialogOpenModeChange(\"\");\n    setDialogKey((prev) => prev + 1);\n    setAttachFile(null);\n  };\n\n  const getPastedImage = (ev) => {\n    const { items } = ev.clipboardData;\n    for (let i = 0; i < items.length; i += 1) {\n      if (items[i].type.indexOf(\"image\") !== -1) {\n        return items[i].getAsFile();\n      }\n    }\n    return null;\n  };\n\n  return (\n    <>\n      {subscription && (\n        <MessageBar\n          subscription={subscription}\n          message={message}\n          onMessageChange={setMessage}\n          onFilePasted={setAttachFile}\n          onOpenDialogClick={handleOpenDialogClick}\n          getPastedImage={getPastedImage}\n        />\n      )}\n      <PublishDialog\n        key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed\n        openMode={dialogOpenMode}\n        baseUrl={subscription?.baseUrl ?? config.base_url}\n        topic={subscription?.topic ?? \"\"}\n        message={message}\n        attachFile={attachFile}\n        getPastedImage={getPastedImage}\n        onClose={handleDialogClose}\n        onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open\n        onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}\n      />\n    </>\n  );\n};\n\nconst MessageBar = (props) => {\n  const { t } = useTranslation();\n  const { subscription } = props;\n  const [snackOpen, setSnackOpen] = useState(false);\n  const handleSendClick = async () => {\n    try {\n      await api.publish(subscription.baseUrl, subscription.topic, props.message);\n    } catch (e) {\n      console.log(`[MessageBar] Error publishing message`, e);\n      setSnackOpen(true);\n    }\n    props.onMessageChange(\"\");\n  };\n\n  const handlePaste = (ev) => {\n    const blob = props.getPastedImage(ev);\n    if (blob) {\n      props.onFilePasted(blob);\n      props.onOpenDialogClick();\n    }\n  };\n\n  return (\n    <Paper\n      elevation={3}\n      sx={{\n        display: \"flex\",\n        position: \"fixed\",\n        bottom: 0,\n        right: 0,\n        padding: 2,\n        width: { xs: \"100%\", sm: `calc(100% - ${Navigation.width}px)` },\n        backgroundColor: (theme) => (theme.palette.mode === \"light\" ? theme.palette.grey[100] : theme.palette.grey[900]),\n      }}\n    >\n      <IconButton color=\"inherit\" size=\"large\" edge=\"start\" onClick={props.onOpenDialogClick} aria-label={t(\"message_bar_show_dialog\")}>\n        <KeyboardArrowUpIcon />\n      </IconButton>\n      <TextField\n        autoFocus\n        margin=\"dense\"\n        placeholder={t(\"message_bar_type_message\")}\n        aria-label={t(\"message_bar_type_message\")}\n        role=\"textbox\"\n        type=\"text\"\n        fullWidth\n        variant=\"standard\"\n        value={props.message}\n        onChange={(ev) => props.onMessageChange(ev.target.value)}\n        onKeyPress={(ev) => {\n          if (ev.key === \"Enter\") {\n            ev.preventDefault();\n            handleSendClick();\n          }\n        }}\n        onPaste={handlePaste}\n      />\n      <IconButton color=\"inherit\" size=\"large\" edge=\"end\" onClick={handleSendClick} aria-label={t(\"message_bar_publish\")}>\n        <SendIcon />\n      </IconButton>\n      <Portal>\n        <Snackbar\n          open={snackOpen}\n          autoHideDuration={3000}\n          onClose={() => setSnackOpen(false)}\n          message={t(\"message_bar_error_publishing\")}\n        />\n      </Portal>\n    </Paper>\n  );\n};\n\nexport default Messaging;\n"
  },
  {
    "path": "web/src/components/Navigation.jsx",
    "content": "import {\n  Alert,\n  AlertTitle,\n  Badge,\n  Box,\n  Button,\n  CircularProgress,\n  Divider,\n  Drawer,\n  IconButton,\n  Link,\n  List,\n  ListItemButton,\n  ListItemIcon,\n  ListItemText,\n  ListSubheader,\n  Portal,\n  Toolbar,\n  Tooltip,\n  Typography,\n  useTheme,\n} from \"@mui/material\";\nimport * as React from \"react\";\nimport { useContext, useState } from \"react\";\nimport ChatBubbleOutlineIcon from \"@mui/icons-material/ChatBubbleOutline\";\nimport Person from \"@mui/icons-material/Person\";\nimport SettingsIcon from \"@mui/icons-material/Settings\";\nimport AddIcon from \"@mui/icons-material/Add\";\nimport { useLocation, useNavigate } from \"react-router-dom\";\nimport { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from \"@mui/icons-material\";\nimport ArticleIcon from \"@mui/icons-material/Article\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport CelebrationIcon from \"@mui/icons-material/Celebration\";\nimport SubscribeDialog from \"./SubscribeDialog\";\nimport { openUrl, topicDisplayName, topicUrl } from \"../app/utils\";\nimport routes from \"./routes\";\nimport { ConnectionState } from \"../app/Connection\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport notifier from \"../app/Notifier\";\nimport config from \"../app/config\";\nimport session from \"../app/Session\";\nimport accountApi, { Permission, Role } from \"../app/AccountApi\";\nimport UpgradeDialog from \"./UpgradeDialog\";\nimport { AccountContext } from \"./App\";\nimport { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from \"./ReserveIcons\";\nimport { SubscriptionPopup } from \"./SubscriptionPopup\";\nimport { useNotificationPermissionListener, useVersionChangeListener } from \"./hooks\";\n\nconst navWidth = 280;\n\nconst Navigation = (props) => {\n  const navigationList = <NavList {...props} />;\n  return (\n    <Box component=\"nav\" role=\"navigation\" sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}>\n      {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}\n      <Drawer\n        variant=\"temporary\"\n        role=\"menubar\"\n        open={props.mobileDrawerOpen}\n        onClose={props.onMobileDrawerToggle}\n        ModalProps={{ keepMounted: true }} // Better open performance on mobile.\n        sx={{\n          display: { xs: \"block\", sm: \"none\" },\n          \"& .MuiDrawer-paper\": { boxSizing: \"border-box\", width: navWidth, backgroundImage: \"none\" },\n        }}\n      >\n        {navigationList}\n      </Drawer>\n      {/* Big screen drawer; persistent, shown if screen is big */}\n      <Drawer\n        open\n        variant=\"permanent\"\n        role=\"menubar\"\n        sx={{\n          display: { xs: \"none\", sm: \"block\" },\n          \"& .MuiDrawer-paper\": { boxSizing: \"border-box\", width: navWidth },\n        }}\n      >\n        {navigationList}\n      </Drawer>\n    </Box>\n  );\n};\nNavigation.width = navWidth;\n\nconst NavList = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const location = useLocation();\n  const { account } = useContext(AccountContext);\n  const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);\n  const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);\n  const [versionChanged, setVersionChanged] = useState(false);\n\n  const handleVersionChange = () => {\n    setVersionChanged(true);\n  };\n\n  useVersionChangeListener(handleVersionChange);\n\n  const handleSubscribeReset = () => {\n    setSubscribeDialogOpen(false);\n    setSubscribeDialogKey((prev) => prev + 1);\n  };\n\n  const handleSubscribeSubmit = (subscription) => {\n    console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);\n    handleSubscribeReset();\n    navigate(routes.forSubscription(subscription));\n  };\n\n  const handleAccountClick = () => {\n    accountApi.sync(); // Dangle!\n    navigate(routes.account);\n  };\n\n  const isAdmin = account?.role === Role.ADMIN;\n  const isPaid = account?.billing?.subscription;\n  const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;\n  const showSubscriptionsList = props.subscriptions?.length > 0;\n  const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());\n  const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());\n  const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();\n  const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();\n  const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser\n\n  const alertVisible =\n    versionChanged ||\n    showNotificationPermissionRequired ||\n    showNotificationPermissionDenied ||\n    showNotificationIOSInstallRequired ||\n    showNotificationBrowserNotSupportedBox ||\n    showNotificationContextNotSupportedBox;\n\n  return (\n    <>\n      <Toolbar sx={{ display: { xs: \"none\", sm: \"block\" } }} />\n      <List component=\"nav\" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : \"\" } }}>\n        {versionChanged && <VersionUpdateBanner />}\n        {showNotificationPermissionRequired && <NotificationPermissionRequired />}\n        {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}\n        {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}\n        {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}\n        {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}\n        {alertVisible && <Divider />}\n        {!showSubscriptionsList && (\n          <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>\n            <ListItemIcon>\n              <ChatBubble />\n            </ListItemIcon>\n            <ListItemText primary={t(\"nav_button_all_notifications\")} />\n          </ListItemButton>\n        )}\n        {showSubscriptionsList && (\n          <>\n            <ListSubheader>{t(\"nav_topics_title\")}</ListSubheader>\n            <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>\n              <ListItemIcon>\n                <ChatBubble />\n              </ListItemIcon>\n              <ListItemText primary={t(\"nav_button_all_notifications\")} />\n            </ListItemButton>\n            <SubscriptionList subscriptions={props.subscriptions} selectedSubscription={props.selectedSubscription} />\n            <Divider sx={{ my: 1 }} />\n          </>\n        )}\n        {session.exists() && (\n          <ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>\n            <ListItemIcon>\n              <Person />\n            </ListItemIcon>\n            <ListItemText primary={t(\"nav_button_account\")} />\n          </ListItemButton>\n        )}\n        <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>\n          <ListItemIcon>\n            <SettingsIcon />\n          </ListItemIcon>\n          <ListItemText primary={t(\"nav_button_settings\")} />\n        </ListItemButton>\n        <ListItemButton onClick={() => openUrl(\"/docs\")}>\n          <ListItemIcon>\n            <ArticleIcon />\n          </ListItemIcon>\n          <ListItemText primary={t(\"nav_button_documentation\")} />\n        </ListItemButton>\n        <ListItemButton onClick={() => props.onPublishMessageClick()}>\n          <ListItemIcon>\n            <Send />\n          </ListItemIcon>\n          <ListItemText primary={t(\"nav_button_publish_message\")} />\n        </ListItemButton>\n        <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>\n          <ListItemIcon>\n            <AddIcon />\n          </ListItemIcon>\n          <ListItemText primary={t(\"nav_button_subscribe\")} />\n        </ListItemButton>\n        {showUpgradeBanner && (\n          // The text background gradient didn't seem to do well with switching between light/dark mode,\n          // So adding a `key` forces React to replace the entire component when the theme changes\n          <UpgradeBanner key={`upgrade-banner-${theme.palette.mode}`} mode={theme.palette.mode} />\n        )}\n      </List>\n      <SubscribeDialog\n        key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed\n        open={subscribeDialogOpen}\n        subscriptions={props.subscriptions}\n        onCancel={handleSubscribeReset}\n        onSuccess={handleSubscribeSubmit}\n      />\n    </>\n  );\n};\n\nconst UpgradeBanner = ({ mode }) => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n\n  const handleClick = () => {\n    setDialogKey((k) => k + 1);\n    setDialogOpen(true);\n  };\n\n  return (\n    <Box\n      sx={{\n        position: \"fixed\",\n        width: `${Navigation.width - 1}px`,\n        bottom: 0,\n        mt: \"auto\",\n        background:\n          mode === \"light\"\n            ? \"linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)\"\n            : \"linear-gradient(150deg, #203631 0%, #2a6e60 100%)\",\n      }}\n    >\n      <Divider />\n      <ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>\n        <ListItemIcon>\n          <CelebrationIcon sx={{ color: mode === \"light\" ? \"#55b86e\" : \"#00ff95\" }} fontSize=\"large\" />\n        </ListItemIcon>\n        <ListItemText\n          sx={{ ml: 1 }}\n          primary={t(\"nav_upgrade_banner_label\")}\n          secondary={t(\"nav_upgrade_banner_description\")}\n          primaryTypographyProps={{\n            style: {\n              fontWeight: 500,\n              fontSize: \"1.1rem\",\n              background:\n                mode === \"light\"\n                  ? \"-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)\"\n                  : \"-webkit-linear-gradient(45deg,rgb(255, 255, 255), #00ff95 80%)\",\n              WebkitBackgroundClip: \"text\",\n              WebkitTextFillColor: \"transparent\",\n            },\n          }}\n          secondaryTypographyProps={{\n            style: {\n              fontSize: \"1rem\",\n            },\n          }}\n        />\n      </ListItemButton>\n      <UpgradeDialog key={`upgradeDialog${dialogKey}`} open={dialogOpen} onCancel={() => setDialogOpen(false)} />\n    </Box>\n  );\n};\n\nconst SubscriptionList = (props) => {\n  const sortedSubscriptions = props.subscriptions\n    .filter((s) => !s.internal)\n    .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));\n  return (\n    <>\n      {sortedSubscriptions.map((subscription) => (\n        <SubscriptionItem\n          key={subscription.id}\n          subscription={subscription}\n          selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}\n        />\n      ))}\n    </>\n  );\n};\n\nconst SubscriptionItem = (props) => {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const [menuAnchorEl, setMenuAnchorEl] = useState(null);\n\n  const { subscription } = props;\n  const iconBadge = subscription.new <= 99 ? subscription.new : \"99+\";\n  const displayName = topicDisplayName(subscription);\n  const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t(\"nav_button_connecting\")})` : displayName;\n  const icon =\n    subscription.state === ConnectionState.Connecting ? (\n      <CircularProgress size=\"24px\" />\n    ) : (\n      <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color=\"primary\">\n        <ChatBubbleOutlineIcon />\n      </Badge>\n    );\n\n  const handleClick = async () => {\n    navigate(routes.forSubscription(subscription));\n    await subscriptionManager.markNotificationsRead(subscription.id);\n  };\n\n  return (\n    <>\n      <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live=\"polite\">\n        <ListItemIcon>{icon}</ListItemIcon>\n        <ListItemText\n          primary={displayName}\n          primaryTypographyProps={{\n            style: { overflow: \"hidden\", textOverflow: \"ellipsis\" },\n          }}\n        />\n        {subscription.reservation?.everyone && (\n          <ListItemIcon edge=\"end\" sx={{ minWidth: \"26px\" }}>\n            {subscription.reservation?.everyone === Permission.READ_WRITE && (\n              <Tooltip title={t(\"prefs_reservations_table_everyone_read_write\")}>\n                <PermissionReadWrite size=\"small\" />\n              </Tooltip>\n            )}\n            {subscription.reservation?.everyone === Permission.READ_ONLY && (\n              <Tooltip title={t(\"prefs_reservations_table_everyone_read_only\")}>\n                <PermissionRead size=\"small\" />\n              </Tooltip>\n            )}\n            {subscription.reservation?.everyone === Permission.WRITE_ONLY && (\n              <Tooltip title={t(\"prefs_reservations_table_everyone_write_only\")}>\n                <PermissionWrite size=\"small\" />\n              </Tooltip>\n            )}\n            {subscription.reservation?.everyone === Permission.DENY_ALL && (\n              <Tooltip title={t(\"prefs_reservations_table_everyone_deny_all\")}>\n                <PermissionDenyAll size=\"small\" />\n              </Tooltip>\n            )}\n          </ListItemIcon>\n        )}\n        {subscription.mutedUntil > 0 && (\n          <ListItemIcon edge=\"end\" sx={{ minWidth: \"26px\" }} aria-label={t(\"nav_button_muted\")}>\n            <Tooltip title={t(\"nav_button_muted\")}>\n              <NotificationsOffOutlined />\n            </Tooltip>\n          </ListItemIcon>\n        )}\n        <ListItemIcon edge=\"end\" sx={{ minWidth: \"26px\" }}>\n          <IconButton\n            size=\"small\"\n            onMouseDown={(e) => e.stopPropagation()}\n            onClick={(e) => {\n              e.stopPropagation();\n              setMenuAnchorEl(e.currentTarget);\n            }}\n          >\n            <MoreVert fontSize=\"small\" />\n          </IconButton>\n        </ListItemIcon>\n      </ListItemButton>\n      <Portal>\n        <SubscriptionPopup subscription={subscription} anchor={menuAnchorEl} onClose={() => setMenuAnchorEl(null)} />\n      </Portal>\n    </>\n  );\n};\n\nconst NotificationPermissionRequired = () => {\n  const { t } = useTranslation();\n  const requestPermission = async () => {\n    await notifier.maybeRequestPermission();\n  };\n  return (\n    <Alert severity=\"warning\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"alert_notification_permission_required_title\")}</AlertTitle>\n      <Typography gutterBottom>{t(\"alert_notification_permission_required_description\")}</Typography>\n      <Button sx={{ float: \"right\" }} color=\"inherit\" size=\"small\" onClick={requestPermission}>\n        {t(\"alert_notification_permission_required_button\")}\n      </Button>\n    </Alert>\n  );\n};\n\nconst NotificationPermissionDeniedAlert = () => {\n  const { t } = useTranslation();\n  return (\n    <Alert severity=\"warning\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"alert_notification_permission_denied_title\")}</AlertTitle>\n      <Typography gutterBottom>{t(\"alert_notification_permission_denied_description\")}</Typography>\n    </Alert>\n  );\n};\n\nconst NotificationIOSInstallRequiredAlert = () => {\n  const { t } = useTranslation();\n  return (\n    <Alert severity=\"warning\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"alert_notification_ios_install_required_title\")}</AlertTitle>\n      <Typography gutterBottom>{t(\"alert_notification_ios_install_required_description\")}</Typography>\n    </Alert>\n  );\n};\n\nconst NotificationBrowserNotSupportedAlert = () => {\n  const { t } = useTranslation();\n  return (\n    <Alert severity=\"warning\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"alert_not_supported_title\")}</AlertTitle>\n      <Typography gutterBottom>{t(\"alert_not_supported_description\")}</Typography>\n    </Alert>\n  );\n};\n\nconst NotificationContextNotSupportedAlert = () => {\n  const { t } = useTranslation();\n  return (\n    <Alert severity=\"warning\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"alert_not_supported_title\")}</AlertTitle>\n      <Typography gutterBottom>\n        <Trans\n          i18nKey=\"alert_not_supported_context_description\"\n          components={{\n            mdnLink: <Link href=\"https://developer.mozilla.org/en-US/docs/Web/API/notification\" target=\"_blank\" rel=\"noopener\" />,\n          }}\n        />\n      </Typography>\n    </Alert>\n  );\n};\n\nconst VersionUpdateBanner = () => {\n  const { t } = useTranslation();\n  const handleRefresh = () => {\n    window.location.reload();\n  };\n  return (\n    <Alert severity=\"info\" sx={{ paddingTop: 2 }}>\n      <AlertTitle>{t(\"version_update_available_title\")}</AlertTitle>\n      <Typography gutterBottom>{t(\"version_update_available_description\")}</Typography>\n      <Button sx={{ float: \"right\" }} color=\"inherit\" size=\"small\" onClick={handleRefresh}>\n        {t(\"common_refresh\")}\n      </Button>\n    </Alert>\n  );\n};\n\nexport default Navigation;\n"
  },
  {
    "path": "web/src/components/Notifications.jsx",
    "content": "import {\n  Container,\n  ButtonBase,\n  CardActions,\n  CardContent,\n  CircularProgress,\n  Fade,\n  Link,\n  Modal,\n  Snackbar,\n  Stack,\n  Tooltip,\n  Card,\n  Typography,\n  IconButton,\n  Box,\n  Button,\n} from \"@mui/material\";\nimport * as React from \"react\";\nimport { useEffect, useState } from \"react\";\nimport CheckIcon from \"@mui/icons-material/Check\";\nimport CloseIcon from \"@mui/icons-material/Close\";\nimport { useLiveQuery } from \"dexie-react-hooks\";\nimport InfiniteScroll from \"react-infinite-scroll-component\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { useOutletContext } from \"react-router-dom\";\nimport { useRemark } from \"react-remark\";\nimport styled from \"@emotion/styled\";\nimport {\n  copyToClipboard,\n  formatBytes,\n  formatShortDateTime,\n  maybeActionErrors,\n  openUrl,\n  shortUrl,\n  topicUrl,\n  unmatchedTags,\n} from \"../app/utils\";\nimport { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from \"../app/actions\";\nimport { formatMessage, formatTitle, isImage } from \"../app/notificationUtils\";\nimport { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from \"./styles\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport notifier from \"../app/Notifier\";\nimport priority1 from \"../img/priority-1.svg\";\nimport priority2 from \"../img/priority-2.svg\";\nimport priority4 from \"../img/priority-4.svg\";\nimport priority5 from \"../img/priority-5.svg\";\nimport logoOutline from \"../img/ntfy-outline.svg\";\nimport AttachmentIcon from \"./AttachmentIcon\";\nimport { useAutoSubscribe } from \"./hooks\";\n\nconst priorityFiles = {\n  1: priority1,\n  2: priority2,\n  4: priority4,\n  5: priority5,\n};\n\nexport const AllSubscriptions = () => {\n  const { subscriptions } = useOutletContext();\n  if (!subscriptions) {\n    return <Loading />;\n  }\n  return <AllSubscriptionsList subscriptions={subscriptions} />;\n};\n\nexport const SingleSubscription = () => {\n  const { subscriptions, selected } = useOutletContext();\n  useAutoSubscribe(subscriptions, selected);\n  if (!selected) {\n    return <Loading />;\n  }\n  return <SingleSubscriptionList subscription={selected} />;\n};\n\nconst AllSubscriptionsList = (props) => {\n  const { subscriptions } = props;\n  const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);\n  if (notifications === null || notifications === undefined) {\n    return <Loading />;\n  }\n  if (subscriptions.length === 0) {\n    return <NoSubscriptions />;\n  }\n  if (notifications.length === 0) {\n    return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;\n  }\n  return <NotificationList key=\"all\" notifications={notifications} messageBar={false} />;\n};\n\nconst SingleSubscriptionList = (props) => {\n  const { subscription } = props;\n  const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);\n  if (notifications === null || notifications === undefined) {\n    return <Loading />;\n  }\n  if (notifications.length === 0) {\n    return <NoNotifications subscription={subscription} />;\n  }\n  return <NotificationList id={subscription.id} notifications={notifications} messageBar />;\n};\n\nconst NotificationList = (props) => {\n  const { t } = useTranslation();\n  const pageSize = 20;\n  const { notifications } = props;\n  const [snackOpen, setSnackOpen] = useState(false);\n  const [maxCount, setMaxCount] = useState(pageSize);\n  const count = Math.min(notifications.length, maxCount);\n\n  useEffect(\n    () => () => {\n      setMaxCount(pageSize);\n      const main = document.getElementById(\"main\");\n      if (main) {\n        main.scrollTo(0, 0);\n      }\n    },\n    [props.id]\n  );\n\n  return (\n    <InfiniteScroll\n      dataLength={count}\n      next={() => setMaxCount((prev) => prev + pageSize)}\n      hasMore={count < notifications.length}\n      loader={<>Loading ...</>}\n      scrollThreshold={0.7}\n      scrollableTarget=\"main\"\n    >\n      <Container\n        maxWidth=\"md\"\n        role=\"list\"\n        aria-label={t(\"notifications_list\")}\n        sx={{\n          marginTop: 3,\n          marginBottom: props.messageBar ? \"100px\" : 3, // Hack to avoid hiding notifications behind the message bar\n        }}\n      >\n        <Stack spacing={3}>\n          {notifications.slice(0, count).map((notification) => (\n            <NotificationItem key={notification.id} notification={notification} onShowSnack={() => setSnackOpen(true)} />\n          ))}\n          <Snackbar\n            open={snackOpen}\n            autoHideDuration={3000}\n            onClose={() => setSnackOpen(false)}\n            message={t(\"notifications_copied_to_clipboard\")}\n          />\n        </Stack>\n      </Container>\n    </InfiniteScroll>\n  );\n};\n\n/**\n * Replace links with <Link/> components; this is a combination of the genius function\n * in [1] and the regex in [2].\n *\n * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760\n * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9\n */\nconst autolink = (s) => {\n  const parts = s.split(/(\\bhttps?:\\/\\/[-A-Z0-9+\\u0026\\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\\u0026@#/%=~()_|]\\b)/gi);\n  for (let i = 1; i < parts.length; i += 2) {\n    parts[i] = (\n      <Link key={i} href={parts[i]} underline=\"hover\" target=\"_blank\" rel=\"noreferrer,noopener\">\n        {shortUrl(parts[i])}\n      </Link>\n    );\n  }\n  return <>{parts}</>;\n};\n\nconst MarkdownContainer = styled(\"div\")`\n  line-height: 1;\n\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6,\n  p,\n  pre,\n  ul,\n  ol,\n  blockquote {\n    margin: 0;\n  }\n\n  p {\n    line-height: 1.5;\n  }\n\n  blockquote,\n  pre {\n    border-radius: 3px;\n    background: ${(props) => (props.theme.palette.mode === \"light\" ? \"#f5f5f5\" : \"#333\")};\n  }\n\n  pre {\n    overflow-x: scroll;\n    padding: 0.9rem;\n  }\n\n  ul,\n  ol,\n  blockquote {\n    padding-inline: 1rem;\n  }\n\n  img {\n    max-width: 100%;\n  }\n`;\n\nconst MarkdownContent = ({ content }) => {\n  const [reactContent, setMarkdownSource] = useRemark();\n\n  useEffect(() => {\n    setMarkdownSource(content);\n  }, [content]);\n\n  return <MarkdownContainer>{reactContent}</MarkdownContainer>;\n};\n\nconst NotificationBody = ({ notification }) => {\n  const displayAsMarkdown = notification.content_type === \"text/markdown\";\n  const formatted = formatMessage(notification);\n  if (displayAsMarkdown) {\n    return <MarkdownContent content={formatted} />;\n  }\n  return autolink(formatted);\n};\n\nconst NotificationItem = (props) => {\n  const { t, i18n } = useTranslation();\n  const { notification } = props;\n  const { attachment } = notification;\n  const date = formatShortDateTime(notification.time, i18n.language);\n  const otherTags = unmatchedTags(notification.tags);\n  const tags = otherTags.length > 0 ? otherTags.join(\", \") : null;\n  const handleDelete = async () => {\n    console.log(`[Notifications] Deleting notification ${notification.id}`);\n    await subscriptionManager.deleteNotification(notification.id);\n  };\n  const handleMarkRead = async () => {\n    console.log(`[Notifications] Marking notification ${notification.id} as read`);\n    await subscriptionManager.markNotificationRead(notification.id);\n  };\n  const handleCopy = (s) => {\n    copyToClipboard(s);\n    props.onShowSnack();\n  };\n  const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;\n  const hasAttachmentActions = attachment && !expired;\n  const hasClickAction = notification.click;\n  const hasUserActions = notification.actions && notification.actions.length > 0;\n  const showActions = hasAttachmentActions || hasClickAction || hasUserActions;\n\n  return (\n    <Card sx={{ padding: 1 }} role=\"listitem\" aria-label={t(\"notifications_list_item\")}>\n      <CardContent>\n        <Tooltip title={t(\"notifications_delete\")} enterDelay={500}>\n          <IconButton onClick={handleDelete} sx={{ float: \"right\", marginRight: -1, marginTop: -1 }} aria-label={t(\"notifications_delete\")}>\n            <CloseIcon />\n          </IconButton>\n        </Tooltip>\n        {notification.new === 1 && (\n          <Tooltip title={t(\"notifications_mark_read\")} enterDelay={500}>\n            <IconButton\n              onClick={handleMarkRead}\n              sx={{ float: \"right\", marginRight: -0.5, marginTop: -1 }}\n              aria-label={t(\"notifications_mark_read\")}\n            >\n              <CheckIcon />\n            </IconButton>\n          </Tooltip>\n        )}\n        <Typography sx={{ fontSize: 14 }} color=\"text.secondary\">\n          {date}\n          {[1, 2, 4, 5].includes(notification.priority) && (\n            <img\n              src={priorityFiles[notification.priority]}\n              alt={t(\"notifications_priority_x\", {\n                priority: notification.priority,\n              })}\n              style={{ verticalAlign: \"bottom\" }}\n            />\n          )}\n          {notification.new === 1 && (\n            <svg\n              style={{ width: \"8px\", height: \"8px\", marginLeft: \"4px\" }}\n              viewBox=\"0 0 100 100\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              aria-label={t(\"notifications_new_indicator\")}\n            >\n              <circle cx=\"50\" cy=\"50\" r=\"50\" fill=\"#338574\" />\n            </svg>\n          )}\n        </Typography>\n        {notification.title && (\n          <Typography variant=\"h5\" component=\"div\" role=\"rowheader\">\n            {formatTitle(notification)}\n          </Typography>\n        )}\n        <Typography variant=\"body1\" sx={{ whiteSpace: \"pre-line\", overflowX: \"auto\" }}>\n          <NotificationBody notification={notification} />\n          {maybeActionErrors(notification)}\n        </Typography>\n        {attachment && <Attachment attachment={attachment} />}\n        {tags && (\n          <Typography sx={{ fontSize: 14 }} color=\"text.secondary\">\n            {t(\"notifications_tags\")}: {tags}\n          </Typography>\n        )}\n      </CardContent>\n      {showActions && (\n        <CardActions sx={{ paddingTop: 0 }}>\n          {hasAttachmentActions && (\n            <>\n              <Tooltip title={t(\"notifications_attachment_copy_url_title\")}>\n                <Button onClick={() => handleCopy(attachment.url)}>{t(\"notifications_attachment_copy_url_button\")}</Button>\n              </Tooltip>\n              <Tooltip\n                title={t(\"notifications_attachment_open_title\", {\n                  url: attachment.url,\n                })}\n              >\n                <Button onClick={() => openUrl(attachment.url)}>{t(\"notifications_attachment_open_button\")}</Button>\n              </Tooltip>\n            </>\n          )}\n          {hasClickAction && (\n            <>\n              <Tooltip title={t(\"notifications_click_copy_url_title\")}>\n                <Button onClick={() => handleCopy(notification.click)}>{t(\"notifications_click_copy_url_button\")}</Button>\n              </Tooltip>\n              <Tooltip\n                title={t(\"notifications_actions_open_url_title\", {\n                  url: notification.click,\n                })}\n              >\n                <Button onClick={() => openUrl(notification.click)}>{t(\"notifications_click_open_button\")}</Button>\n              </Tooltip>\n            </>\n          )}\n          {hasUserActions && <UserActions notification={notification} onShowSnack={props.onShowSnack} />}\n        </CardActions>\n      )}\n    </Card>\n  );\n};\n\nconst Attachment = (props) => {\n  const { t, i18n } = useTranslation();\n  const { attachment } = props;\n  const expired = attachment.expires && attachment.expires < Date.now() / 1000;\n  const expires = attachment.expires && attachment.expires > Date.now() / 1000;\n  const displayableImage = !expired && isImage(attachment);\n\n  // Unexpired image\n  if (displayableImage) {\n    return <Image attachment={attachment} />;\n  }\n\n  // Anything else: Show box\n  const infos = [];\n  if (attachment.size) {\n    infos.push(formatBytes(attachment.size));\n  }\n  if (expires) {\n    infos.push(\n      t(\"notifications_attachment_link_expires\", {\n        date: formatShortDateTime(attachment.expires, i18n.language),\n      })\n    );\n  }\n  if (expired) {\n    infos.push(t(\"notifications_attachment_link_expired\"));\n  }\n  const maybeInfoText =\n    infos.length > 0 ? (\n      <>\n        <br />\n        {infos.join(\", \")}\n      </>\n    ) : null;\n\n  // If expired, just show infos without click target\n  if (expired) {\n    return (\n      <Box\n        sx={{\n          display: \"flex\",\n          alignItems: \"center\",\n          marginTop: 2,\n          padding: 1,\n          borderRadius: \"4px\",\n        }}\n      >\n        <AttachmentIcon type={attachment.type} />\n        <Typography variant=\"body2\" sx={{ marginLeft: 1, textAlign: \"left\", color: \"text.primary\" }}>\n          <b>{attachment.name}</b>\n          {maybeInfoText}\n        </Typography>\n      </Box>\n    );\n  }\n\n  // Not expired\n  return (\n    <ButtonBase\n      sx={{\n        marginTop: 2,\n      }}\n    >\n      <Link\n        href={attachment.url}\n        target=\"_blank\"\n        rel=\"noopener\"\n        underline=\"none\"\n        sx={{\n          display: \"flex\",\n          alignItems: \"center\",\n          padding: 1,\n          borderRadius: \"4px\",\n          \"&:hover\": {\n            backgroundColor: \"rgba(0, 0, 0, 0.05)\",\n          },\n        }}\n      >\n        <AttachmentIcon type={attachment.type} />\n        <Typography variant=\"body2\" sx={{ marginLeft: 1, textAlign: \"left\", color: \"text.primary\" }}>\n          <b>{attachment.name}</b>\n          {maybeInfoText}\n        </Typography>\n      </Link>\n    </ButtonBase>\n  );\n};\n\nconst Image = (props) => {\n  const { t } = useTranslation();\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Box\n        component=\"img\"\n        src={props.attachment.url}\n        loading=\"lazy\"\n        alt={t(\"notifications_attachment_image\")}\n        onClick={() => setOpen(true)}\n        sx={{\n          marginTop: 2,\n          borderRadius: \"4px\",\n          boxShadow: 2,\n          width: 1,\n          maxHeight: \"400px\",\n          objectFit: \"cover\",\n          cursor: \"pointer\",\n        }}\n      />\n      <Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>\n        <Fade in={open}>\n          <Box\n            component=\"img\"\n            src={props.attachment.url}\n            alt={t(\"notifications_attachment_image\")}\n            loading=\"lazy\"\n            sx={{\n              maxWidth: 1,\n              maxHeight: 1,\n              position: \"absolute\",\n              top: \"50%\",\n              left: \"50%\",\n              transform: \"translate(-50%, -50%)\",\n              padding: 4,\n            }}\n          />\n        </Fade>\n      </Modal>\n    </>\n  );\n};\n\nconst UserActions = (props) => (\n  <>\n    {props.notification.actions.map((action) => (\n      <UserAction key={action.id} notification={props.notification} action={action} onShowSnack={props.onShowSnack} />\n    ))}\n  </>\n);\n\nconst ACTION_PROGRESS_ONGOING = 1;\nconst ACTION_PROGRESS_SUCCESS = 2;\nconst ACTION_PROGRESS_FAILED = 3;\n\nconst ACTION_LABEL_SUFFIX = {\n  [ACTION_PROGRESS_ONGOING]: \" …\",\n  [ACTION_PROGRESS_SUCCESS]: \" ✔\",\n  [ACTION_PROGRESS_FAILED]: \" ❌\",\n};\n\nconst updateActionStatus = (notification, action, progress, error) => {\n  subscriptionManager.updateNotification({\n    ...notification,\n    actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)),\n  });\n};\n\nconst clearNotification = async (notification) => {\n  console.log(`[Notifications] Clearing notification ${notification.id}`);\n  const subscription = await subscriptionManager.get(notification.subscriptionId);\n  if (subscription) {\n    await notifier.cancel(subscription, notification);\n  }\n  await subscriptionManager.markNotificationRead(notification.id);\n};\n\nconst performHttpAction = async (notification, action) => {\n  console.log(`[Notifications] Performing HTTP user action`, action);\n  try {\n    updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);\n    const response = await fetch(action.url, {\n      method: action.method ?? \"POST\",\n      headers: action.headers ?? {},\n      // This must not null-coalesce to a non nullish value. Otherwise, the fetch API\n      // will reject it for \"having a body\"\n      body: action.body,\n    });\n    console.log(`[Notifications] HTTP user action response`, response);\n    const success = response.status >= 200 && response.status <= 299;\n    if (success) {\n      updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);\n      if (action.clear) {\n        await clearNotification(notification);\n      }\n    } else {\n      updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);\n    }\n  } catch (e) {\n    console.log(`[Notifications] HTTP action failed`, e);\n    updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);\n  }\n};\n\nconst UserAction = (props) => {\n  const { t } = useTranslation();\n  const { notification } = props;\n  const { action } = props;\n  if (action.action === ACTION_BROADCAST) {\n    return (\n      <Tooltip title={t(\"notifications_actions_not_supported\")}>\n        <span>\n          <Button disabled aria-label={t(\"notifications_actions_not_supported\")}>\n            {action.label}\n          </Button>\n        </span>\n      </Tooltip>\n    );\n  }\n  if (action.action === ACTION_VIEW) {\n    const handleClick = () => {\n      openUrl(action.url);\n      if (action.clear) {\n        clearNotification(notification);\n      }\n    };\n    return (\n      <Tooltip title={t(\"notifications_actions_open_url_title\", { url: action.url })}>\n        <Button\n          onClick={handleClick}\n          aria-label={t(\"notifications_actions_open_url_title\", {\n            url: action.url,\n          })}\n        >\n          {action.label}\n        </Button>\n      </Tooltip>\n    );\n  }\n  if (action.action === ACTION_HTTP) {\n    const method = action.method ?? \"POST\";\n    const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? \"\");\n    return (\n      <Tooltip\n        title={t(\"notifications_actions_http_request_title\", {\n          method,\n          url: action.url,\n        })}\n      >\n        <Button\n          onClick={() => performHttpAction(notification, action)}\n          aria-label={t(\"notifications_actions_http_request_title\", {\n            method,\n            url: action.url,\n          })}\n        >\n          {label}\n        </Button>\n      </Tooltip>\n    );\n  }\n  if (action.action === ACTION_COPY) {\n    const handleClick = async () => {\n      await copyToClipboard(action.value);\n      props.onShowSnack();\n      if (action.clear) {\n        await clearNotification(notification);\n      }\n    };\n    return (\n      <Tooltip title={t(\"common_copy_to_clipboard\")}>\n        <Button onClick={handleClick} aria-label={t(\"common_copy_to_clipboard\")}>\n          {action.label}\n        </Button>\n      </Tooltip>\n    );\n  }\n  return null; // Others\n};\n\nconst NoNotifications = (props) => {\n  const { t } = useTranslation();\n  const topicUrlResolved = topicUrl(props.subscription.baseUrl, props.subscription.topic);\n  return (\n    <VerticallyCenteredContainer maxWidth=\"xs\">\n      <Typography variant=\"h5\" align=\"center\" sx={{ paddingBottom: 1 }}>\n        <img src={logoOutline} height=\"64\" width=\"64\" alt={t(\"action_bar_logo_alt\")} />\n        <br />\n        {t(\"notifications_none_for_topic_title\")}\n      </Typography>\n      <Paragraph>{t(\"notifications_none_for_topic_description\")}</Paragraph>\n      <Paragraph>\n        {t(\"notifications_example\")}:<br />\n        <tt>\n          {'$ curl -d \"Hi\" '}\n          {topicUrlResolved}\n        </tt>\n      </Paragraph>\n      <Paragraph>\n        <ForMoreDetails />\n      </Paragraph>\n    </VerticallyCenteredContainer>\n  );\n};\n\nconst NoNotificationsWithoutSubscription = (props) => {\n  const { t } = useTranslation();\n  const subscription = props.subscriptions[0];\n  const topicUrlResolved = topicUrl(subscription.baseUrl, subscription.topic);\n  return (\n    <VerticallyCenteredContainer maxWidth=\"xs\">\n      <Typography variant=\"h5\" align=\"center\" sx={{ paddingBottom: 1 }}>\n        <img src={logoOutline} height=\"64\" width=\"64\" alt={t(\"action_bar_logo_alt\")} />\n        <br />\n        {t(\"notifications_none_for_any_title\")}\n      </Typography>\n      <Paragraph>{t(\"notifications_none_for_any_description\")}</Paragraph>\n      <Paragraph>\n        {t(\"notifications_example\")}:<br />\n        <tt>\n          {'$ curl -d \"Hi\" '}\n          {topicUrlResolved}\n        </tt>\n      </Paragraph>\n      <Paragraph>\n        <ForMoreDetails />\n      </Paragraph>\n    </VerticallyCenteredContainer>\n  );\n};\n\nconst NoSubscriptions = () => {\n  const { t } = useTranslation();\n  return (\n    <VerticallyCenteredContainer maxWidth=\"xs\">\n      <Typography variant=\"h5\" align=\"center\" sx={{ paddingBottom: 1 }}>\n        <img src={logoOutline} height=\"64\" width=\"64\" alt={t(\"action_bar_logo_alt\")} />\n        <br />\n        {t(\"notifications_no_subscriptions_title\")}\n      </Typography>\n      <Paragraph>\n        {t(\"notifications_no_subscriptions_description\", {\n          linktext: t(\"nav_button_subscribe\"),\n        })}\n      </Paragraph>\n      <Paragraph>\n        <ForMoreDetails />\n      </Paragraph>\n    </VerticallyCenteredContainer>\n  );\n};\n\nconst ForMoreDetails = () => (\n  <Trans\n    i18nKey=\"notifications_more_details\"\n    components={{\n      websiteLink: <Link href=\"https://ntfy.sh\" target=\"_blank\" rel=\"noopener\" />,\n      docsLink: <Link href=\"https://ntfy.sh/docs\" target=\"_blank\" rel=\"noopener\" />,\n    }}\n  />\n);\n\nconst Loading = () => {\n  const { t } = useTranslation();\n  return (\n    <VerticallyCenteredContainer>\n      <Typography variant=\"h5\" color=\"text.secondary\" align=\"center\" sx={{ paddingBottom: 1 }}>\n        <CircularProgress disableShrink sx={{ marginBottom: 1 }} />\n        <br />\n        {t(\"notifications_loading\")}\n      </Typography>\n    </VerticallyCenteredContainer>\n  );\n};\n"
  },
  {
    "path": "web/src/components/PopupMenu.jsx",
    "content": "import { Fade, Menu } from \"@mui/material\";\nimport * as React from \"react\";\n\nconst PopupMenu = (props) => {\n  const horizontal = props.horizontal ?? \"left\";\n  const arrow = horizontal === \"right\" ? { right: 19 } : { left: 19 };\n  return (\n    <Menu\n      anchorEl={props.anchorEl}\n      open={props.open}\n      onClose={props.onClose}\n      onClick={props.onClose}\n      TransitionComponent={Fade}\n      PaperProps={{\n        elevation: 0,\n        sx: {\n          overflow: \"visible\",\n          filter: \"drop-shadow(0px 2px 8px rgba(0,0,0,0.32))\",\n          mt: 1.5,\n          \"& .MuiAvatar-root\": {\n            width: 32,\n            height: 32,\n            ml: -0.5,\n            mr: 1,\n          },\n          \"&:before\": {\n            content: '\"\"',\n            display: \"block\",\n            position: \"absolute\",\n            top: 0,\n            width: 10,\n            height: 10,\n            bgcolor: \"background.paper\",\n            transform: \"translateY(-50%) rotate(45deg)\",\n            zIndex: 0,\n            ...arrow,\n          },\n        },\n      }}\n      transformOrigin={{ horizontal, vertical: \"top\" }}\n      anchorOrigin={{ horizontal, vertical: \"bottom\" }}\n    >\n      {props.children}\n    </Menu>\n  );\n};\n\nexport default PopupMenu;\n"
  },
  {
    "path": "web/src/components/Pref.jsx",
    "content": "import { styled } from \"@mui/material\";\nimport * as React from \"react\";\n\nexport const PrefGroup = styled(\"div\", { attrs: { role: \"table\" } })`\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n`;\n\nconst PrefRow = styled(\"div\")`\n  display: flex;\n  flex-direction: row;\n\n  > div:first-of-type {\n    flex: 1 0 40%;\n    display: flex;\n    flex-direction: column;\n    justify-content: ${(props) => (props.alignTop ? \"normal\" : \"center\")};\n  }\n\n  > div:last-of-type {\n    flex: 1 0 calc(60% - 50px);\n    display: flex;\n    flex-direction: column;\n    justify-content: ${(props) => (props.alignTop ? \"normal\" : \"center\")};\n  }\n\n  @media (max-width: 1000px) {\n    flex-direction: column;\n    gap: 10px;\n\n    > :div:first-of-type,\n    > :div:last-of-type {\n      flex: unset;\n    }\n\n    > div:last-of-type {\n      .MuiFormControl-root {\n        margin: 0;\n      }\n    }\n  }\n`;\n\nexport const Pref = (props) => (\n  <PrefRow role=\"row\" alignTop={props.alignTop}>\n    <div role=\"cell\" id={props.labelId ?? \"\"} aria-label={props.title}>\n      <div>\n        <b>{props.title}</b>\n        {props.subtitle && <em> ({props.subtitle})</em>}\n      </div>\n      {props.description && (\n        <div>\n          <em>{props.description}</em>\n        </div>\n      )}\n    </div>\n    <div role=\"cell\">{props.children}</div>\n  </PrefRow>\n);\n"
  },
  {
    "path": "web/src/components/Preferences.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useEffect, useState } from \"react\";\nimport {\n  Alert,\n  CardActions,\n  CardContent,\n  Chip,\n  FormControl,\n  Select,\n  Stack,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableRow,\n  Tooltip,\n  useMediaQuery,\n  Typography,\n  IconButton,\n  Container,\n  TextField,\n  MenuItem,\n  Card,\n  Button,\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  useTheme,\n} from \"@mui/material\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport CloseIcon from \"@mui/icons-material/Close\";\nimport PlayArrowIcon from \"@mui/icons-material/PlayArrow\";\nimport { useLiveQuery } from \"dexie-react-hooks\";\nimport { useTranslation } from \"react-i18next\";\nimport { Info } from \"@mui/icons-material\";\nimport { useOutletContext } from \"react-router-dom\";\nimport userManager from \"../app/UserManager\";\nimport { playSound, shortUrl, shuffle, sounds, validUrl } from \"../app/utils\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport accountApi, { Permission, Role } from \"../app/AccountApi\";\nimport { Pref, PrefGroup } from \"./Pref\";\nimport { AccountContext } from \"./App\";\nimport { Paragraph } from \"./styles\";\nimport prefs, { THEME } from \"../app/Prefs\";\nimport { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from \"./ReserveIcons\";\nimport { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from \"./ReserveDialogs\";\nimport { UnauthorizedError } from \"../app/errors\";\nimport { subscribeTopic } from \"./SubscribeDialog\";\nimport notifier from \"../app/Notifier\";\nimport { useIsLaunchedPWA, useNotificationPermissionListener } from \"./hooks\";\n\nconst maybeUpdateAccountSettings = async (payload) => {\n  if (!session.exists()) {\n    return;\n  }\n  try {\n    await accountApi.updateSettings(payload);\n  } catch (e) {\n    console.log(`[Preferences] Error updating account settings`, e);\n    if (e instanceof UnauthorizedError) {\n      await session.resetAndRedirect(routes.login);\n    }\n  }\n};\n\nconst Preferences = () => (\n  <Container maxWidth=\"md\" sx={{ marginTop: 3, marginBottom: 3 }}>\n    <Stack spacing={3}>\n      <Notifications />\n      <Reservations />\n      <Users />\n      <Appearance />\n    </Stack>\n  </Container>\n);\n\nconst Notifications = () => {\n  const { t } = useTranslation();\n  const isLaunchedPWA = useIsLaunchedPWA();\n  const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());\n\n  return (\n    <Card sx={{ p: 3 }} aria-label={t(\"prefs_notifications_title\")}>\n      <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n        {t(\"prefs_notifications_title\")}\n      </Typography>\n      <PrefGroup>\n        <Sound />\n        <MinPriority />\n        <DeleteAfter />\n        {!isLaunchedPWA && pushPossible && <WebPushEnabled />}\n      </PrefGroup>\n    </Card>\n  );\n};\n\nconst Sound = () => {\n  const { t } = useTranslation();\n  const labelId = \"prefSound\";\n  const sound = useLiveQuery(async () => prefs.sound());\n  const handleChange = async (ev) => {\n    await prefs.setSound(ev.target.value);\n    await maybeUpdateAccountSettings({\n      notification: {\n        sound: ev.target.value,\n      },\n    });\n  };\n  if (!sound) {\n    return null; // While loading\n  }\n  let description;\n  if (sound === \"none\") {\n    description = t(\"prefs_notifications_sound_description_none\");\n  } else {\n    description = t(\"prefs_notifications_sound_description_some\", {\n      sound: sounds[sound].label,\n    });\n  }\n  return (\n    <Pref labelId={labelId} title={t(\"prefs_notifications_sound_title\")} description={description}>\n      <div style={{ display: \"flex\", width: \"100%\" }}>\n        <FormControl fullWidth variant=\"standard\" sx={{ margin: 1 }}>\n          <Select value={sound} onChange={handleChange} aria-labelledby={labelId}>\n            <MenuItem value=\"none\">{t(\"prefs_notifications_sound_no_sound\")}</MenuItem>\n            {Object.entries(sounds).map((s) => (\n              <MenuItem key={s[0]} value={s[0]}>\n                {s[1].label}\n              </MenuItem>\n            ))}\n          </Select>\n        </FormControl>\n        <IconButton onClick={() => playSound(sound)} disabled={sound === \"none\"} aria-label={t(\"prefs_notifications_sound_play\")}>\n          <PlayArrowIcon />\n        </IconButton>\n      </div>\n    </Pref>\n  );\n};\n\nconst MinPriority = () => {\n  const { t } = useTranslation();\n  const labelId = \"prefMinPriority\";\n  const minPriority = useLiveQuery(async () => prefs.minPriority());\n  const handleChange = async (ev) => {\n    await prefs.setMinPriority(ev.target.value);\n    await maybeUpdateAccountSettings({\n      notification: {\n        min_priority: ev.target.value,\n      },\n    });\n  };\n  if (!minPriority) {\n    return null; // While loading\n  }\n  const priorities = {\n    1: t(\"priority_min\"),\n    2: t(\"priority_low\"),\n    3: t(\"priority_default\"),\n    4: t(\"priority_high\"),\n    5: t(\"priority_max\"),\n  };\n  let description;\n  if (minPriority === 1) {\n    description = t(\"prefs_notifications_min_priority_description_any\");\n  } else if (minPriority === 5) {\n    description = t(\"prefs_notifications_min_priority_description_max\");\n  } else {\n    description = t(\"prefs_notifications_min_priority_description_x_or_higher\", {\n      number: minPriority,\n      name: priorities[minPriority],\n    });\n  }\n  return (\n    <Pref labelId={labelId} title={t(\"prefs_notifications_min_priority_title\")} description={description}>\n      <FormControl fullWidth variant=\"standard\" sx={{ m: 1 }}>\n        <Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>\n          <MenuItem value={1}>{t(\"prefs_notifications_min_priority_any\")}</MenuItem>\n          <MenuItem value={2}>{t(\"prefs_notifications_min_priority_low_and_higher\")}</MenuItem>\n          <MenuItem value={3}>{t(\"prefs_notifications_min_priority_default_and_higher\")}</MenuItem>\n          <MenuItem value={4}>{t(\"prefs_notifications_min_priority_high_and_higher\")}</MenuItem>\n          <MenuItem value={5}>{t(\"prefs_notifications_min_priority_max_only\")}</MenuItem>\n        </Select>\n      </FormControl>\n    </Pref>\n  );\n};\n\nconst DeleteAfter = () => {\n  const { t } = useTranslation();\n  const labelId = \"prefDeleteAfter\";\n  const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());\n  const handleChange = async (ev) => {\n    await prefs.setDeleteAfter(ev.target.value);\n    await maybeUpdateAccountSettings({\n      notification: {\n        delete_after: ev.target.value,\n      },\n    });\n  };\n\n  if (deleteAfter === null || deleteAfter === undefined) {\n    // !deleteAfter will not work with \"0\"\n    return null; // While loading\n  }\n\n  const description = (() => {\n    switch (deleteAfter) {\n      case 0:\n        return t(\"prefs_notifications_delete_after_never_description\");\n      case 10800:\n        return t(\"prefs_notifications_delete_after_three_hours_description\");\n      case 86400:\n        return t(\"prefs_notifications_delete_after_one_day_description\");\n      case 604800:\n        return t(\"prefs_notifications_delete_after_one_week_description\");\n      case 2592000:\n        return t(\"prefs_notifications_delete_after_one_month_description\");\n      default:\n        return \"\";\n    }\n  })();\n\n  return (\n    <Pref labelId={labelId} title={t(\"prefs_notifications_delete_after_title\")} description={description}>\n      <FormControl fullWidth variant=\"standard\" sx={{ m: 1 }}>\n        <Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>\n          <MenuItem value={0}>{t(\"prefs_notifications_delete_after_never\")}</MenuItem>\n          <MenuItem value={10800}>{t(\"prefs_notifications_delete_after_three_hours\")}</MenuItem>\n          <MenuItem value={86400}>{t(\"prefs_notifications_delete_after_one_day\")}</MenuItem>\n          <MenuItem value={604800}>{t(\"prefs_notifications_delete_after_one_week\")}</MenuItem>\n          <MenuItem value={2592000}>{t(\"prefs_notifications_delete_after_one_month\")}</MenuItem>\n        </Select>\n      </FormControl>\n    </Pref>\n  );\n};\n\nconst Theme = () => {\n  const { t } = useTranslation();\n  const labelId = \"prefTheme\";\n  const theme = useLiveQuery(async () => prefs.theme());\n  const handleChange = async (ev) => {\n    await prefs.setTheme(ev.target.value);\n  };\n\n  return (\n    <Pref labelId={labelId} title={t(\"prefs_appearance_theme_title\")}>\n      <FormControl fullWidth variant=\"standard\" sx={{ m: 1 }}>\n        <Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>\n          <MenuItem value={THEME.SYSTEM}>{t(\"prefs_appearance_theme_system\")}</MenuItem>\n          <MenuItem value={THEME.DARK}>{t(\"prefs_appearance_theme_dark\")}</MenuItem>\n          <MenuItem value={THEME.LIGHT}>{t(\"prefs_appearance_theme_light\")}</MenuItem>\n        </Select>\n      </FormControl>\n    </Pref>\n  );\n};\n\nconst WebPushEnabled = () => {\n  const { t } = useTranslation();\n  const labelId = \"prefWebPushEnabled\";\n  const enabled = useLiveQuery(async () => prefs.webPushEnabled());\n  const handleChange = async (ev) => {\n    await prefs.setWebPushEnabled(ev.target.value);\n  };\n\n  return (\n    <Pref\n      labelId={labelId}\n      title={t(\"prefs_notifications_web_push_title\")}\n      description={enabled ? t(\"prefs_notifications_web_push_enabled_description\") : t(\"prefs_notifications_web_push_disabled_description\")}\n    >\n      <FormControl fullWidth variant=\"standard\" sx={{ m: 1 }}>\n        <Select value={enabled ?? false} onChange={handleChange} aria-labelledby={labelId}>\n          <MenuItem value>{t(\"prefs_notifications_web_push_enabled\", { server: shortUrl(config.base_url) })}</MenuItem>\n          <MenuItem value={false}>{t(\"prefs_notifications_web_push_disabled\")}</MenuItem>\n        </Select>\n      </FormControl>\n    </Pref>\n  );\n};\n\nconst Users = () => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const users = useLiveQuery(() => userManager.all());\n  const handleAddClick = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n  const handleDialogCancel = () => {\n    setDialogOpen(false);\n  };\n  const handleDialogSubmit = async (user) => {\n    setDialogOpen(false);\n    try {\n      await userManager.save(user);\n      console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);\n    } catch (e) {\n      console.log(`[Preferences] Error adding user.`, e);\n    }\n  };\n  return (\n    <Card sx={{ padding: 1 }} aria-label={t(\"prefs_users_title\")}>\n      <CardContent sx={{ paddingBottom: 1 }}>\n        <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n          {t(\"prefs_users_title\")}\n        </Typography>\n        <Paragraph>\n          {t(\"prefs_users_description\")}\n          {session.exists() && <>{` ${t(\"prefs_users_description_no_sync\")}`}</>}\n        </Paragraph>\n        {users?.length > 0 && <UserTable users={users} />}\n      </CardContent>\n      <CardActions>\n        <Button onClick={handleAddClick}>{t(\"prefs_users_add_button\")}</Button>\n        <UserDialog\n          key={`userAddDialog${dialogKey}`}\n          open={dialogOpen}\n          user={null}\n          users={users}\n          onCancel={handleDialogCancel}\n          onSubmit={handleDialogSubmit}\n        />\n      </CardActions>\n    </Card>\n  );\n};\n\nconst UserTable = (props) => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const [dialogUser, setDialogUser] = useState(null);\n\n  const handleEditClick = (user) => {\n    setDialogKey((prev) => prev + 1);\n    setDialogUser(user);\n    setDialogOpen(true);\n  };\n\n  const handleDialogCancel = () => {\n    setDialogOpen(false);\n  };\n\n  const handleDialogSubmit = async (user) => {\n    setDialogOpen(false);\n    try {\n      await userManager.save(user);\n      console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);\n    } catch (e) {\n      console.log(`[Preferences] Error updating user.`, e);\n    }\n  };\n\n  const handleDeleteClick = async (user) => {\n    try {\n      await userManager.delete(user.baseUrl);\n      console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);\n    } catch (e) {\n      console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);\n    }\n  };\n\n  return (\n    <Table size=\"small\" aria-label={t(\"prefs_users_table\")}>\n      <TableHead>\n        <TableRow>\n          <TableCell sx={{ paddingLeft: 0 }}>{t(\"prefs_users_table_user_header\")}</TableCell>\n          <TableCell>{t(\"prefs_users_table_base_url_header\")}</TableCell>\n          <TableCell />\n        </TableRow>\n      </TableHead>\n      <TableBody>\n        {props.users?.map((user) => (\n          <TableRow key={user.baseUrl} sx={{ \"&:last-child td, &:last-child th\": { border: 0 } }}>\n            <TableCell component=\"th\" scope=\"row\" sx={{ paddingLeft: 0 }} aria-label={t(\"prefs_users_table_user_header\")}>\n              {user.username}\n            </TableCell>\n            <TableCell aria-label={t(\"prefs_users_table_base_url_header\")}>{user.baseUrl}</TableCell>\n            <TableCell align=\"right\" sx={{ whiteSpace: \"nowrap\" }}>\n              {(!session.exists() || user.baseUrl !== config.base_url) && (\n                <>\n                  <Tooltip title={t(\"prefs_users_edit_button\")}>\n                    <IconButton onClick={() => handleEditClick(user)} aria-label={t(\"prefs_users_edit_button\")}>\n                      <EditIcon />\n                    </IconButton>\n                  </Tooltip>\n                  <Tooltip title={t(\"prefs_users_delete_button\")}>\n                    <IconButton onClick={() => handleDeleteClick(user)} aria-label={t(\"prefs_users_delete_button\")}>\n                      <CloseIcon />\n                    </IconButton>\n                  </Tooltip>\n                </>\n              )}\n              {session.exists() && user.baseUrl === config.base_url && (\n                <Tooltip title={t(\"prefs_users_table_cannot_delete_or_edit\")}>\n                  <span>\n                    <IconButton disabled>\n                      <EditIcon />\n                    </IconButton>\n                    <IconButton disabled>\n                      <CloseIcon />\n                    </IconButton>\n                  </span>\n                </Tooltip>\n              )}\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n      <UserDialog\n        key={`userEditDialog${dialogKey}`}\n        open={dialogOpen}\n        user={dialogUser}\n        users={props.users}\n        onCancel={handleDialogCancel}\n        onSubmit={handleDialogSubmit}\n      />\n    </Table>\n  );\n};\n\nconst UserDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [baseUrl, setBaseUrl] = useState(\"\");\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n  const editMode = props.user !== null;\n  const baseUrlValid = baseUrl.length === 0 || validUrl(baseUrl);\n  const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);\n  const baseUrlError = baseUrl.length > 0 && (!baseUrlValid || baseUrlExists);\n  const addButtonEnabled = (() => {\n    if (editMode) {\n      return username.length > 0 && password.length > 0;\n    }\n    return validUrl(baseUrl) && !baseUrlExists && username.length > 0 && password.length > 0;\n  })();\n  const baseUrlHelperText = (() => {\n    if (baseUrl.length > 0 && !baseUrlValid) {\n      return t(\"prefs_users_dialog_base_url_invalid\");\n    }\n    if (baseUrlExists) {\n      return t(\"prefs_users_dialog_base_url_exists\");\n    }\n    return \"\";\n  })();\n  const handleSubmit = async () => {\n    props.onSubmit({\n      baseUrl,\n      username,\n      password,\n    });\n  };\n  useEffect(() => {\n    if (editMode) {\n      setBaseUrl(props.user.baseUrl);\n      setUsername(props.user.username);\n      setPassword(props.user.password);\n    }\n  }, [editMode, props.user]);\n  return (\n    <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>\n      <DialogTitle>{editMode ? t(\"prefs_users_dialog_title_edit\") : t(\"prefs_users_dialog_title_add\")}</DialogTitle>\n      <DialogContent>\n        {!editMode && (\n          <TextField\n            autoFocus\n            margin=\"dense\"\n            id=\"baseUrl\"\n            label={t(\"prefs_users_dialog_base_url_label\")}\n            aria-label={t(\"prefs_users_dialog_base_url_label\")}\n            value={baseUrl}\n            onChange={(ev) => setBaseUrl(ev.target.value)}\n            type=\"url\"\n            fullWidth\n            variant=\"standard\"\n            error={baseUrlError}\n            helperText={baseUrlHelperText}\n          />\n        )}\n        <TextField\n          autoFocus={editMode}\n          margin=\"dense\"\n          id=\"username\"\n          label={t(\"prefs_users_dialog_username_label\")}\n          aria-label={t(\"prefs_users_dialog_username_label\")}\n          value={username}\n          onChange={(ev) => setUsername(ev.target.value)}\n          type=\"text\"\n          fullWidth\n          variant=\"standard\"\n        />\n        <TextField\n          margin=\"dense\"\n          id=\"password\"\n          label={t(\"prefs_users_dialog_password_label\")}\n          aria-label={t(\"prefs_users_dialog_password_label\")}\n          type=\"password\"\n          value={password}\n          onChange={(ev) => setPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n        />\n      </DialogContent>\n      <DialogActions>\n        <Button onClick={props.onCancel}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSubmit} disabled={!addButtonEnabled}>\n          {editMode ? t(\"common_save\") : t(\"common_add\")}\n        </Button>\n      </DialogActions>\n    </Dialog>\n  );\n};\n\nconst Appearance = () => {\n  const { t } = useTranslation();\n  return (\n    <Card sx={{ p: 3 }} aria-label={t(\"prefs_appearance_title\")}>\n      <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n        {t(\"prefs_appearance_title\")}\n      </Typography>\n      <PrefGroup>\n        <Theme />\n        <Language />\n      </PrefGroup>\n    </Card>\n  );\n};\n\nconst Language = () => {\n  const { t, i18n } = useTranslation();\n  const labelId = \"prefLanguage\";\n  const lang = i18n.resolvedLanguage ?? \"en\";\n\n  // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.\n  // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.\n  const randomFlags = shuffle([\n    \"🇬🇧\",\n    \"🇺🇸\",\n    \"🇪🇸\",\n    \"🇫🇷\",\n    \"🇧🇬\",\n    \"🇨🇿\",\n    \"🇩🇪\",\n    \"🇵🇱\",\n    \"🇺🇦\",\n    \"🇨🇳\",\n    \"🇮🇹\",\n    \"🇭🇺\",\n    \"🇧🇷\",\n    \"🇳🇱\",\n    \"🇮🇩\",\n    \"🇯🇵\",\n    \"🇷🇺\",\n    \"🇹🇷\",\n    \"🇫🇮\",\n  ]).slice(0, 3);\n  const showFlags = !navigator.userAgent.includes(\"Windows\");\n  let title = t(\"prefs_appearance_language_title\");\n  if (showFlags) {\n    title += ` ${randomFlags.join(\" \")}`;\n  }\n\n  const handleChange = async (ev) => {\n    await i18n.changeLanguage(ev.target.value);\n    await maybeUpdateAccountSettings({\n      language: ev.target.value,\n    });\n  };\n\n  // Remember: Flags are not languages. Don't put flags next to the language in the list.\n  // Languages names from: https://www.omniglot.com/language/names.htm\n  // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l\n\n  return (\n    <Pref labelId={labelId} title={title}>\n      <FormControl fullWidth variant=\"standard\" sx={{ m: 1 }}>\n        <Select value={lang} onChange={handleChange} aria-labelledby={labelId}>\n          <MenuItem value=\"en\">English</MenuItem>\n          <MenuItem value=\"ar\">العربية</MenuItem>\n          <MenuItem value=\"id\">Bahasa Indonesia</MenuItem>\n          <MenuItem value=\"bg\">Български</MenuItem>\n          <MenuItem value=\"cs\">Čeština</MenuItem>\n          <MenuItem value=\"zh_Hant\">繁體中文</MenuItem>\n          <MenuItem value=\"zh_Hans\">简体中文</MenuItem>\n          <MenuItem value=\"da\">Dansk</MenuItem>\n          <MenuItem value=\"de\">Deutsch</MenuItem>\n          <MenuItem value=\"et\">Eesti</MenuItem>\n          <MenuItem value=\"es\">Español</MenuItem>\n          <MenuItem value=\"fr\">Français</MenuItem>\n          <MenuItem value=\"gl\">Galego</MenuItem>\n          <MenuItem value=\"it\">Italiano</MenuItem>\n          <MenuItem value=\"hu\">Magyar</MenuItem>\n          <MenuItem value=\"ko\">한국어</MenuItem>\n          <MenuItem value=\"ja\">日本語</MenuItem>\n          <MenuItem value=\"nl\">Nederlands</MenuItem>\n          <MenuItem value=\"nb_NO\">Norsk bokmål</MenuItem>\n          <MenuItem value=\"uk\">Українська</MenuItem>\n          <MenuItem value=\"pt\">Português</MenuItem>\n          <MenuItem value=\"pt_BR\">Português (Brasil)</MenuItem>\n          <MenuItem value=\"pl\">Polski</MenuItem>\n          <MenuItem value=\"ru\">Русский</MenuItem>\n          <MenuItem value=\"ro\">Română</MenuItem>\n          <MenuItem value=\"sk\">Slovenčina</MenuItem>\n          <MenuItem value=\"fi\">Suomi</MenuItem>\n          <MenuItem value=\"sv\">Svenska</MenuItem>\n          <MenuItem value=\"tr\">Türkçe</MenuItem>\n          <MenuItem value=\"ta\">தமிழ்</MenuItem>\n        </Select>\n      </FormControl>\n    </Pref>\n  );\n};\n\nconst Reservations = () => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogOpen, setDialogOpen] = useState(false);\n\n  if (!config.enable_reservations || !session.exists() || !account) {\n    return <></>;\n  }\n  const reservations = account.reservations || [];\n  const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;\n\n  const handleAddClick = () => {\n    setDialogKey((prev) => prev + 1);\n    setDialogOpen(true);\n  };\n\n  return (\n    <Card sx={{ padding: 1 }} aria-label={t(\"prefs_reservations_title\")}>\n      <CardContent sx={{ paddingBottom: 1 }}>\n        <Typography variant=\"h5\" sx={{ marginBottom: 2 }}>\n          {t(\"prefs_reservations_title\")}\n        </Typography>\n        <Paragraph>{t(\"prefs_reservations_description\")}</Paragraph>\n        {reservations.length > 0 && <ReservationsTable reservations={reservations} />}\n        {limitReached && <Alert severity=\"info\">{t(\"prefs_reservations_limit_reached\")}</Alert>}\n      </CardContent>\n      <CardActions>\n        <Button onClick={handleAddClick} disabled={limitReached}>\n          {t(\"prefs_reservations_add_button\")}\n        </Button>\n        <ReserveAddDialog\n          key={`reservationAddDialog${dialogKey}`}\n          open={dialogOpen}\n          reservations={reservations}\n          onClose={() => setDialogOpen(false)}\n        />\n      </CardActions>\n    </Card>\n  );\n};\n\nconst ReservationsTable = (props) => {\n  const { t } = useTranslation();\n  const [dialogKey, setDialogKey] = useState(0);\n  const [dialogReservation, setDialogReservation] = useState(null);\n  const [editDialogOpen, setEditDialogOpen] = useState(false);\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n  const { subscriptions } = useOutletContext();\n  const localSubscriptions =\n    subscriptions?.length > 0\n      ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s })))\n      : {};\n\n  const handleEditClick = (reservation) => {\n    setDialogKey((prev) => prev + 1);\n    setDialogReservation(reservation);\n    setEditDialogOpen(true);\n  };\n\n  const handleDeleteClick = async (reservation) => {\n    setDialogKey((prev) => prev + 1);\n    setDialogReservation(reservation);\n    setDeleteDialogOpen(true);\n  };\n\n  const handleSubscribeClick = async (reservation) => {\n    await subscribeTopic(config.base_url, reservation.topic, {});\n  };\n\n  return (\n    <Table size=\"small\" aria-label={t(\"prefs_reservations_table\")}>\n      <TableHead>\n        <TableRow>\n          <TableCell sx={{ paddingLeft: 0 }}>{t(\"prefs_reservations_table_topic_header\")}</TableCell>\n          <TableCell>{t(\"prefs_reservations_table_access_header\")}</TableCell>\n          <TableCell />\n        </TableRow>\n      </TableHead>\n      <TableBody>\n        {props.reservations.map((reservation) => (\n          <TableRow key={reservation.topic} sx={{ \"&:last-child td, &:last-child th\": { border: 0 } }}>\n            <TableCell component=\"th\" scope=\"row\" sx={{ paddingLeft: 0 }} aria-label={t(\"prefs_reservations_table_topic_header\")}>\n              {reservation.topic}\n            </TableCell>\n            <TableCell aria-label={t(\"prefs_reservations_table_access_header\")}>\n              {reservation.everyone === Permission.READ_WRITE && (\n                <>\n                  <PermissionReadWrite size=\"small\" sx={{ verticalAlign: \"bottom\", mr: 1.5 }} />\n                  {t(\"prefs_reservations_table_everyone_read_write\")}\n                </>\n              )}\n              {reservation.everyone === Permission.READ_ONLY && (\n                <>\n                  <PermissionRead size=\"small\" sx={{ verticalAlign: \"bottom\", mr: 1.5 }} />\n                  {t(\"prefs_reservations_table_everyone_read_only\")}\n                </>\n              )}\n              {reservation.everyone === Permission.WRITE_ONLY && (\n                <>\n                  <PermissionWrite size=\"small\" sx={{ verticalAlign: \"bottom\", mr: 1.5 }} />\n                  {t(\"prefs_reservations_table_everyone_write_only\")}\n                </>\n              )}\n              {reservation.everyone === Permission.DENY_ALL && (\n                <>\n                  <PermissionDenyAll size=\"small\" sx={{ verticalAlign: \"bottom\", mr: 1.5 }} />\n                  {t(\"prefs_reservations_table_everyone_deny_all\")}\n                </>\n              )}\n            </TableCell>\n            <TableCell align=\"right\" sx={{ whiteSpace: \"nowrap\" }}>\n              {!localSubscriptions[reservation.topic] && (\n                <Tooltip title={t(\"prefs_reservations_table_click_to_subscribe\")}>\n                  <Chip\n                    icon={<Info />}\n                    onClick={() => handleSubscribeClick(reservation)}\n                    label={t(\"prefs_reservations_table_not_subscribed\")}\n                    color=\"primary\"\n                    variant=\"outlined\"\n                  />\n                </Tooltip>\n              )}\n              <Tooltip title={t(\"prefs_reservations_edit_button\")}>\n                <IconButton onClick={() => handleEditClick(reservation)} aria-label={t(\"prefs_reservations_edit_button\")}>\n                  <EditIcon />\n                </IconButton>\n              </Tooltip>\n              <Tooltip title={t(\"prefs_reservations_delete_button\")}>\n                <IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t(\"prefs_reservations_delete_button\")}>\n                  <CloseIcon />\n                </IconButton>\n              </Tooltip>\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n      <ReserveEditDialog\n        key={`reservationEditDialog${dialogKey}`}\n        open={editDialogOpen}\n        reservation={dialogReservation}\n        reservations={props.reservations}\n        onClose={() => setEditDialogOpen(false)}\n      />\n      <ReserveDeleteDialog\n        key={`reservationDeleteDialog${dialogKey}`}\n        open={deleteDialogOpen}\n        topic={dialogReservation?.topic}\n        onClose={() => setDeleteDialogOpen(false)}\n      />\n    </Table>\n  );\n};\n\nexport default Preferences;\n"
  },
  {
    "path": "web/src/components/PublishDialog.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useEffect, useRef, useState } from \"react\";\nimport {\n  Checkbox,\n  Chip,\n  FormControl,\n  FormControlLabel,\n  InputLabel,\n  Link,\n  Select,\n  Tooltip,\n  useMediaQuery,\n  TextField,\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  Button,\n  Typography,\n  IconButton,\n  MenuItem,\n  Box,\n  useTheme,\n} from \"@mui/material\";\nimport InsertEmoticonIcon from \"@mui/icons-material/InsertEmoticon\";\nimport { Close } from \"@mui/icons-material\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport priority1 from \"../img/priority-1.svg\";\nimport priority2 from \"../img/priority-2.svg\";\nimport priority3 from \"../img/priority-3.svg\";\nimport priority4 from \"../img/priority-4.svg\";\nimport priority5 from \"../img/priority-5.svg\";\nimport { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from \"../app/utils\";\nimport AttachmentIcon from \"./AttachmentIcon\";\nimport DialogFooter from \"./DialogFooter\";\nimport api from \"../app/Api\";\nimport userManager from \"../app/UserManager\";\nimport EmojiPicker from \"./EmojiPicker\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport accountApi from \"../app/AccountApi\";\nimport { UnauthorizedError } from \"../app/errors\";\nimport { AccountContext } from \"./App\";\n\nconst PublishDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [baseUrl, setBaseUrl] = useState(\"\");\n  const [topic, setTopic] = useState(\"\");\n  const [message, setMessage] = useState(\"\");\n  const [messageFocused, setMessageFocused] = useState(true);\n  const [title, setTitle] = useState(\"\");\n  const [tags, setTags] = useState(\"\");\n  const [priority, setPriority] = useState(3);\n  const [clickUrl, setClickUrl] = useState(\"\");\n  const [attachUrl, setAttachUrl] = useState(\"\");\n  const [attachFile, setAttachFile] = useState(null);\n  const [filename, setFilename] = useState(\"\");\n  const [filenameEdited, setFilenameEdited] = useState(false);\n  const [email, setEmail] = useState(\"\");\n  const [call, setCall] = useState(\"\");\n  const [delay, setDelay] = useState(\"\");\n  const [publishAnother, setPublishAnother] = useState(false);\n  const [markdownEnabled, setMarkdownEnabled] = useState(false);\n\n  const [showTopicUrl, setShowTopicUrl] = useState(\"\");\n  const [showClickUrl, setShowClickUrl] = useState(false);\n  const [showAttachUrl, setShowAttachUrl] = useState(false);\n  const [showEmail, setShowEmail] = useState(false);\n  const [showCall, setShowCall] = useState(false);\n  const [showDelay, setShowDelay] = useState(false);\n\n  const showAttachFile = !!attachFile && !showAttachUrl;\n  const attachFileInput = useRef();\n  const [attachFileError, setAttachFileError] = useState(\"\");\n\n  const [activeRequest, setActiveRequest] = useState(null);\n  const [status, setStatus] = useState(\"\");\n  const disabled = !!activeRequest;\n\n  const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);\n\n  const [dropZone, setDropZone] = useState(false);\n  const [sendButtonEnabled, setSendButtonEnabled] = useState(true);\n\n  const open = !!props.openMode;\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  useEffect(() => {\n    window.addEventListener(\"dragenter\", () => {\n      props.onDragEnter();\n      setDropZone(true);\n    });\n  }, []);\n\n  useEffect(() => {\n    setBaseUrl(props.baseUrl);\n    setTopic(props.topic);\n    setShowTopicUrl(!props.baseUrl || !props.topic);\n    setMessageFocused(!!props.topic); // Focus message only if topic is set\n  }, [props.baseUrl, props.topic]);\n\n  useEffect(() => {\n    const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;\n    setSendButtonEnabled(valid);\n  }, [baseUrl, topic, attachFileError]);\n\n  useEffect(() => {\n    setMessage(props.message);\n  }, [props.message]);\n\n  const updateBaseUrl = (newVal) => {\n    if (validUrl(newVal)) {\n      setBaseUrl(newVal.replace(/\\/$/, \"\")); // strip traililng slash after https?://\n    } else {\n      setBaseUrl(newVal);\n    }\n  };\n\n  const handleSubmit = async () => {\n    const url = new URL(topicUrl(baseUrl, topic));\n    if (title.trim()) {\n      url.searchParams.append(\"title\", title.trim());\n    }\n    if (tags.trim()) {\n      url.searchParams.append(\"tags\", tags.trim());\n    }\n    if (priority && priority !== 3) {\n      url.searchParams.append(\"priority\", priority.toString());\n    }\n    if (clickUrl.trim()) {\n      url.searchParams.append(\"click\", clickUrl.trim());\n    }\n    if (attachUrl.trim()) {\n      url.searchParams.append(\"attach\", attachUrl.trim());\n    }\n    if (filename.trim()) {\n      url.searchParams.append(\"filename\", filename.trim());\n    }\n    if (email.trim()) {\n      url.searchParams.append(\"email\", email.trim());\n    }\n    if (call.trim()) {\n      url.searchParams.append(\"call\", call.trim());\n    }\n    if (delay.trim()) {\n      url.searchParams.append(\"delay\", delay.trim());\n    }\n    if (attachFile && message.trim()) {\n      url.searchParams.append(\"message\", message.replaceAll(\"\\n\", \"\\\\n\").trim());\n    }\n    if (markdownEnabled) {\n      url.searchParams.append(\"markdown\", \"true\");\n    }\n\n    const body = attachFile || message;\n    try {\n      const user = await userManager.get(baseUrl);\n      const headers = maybeWithAuth({}, user);\n      const progressFn = (ev) => {\n        if (ev.loaded > 0 && ev.total > 0) {\n          setStatus(\n            t(\"publish_dialog_progress_uploading_detail\", {\n              loaded: formatBytes(ev.loaded),\n              total: formatBytes(ev.total),\n              percent: Math.round((ev.loaded * 100.0) / ev.total),\n            })\n          );\n        } else {\n          setStatus(t(\"publish_dialog_progress_uploading\"));\n        }\n      };\n      const request = api.publishXHR(url, body, headers, progressFn);\n      setActiveRequest(request);\n      await request;\n      if (!publishAnother) {\n        props.onClose();\n      } else {\n        setStatus(t(\"publish_dialog_message_published\"));\n        setActiveRequest(null);\n      }\n    } catch (e) {\n      setStatus(<Typography sx={{ color: \"error.main\", maxWidth: \"400px\" }}>{e}</Typography>);\n      setActiveRequest(null);\n    }\n  };\n\n  const checkAttachmentLimits = async (file) => {\n    try {\n      const apiAccount = await accountApi.get();\n      const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0;\n      const remainingBytes = apiAccount.stats.attachment_total_size_remaining;\n      const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;\n      const quotaReached = remainingBytes > 0 && file.size > remainingBytes;\n      if (fileSizeLimitReached && quotaReached) {\n        setAttachFileError(\n          t(\"publish_dialog_attachment_limits_file_and_quota_reached\", {\n            fileSizeLimit: formatBytes(fileSizeLimit),\n            remainingBytes: formatBytes(remainingBytes),\n          })\n        );\n      } else if (fileSizeLimitReached) {\n        setAttachFileError(\n          t(\"publish_dialog_attachment_limits_file_reached\", {\n            fileSizeLimit: formatBytes(fileSizeLimit),\n          })\n        );\n      } else if (quotaReached) {\n        setAttachFileError(\n          t(\"publish_dialog_attachment_limits_quota_reached\", {\n            remainingBytes: formatBytes(remainingBytes),\n          })\n        );\n      } else {\n        setAttachFileError(\"\");\n      }\n    } catch (e) {\n      console.log(`[PublishDialog] Retrieving attachment limits failed`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setAttachFileError(\"\"); // Reset error (rely on server-side checking)\n      }\n    }\n  };\n\n  const handleAttachFileClick = () => {\n    attachFileInput.current.click();\n  };\n\n  const updateAttachFile = async (file) => {\n    setAttachFile(file);\n    setFilename(file.name);\n    props.onResetOpenMode();\n    await checkAttachmentLimits(file);\n  };\n\n  useEffect(() => {\n    if (props.attachFile) {\n      updateAttachFile(props.attachFile);\n    }\n  }, [props.attachFile]);\n\n  const handlePaste = (ev) => {\n    const blob = props.getPastedImage(ev);\n    if (blob) {\n      updateAttachFile(blob);\n    }\n  };\n\n  const handleAttachFileChanged = async (ev) => {\n    await updateAttachFile(ev.target.files[0]);\n  };\n\n  const handleAttachFileDrop = async (ev) => {\n    ev.preventDefault();\n    setDropZone(false);\n    await updateAttachFile(ev.dataTransfer.files[0]);\n  };\n\n  const handleAttachFileDragLeave = () => {\n    setDropZone(false);\n    if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {\n      props.onClose(); // Only close dialog if it was not open before dragging file in\n    }\n  };\n\n  const handleEmojiClick = (ev) => {\n    setEmojiPickerAnchorEl(ev.currentTarget);\n  };\n\n  const handleEmojiPick = (emoji) => {\n    setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));\n  };\n\n  const handleEmojiClose = () => {\n    setEmojiPickerAnchorEl(null);\n  };\n\n  const priorities = {\n    1: { label: t(\"publish_dialog_priority_min\"), file: priority1 },\n    2: { label: t(\"publish_dialog_priority_low\"), file: priority2 },\n    3: { label: t(\"publish_dialog_priority_default\"), file: priority3 },\n    4: { label: t(\"publish_dialog_priority_high\"), file: priority4 },\n    5: { label: t(\"publish_dialog_priority_max\"), file: priority5 },\n  };\n\n  return (\n    <>\n      {dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}\n      <Dialog maxWidth=\"md\" open={open} onClose={props.onCancel} fullScreen={fullScreen}>\n        <DialogTitle>\n          {baseUrl && topic\n            ? t(\"publish_dialog_title_topic\", {\n                topic: topicShortUrl(baseUrl, topic),\n              })\n            : t(\"publish_dialog_title_no_topic\")}\n        </DialogTitle>\n        <DialogContent>\n          {dropZone && <DropBox />}\n          {showTopicUrl && (\n            <ClosableRow\n              closable={!!props.baseUrl && !!props.topic}\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_topic_reset\")}\n              onClose={() => {\n                setBaseUrl(props.baseUrl);\n                setTopic(props.topic);\n                setShowTopicUrl(false);\n              }}\n            >\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_base_url_label\")}\n                placeholder={t(\"publish_dialog_base_url_placeholder\")}\n                value={baseUrl}\n                onChange={(ev) => updateBaseUrl(ev.target.value)}\n                disabled={disabled}\n                type=\"url\"\n                variant=\"standard\"\n                sx={{ flexGrow: 1, marginRight: 1 }}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_base_url_label\"),\n                }}\n              />\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_topic_label\")}\n                placeholder={t(\"publish_dialog_topic_placeholder\")}\n                value={topic}\n                onChange={(ev) => setTopic(ev.target.value)}\n                disabled={disabled}\n                type=\"text\"\n                variant=\"standard\"\n                autoFocus={!messageFocused}\n                sx={{ flexGrow: 1 }}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_topic_label\"),\n                }}\n              />\n            </ClosableRow>\n          )}\n          <TextField\n            margin=\"dense\"\n            label={t(\"publish_dialog_title_label\")}\n            placeholder={t(\"publish_dialog_title_placeholder\")}\n            value={title}\n            onChange={(ev) => setTitle(ev.target.value)}\n            disabled={disabled}\n            type=\"text\"\n            fullWidth\n            variant=\"standard\"\n            inputProps={{\n              \"aria-label\": t(\"publish_dialog_title_label\"),\n            }}\n          />\n          <TextField\n            margin=\"dense\"\n            label={t(\"publish_dialog_message_label\")}\n            placeholder={t(\"publish_dialog_message_placeholder\")}\n            value={message}\n            onChange={(ev) => setMessage(ev.target.value)}\n            disabled={disabled}\n            type=\"text\"\n            variant=\"standard\"\n            rows={5}\n            autoFocus={messageFocused}\n            fullWidth\n            multiline\n            inputProps={{\n              \"aria-label\": t(\"publish_dialog_message_label\"),\n            }}\n            onPaste={handlePaste}\n          />\n          <FormControlLabel\n            label={t(\"publish_dialog_checkbox_markdown\")}\n            sx={{ marginRight: 2 }}\n            control={\n              <Checkbox\n                size=\"small\"\n                checked={markdownEnabled}\n                onChange={(ev) => setMarkdownEnabled(ev.target.checked)}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_checkbox_markdown\"),\n                }}\n              />\n            }\n          />\n          <div style={{ display: \"flex\" }}>\n            <EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />\n            <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t(\"publish_dialog_emoji_picker_show\")}>\n              <InsertEmoticonIcon />\n            </DialogIconButton>\n            <TextField\n              margin=\"dense\"\n              label={t(\"publish_dialog_tags_label\")}\n              placeholder={t(\"publish_dialog_tags_placeholder\")}\n              value={tags}\n              onChange={(ev) => setTags(ev.target.value)}\n              disabled={disabled}\n              type=\"text\"\n              variant=\"standard\"\n              sx={{ flexGrow: 1, marginRight: 1 }}\n              inputProps={{\n                \"aria-label\": t(\"publish_dialog_tags_label\"),\n              }}\n            />\n            <FormControl variant=\"standard\" margin=\"dense\" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>\n              <InputLabel />\n              <Select\n                label={t(\"publish_dialog_priority_label\")}\n                margin=\"dense\"\n                value={priority}\n                onChange={(ev) => setPriority(ev.target.value)}\n                disabled={disabled}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_priority_label\"),\n                }}\n              >\n                {[5, 4, 3, 2, 1].map((p) => (\n                  <MenuItem\n                    key={`priorityMenuItem${p}`}\n                    value={p}\n                    aria-label={t(\"notifications_priority_x\", {\n                      priority: p,\n                    })}\n                  >\n                    <div style={{ display: \"flex\", alignItems: \"center\" }}>\n                      <img\n                        src={priorities[p].file}\n                        style={{ marginRight: \"8px\" }}\n                        alt={t(\"notifications_priority_x\", {\n                          priority: p,\n                        })}\n                      />\n                      <div>{priorities[p].label}</div>\n                    </div>\n                  </MenuItem>\n                ))}\n              </Select>\n            </FormControl>\n          </div>\n          {showClickUrl && (\n            <ClosableRow\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_click_reset\")}\n              onClose={() => {\n                setClickUrl(\"\");\n                setShowClickUrl(false);\n              }}\n            >\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_click_label\")}\n                placeholder={t(\"publish_dialog_click_placeholder\")}\n                value={clickUrl}\n                onChange={(ev) => setClickUrl(ev.target.value)}\n                disabled={disabled}\n                type=\"url\"\n                fullWidth\n                variant=\"standard\"\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_click_label\"),\n                }}\n              />\n            </ClosableRow>\n          )}\n          {showEmail && (\n            <ClosableRow\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_email_reset\")}\n              onClose={() => {\n                setEmail(\"\");\n                setShowEmail(false);\n              }}\n            >\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_email_label\")}\n                placeholder={t(\"publish_dialog_email_placeholder\")}\n                value={email}\n                onChange={(ev) => setEmail(ev.target.value)}\n                disabled={disabled}\n                type=\"email\"\n                variant=\"standard\"\n                fullWidth\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_email_label\"),\n                }}\n              />\n            </ClosableRow>\n          )}\n          {showCall && (\n            <ClosableRow\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_call_reset\")}\n              onClose={() => {\n                setCall(\"\");\n                setShowCall(false);\n              }}\n            >\n              <FormControl fullWidth variant=\"standard\" margin=\"dense\">\n                <InputLabel />\n                <Select\n                  label={t(\"publish_dialog_call_label\")}\n                  margin=\"dense\"\n                  value={call}\n                  onChange={(ev) => setCall(ev.target.value)}\n                  disabled={disabled}\n                  inputProps={{\n                    \"aria-label\": t(\"publish_dialog_call_label\"),\n                  }}\n                >\n                  {account?.phone_numbers?.map((phoneNumber) => (\n                    <MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>\n                      {t(\"publish_dialog_call_item\", { number: phoneNumber })}\n                    </MenuItem>\n                  ))}\n                </Select>\n              </FormControl>\n            </ClosableRow>\n          )}\n          {showAttachUrl && (\n            <ClosableRow\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_attach_reset\")}\n              onClose={() => {\n                setAttachUrl(\"\");\n                setFilename(\"\");\n                setFilenameEdited(false);\n                setShowAttachUrl(false);\n              }}\n            >\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_attach_label\")}\n                placeholder={t(\"publish_dialog_attach_placeholder\")}\n                value={attachUrl}\n                onChange={(ev) => {\n                  const url = ev.target.value;\n                  setAttachUrl(url);\n                  if (!filenameEdited) {\n                    try {\n                      const u = new URL(url);\n                      const parts = u.pathname.split(\"/\");\n                      if (parts.length > 0) {\n                        setFilename(parts[parts.length - 1]);\n                      }\n                    } catch (e) {\n                      // Do nothing\n                    }\n                  }\n                }}\n                disabled={disabled}\n                type=\"url\"\n                variant=\"standard\"\n                sx={{ flexGrow: 5, marginRight: 1 }}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_attach_label\"),\n                }}\n              />\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_filename_label\")}\n                placeholder={t(\"publish_dialog_filename_placeholder\")}\n                value={filename}\n                onChange={(ev) => {\n                  setFilename(ev.target.value);\n                  setFilenameEdited(true);\n                }}\n                disabled={disabled}\n                type=\"text\"\n                variant=\"standard\"\n                sx={{ flexGrow: 1 }}\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_filename_label\"),\n                }}\n              />\n            </ClosableRow>\n          )}\n          <input type=\"file\" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: \"none\" }} aria-hidden />\n          {showAttachFile && (\n            <AttachmentBox\n              file={attachFile}\n              filename={filename}\n              disabled={disabled}\n              error={attachFileError}\n              onChangeFilename={(f) => setFilename(f)}\n              onClose={() => {\n                setAttachFile(null);\n                setAttachFileError(\"\");\n                setFilename(\"\");\n              }}\n            />\n          )}\n          {showDelay && (\n            <ClosableRow\n              disabled={disabled}\n              closeLabel={t(\"publish_dialog_delay_reset\")}\n              onClose={() => {\n                setDelay(\"\");\n                setShowDelay(false);\n              }}\n            >\n              <TextField\n                margin=\"dense\"\n                label={t(\"publish_dialog_delay_label\")}\n                placeholder={t(\"publish_dialog_delay_placeholder\", {\n                  unixTimestamp: \"1649029748\",\n                  relativeTime: \"30m\",\n                  naturalLanguage: \"tomorrow, 9am\",\n                })}\n                value={delay}\n                onChange={(ev) => setDelay(ev.target.value)}\n                disabled={disabled}\n                type=\"text\"\n                variant=\"standard\"\n                fullWidth\n                inputProps={{\n                  \"aria-label\": t(\"publish_dialog_delay_label\"),\n                }}\n              />\n            </ClosableRow>\n          )}\n          <Typography variant=\"body1\" sx={{ marginTop: 2, marginBottom: 1 }}>\n            {t(\"publish_dialog_other_features\")}\n          </Typography>\n          <div>\n            {!showClickUrl && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_click_label\")}\n                aria-label={t(\"publish_dialog_chip_click_label\")}\n                onClick={() => setShowClickUrl(true)}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {!showEmail && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_email_label\")}\n                aria-label={t(\"publish_dialog_chip_email_label\")}\n                onClick={() => setShowEmail(true)}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {account?.phone_numbers?.length > 0 && !showCall && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_call_label\")}\n                aria-label={t(\"publish_dialog_chip_call_label\")}\n                onClick={() => {\n                  setShowCall(true);\n                  setCall(account.phone_numbers[0]);\n                }}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {!showAttachUrl && !showAttachFile && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_attach_url_label\")}\n                aria-label={t(\"publish_dialog_chip_attach_url_label\")}\n                onClick={() => setShowAttachUrl(true)}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {!showAttachFile && !showAttachUrl && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_attach_file_label\")}\n                aria-label={t(\"publish_dialog_chip_attach_file_label\")}\n                onClick={() => handleAttachFileClick()}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {!showDelay && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_delay_label\")}\n                aria-label={t(\"publish_dialog_chip_delay_label\")}\n                onClick={() => setShowDelay(true)}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {!showTopicUrl && (\n              <Chip\n                clickable\n                disabled={disabled}\n                label={t(\"publish_dialog_chip_topic_label\")}\n                aria-label={t(\"publish_dialog_chip_topic_label\")}\n                onClick={() => setShowTopicUrl(true)}\n                sx={{ marginRight: 1, marginBottom: 1 }}\n              />\n            )}\n            {account && !account?.phone_numbers && (\n              <Tooltip title={t(\"publish_dialog_chip_call_no_verified_numbers_tooltip\")}>\n                <span>\n                  <Chip\n                    clickable\n                    disabled\n                    label={t(\"publish_dialog_chip_call_label\")}\n                    aria-label={t(\"publish_dialog_chip_call_label\")}\n                    sx={{ marginRight: 1, marginBottom: 1 }}\n                  />\n                </span>\n              </Tooltip>\n            )}\n          </div>\n          <Typography variant=\"body1\" sx={{ marginTop: 1, marginBottom: 1 }}>\n            <Trans\n              i18nKey=\"publish_dialog_details_examples_description\"\n              components={{\n                docsLink: <Link href=\"https://ntfy.sh/docs\" target=\"_blank\" rel=\"noopener\" />,\n              }}\n            />\n          </Typography>\n        </DialogContent>\n        <DialogFooter status={status}>\n          {activeRequest && <Button onClick={() => activeRequest.abort()}>{t(\"publish_dialog_button_cancel_sending\")}</Button>}\n          {!activeRequest && (\n            <>\n              <FormControlLabel\n                label={t(\"publish_dialog_checkbox_publish_another\")}\n                sx={{ marginRight: 2 }}\n                control={\n                  <Checkbox\n                    size=\"small\"\n                    checked={publishAnother}\n                    onChange={(ev) => setPublishAnother(ev.target.checked)}\n                    inputProps={{\n                      \"aria-label\": t(\"publish_dialog_checkbox_publish_another\"),\n                    }}\n                  />\n                }\n              />\n              <Button onClick={props.onClose}>{t(\"publish_dialog_button_cancel\")}</Button>\n              <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>\n                {t(\"publish_dialog_button_send\")}\n              </Button>\n            </>\n          )}\n        </DialogFooter>\n      </Dialog>\n    </>\n  );\n};\n\nconst Row = (props) => (\n  <div style={{ display: \"flex\" }} role=\"row\">\n    {props.children}\n  </div>\n);\n\nconst ClosableRow = (props) => {\n  const closable = props.closable !== undefined ? props.closable : true;\n  return (\n    <Row>\n      {props.children}\n      {closable && (\n        <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: \"6px\" }} aria-label={props.closeLabel}>\n          <Close />\n        </DialogIconButton>\n      )}\n    </Row>\n  );\n};\n\nconst DialogIconButton = (props) => {\n  const sx = props.sx || {};\n  return (\n    <IconButton\n      color=\"inherit\"\n      size=\"large\"\n      edge=\"start\"\n      sx={{ height: \"45px\", marginTop: \"17px\", ...sx }}\n      onClick={props.onClick}\n      disabled={props.disabled}\n      aria-label={props[\"aria-label\"]}\n    >\n      {props.children}\n    </IconButton>\n  );\n};\n\nconst AttachmentBox = (props) => {\n  const { t } = useTranslation();\n  const { file } = props;\n  return (\n    <>\n      <Typography variant=\"body1\" sx={{ marginTop: 2 }}>\n        {t(\"publish_dialog_attached_file_title\")}\n      </Typography>\n      <Box\n        sx={{\n          display: \"flex\",\n          alignItems: \"center\",\n          padding: 0.5,\n          borderRadius: \"4px\",\n        }}\n      >\n        <AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />\n        <Box sx={{ marginLeft: 1, textAlign: \"left\" }}>\n          <ExpandingTextField\n            minWidth={140}\n            variant=\"body2\"\n            placeholder={t(\"publish_dialog_attached_file_filename_placeholder\")}\n            value={props.filename}\n            onChange={(ev) => props.onChangeFilename(ev.target.value)}\n            disabled={props.disabled}\n          />\n          <br />\n          <Typography variant=\"body2\" sx={{ color: \"text.primary\" }}>\n            {formatBytes(file.size)}\n            {props.error && (\n              <Typography component=\"span\" sx={{ color: \"error.main\" }} aria-live=\"polite\">\n                {\" \"}\n                ({props.error})\n              </Typography>\n            )}\n          </Typography>\n        </Box>\n        <DialogIconButton\n          disabled={props.disabled}\n          onClick={props.onClose}\n          sx={{ marginLeft: \"6px\" }}\n          aria-label={t(\"publish_dialog_attached_file_remove\")}\n        >\n          <Close />\n        </DialogIconButton>\n      </Box>\n    </>\n  );\n};\n\nconst ExpandingTextField = (props) => {\n  const theme = useTheme();\n  const invisibleFieldRef = useRef();\n  const [textWidth, setTextWidth] = useState(props.minWidth);\n  const determineTextWidth = () => {\n    const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();\n    if (!boundingRect) {\n      return props.minWidth;\n    }\n    return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth;\n  };\n  useEffect(() => {\n    setTextWidth(determineTextWidth() + 5);\n  }, [props.value]);\n  return (\n    <>\n      <Typography ref={invisibleFieldRef} component=\"span\" variant={props.variant} aria-hidden sx={{ position: \"absolute\", left: \"-200%\" }}>\n        {props.value}\n      </Typography>\n      <TextField\n        margin=\"dense\"\n        placeholder={props.placeholder}\n        value={props.value}\n        onChange={props.onChange}\n        type=\"text\"\n        variant=\"standard\"\n        sx={{ width: `${textWidth}px`, borderBottom: \"none\" }}\n        InputProps={{\n          style: { fontSize: theme.typography[props.variant].fontSize },\n        }}\n        inputProps={{\n          style: { paddingBottom: 0, paddingTop: 0 },\n          \"aria-label\": props.placeholder,\n        }}\n        disabled={props.disabled}\n      />\n    </>\n  );\n};\n\nconst DropArea = (props) => {\n  const allowDrag = (ev) => {\n    // This is where we could disallow certain files to be dragged in.\n    // For now we allow all files.\n\n    // eslint-disable-next-line no-param-reassign\n    ev.dataTransfer.dropEffect = \"copy\";\n    ev.preventDefault();\n  };\n\n  return (\n    <Box\n      sx={{\n        position: \"absolute\",\n        left: 0,\n        top: 0,\n        right: 0,\n        bottom: 0,\n        zIndex: 10002,\n      }}\n      onDrop={props.onDrop}\n      onDragEnter={allowDrag}\n      onDragOver={allowDrag}\n      onDragLeave={props.onDragLeave}\n    />\n  );\n};\n\nconst DropBox = () => {\n  const { t } = useTranslation();\n  return (\n    <Box\n      sx={{\n        position: \"absolute\",\n        left: 0,\n        top: 0,\n        right: 0,\n        bottom: 0,\n        zIndex: 10000,\n        backgroundColor: \"#ffffffbb\",\n      }}\n    >\n      <Box\n        sx={{\n          position: \"absolute\",\n          border: \"3px dashed #ccc\",\n          borderRadius: \"5px\",\n          left: \"40px\",\n          top: \"40px\",\n          right: \"40px\",\n          bottom: \"40px\",\n          zIndex: 10001,\n          display: \"flex\",\n          justifyContent: \"center\",\n          alignItems: \"center\",\n        }}\n      >\n        <Typography variant=\"h5\">{t(\"publish_dialog_drop_file_here\")}</Typography>\n      </Box>\n    </Box>\n  );\n};\n\nPublishDialog.OPEN_MODE_DEFAULT = \"default\";\nPublishDialog.OPEN_MODE_DRAG = \"drag\";\n\nexport default PublishDialog;\n"
  },
  {
    "path": "web/src/components/RTLCacheProvider.jsx",
    "content": "import React from \"react\";\n\nimport rtlPlugin from \"stylis-plugin-rtl\";\nimport { CacheProvider } from \"@emotion/react\";\nimport createCache from \"@emotion/cache\";\nimport { prefixer } from \"stylis\";\nimport { useTranslation } from \"react-i18next\";\n\n// https://mui.com/material-ui/guides/right-to-left\n\nconst cacheRtl = createCache({\n  key: \"muirtl\",\n  stylisPlugins: [prefixer, rtlPlugin],\n});\n\nconst RTLCacheProvider = ({ children }) => {\n  const { i18n } = useTranslation();\n\n  return i18n.dir() === \"rtl\" ? <CacheProvider value={cacheRtl}>{children}</CacheProvider> : children;\n};\n\nexport default RTLCacheProvider;\n"
  },
  {
    "path": "web/src/components/ReserveDialogs.jsx",
    "content": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport {\n  Button,\n  TextField,\n  Dialog,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Alert,\n  FormControl,\n  Select,\n  useMediaQuery,\n  MenuItem,\n  ListItemIcon,\n  ListItemText,\n  useTheme,\n} from \"@mui/material\";\nimport { useTranslation } from \"react-i18next\";\nimport { Check, DeleteForever } from \"@mui/icons-material\";\nimport { validTopic } from \"../app/utils\";\nimport DialogFooter from \"./DialogFooter\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport accountApi, { Permission } from \"../app/AccountApi\";\nimport ReserveTopicSelect from \"./ReserveTopicSelect\";\nimport { TopicReservedError, UnauthorizedError } from \"../app/errors\";\n\nexport const ReserveAddDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [topic, setTopic] = useState(props.topic || \"\");\n  const [everyone, setEveryone] = useState(Permission.DENY_ALL);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n  const allowTopicEdit = !props.topic;\n  const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0;\n  const submitButtonEnabled = validTopic(topic) && !alreadyReserved;\n\n  const handleSubmit = async () => {\n    try {\n      await accountApi.upsertReservation(topic, everyone);\n      console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);\n    } catch (e) {\n      console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else if (e instanceof TopicReservedError) {\n        setError(t(\"subscribe_dialog_error_topic_already_reserved\"));\n        return;\n      } else {\n        setError(e.message);\n        return;\n      }\n    }\n    props.onClose();\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} maxWidth=\"sm\" fullWidth fullScreen={fullScreen}>\n      <DialogTitle>{t(\"prefs_reservations_dialog_title_add\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"prefs_reservations_dialog_description\")}</DialogContentText>\n        {allowTopicEdit && (\n          <TextField\n            autoFocus\n            margin=\"dense\"\n            id=\"topic\"\n            label={t(\"prefs_reservations_dialog_topic_label\")}\n            aria-label={t(\"prefs_reservations_dialog_topic_label\")}\n            value={topic}\n            onChange={(ev) => setTopic(ev.target.value)}\n            type=\"url\"\n            fullWidth\n            variant=\"standard\"\n          />\n        )}\n        <ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSubmit} disabled={!submitButtonEnabled}>\n          {t(\"common_add\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nexport const ReserveEditDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleSubmit = async () => {\n    try {\n      await accountApi.upsertReservation(props.reservation.topic, everyone);\n      console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);\n    } catch (e) {\n      console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n        return;\n      }\n    }\n    props.onClose();\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} maxWidth=\"sm\" fullWidth fullScreen={fullScreen}>\n      <DialogTitle>{t(\"prefs_reservations_dialog_title_edit\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"prefs_reservations_dialog_description\")}</DialogContentText>\n        <ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSubmit}>{t(\"common_save\")}</Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nexport const ReserveDeleteDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [deleteMessages, setDeleteMessages] = useState(false);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleSubmit = async () => {\n    try {\n      await accountApi.deleteReservation(props.topic, deleteMessages);\n      console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);\n    } catch (e) {\n      console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n        return;\n      }\n    }\n    props.onClose();\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} maxWidth=\"sm\" fullWidth fullScreen={fullScreen}>\n      <DialogTitle>{t(\"prefs_reservations_dialog_title_delete\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"reservation_delete_dialog_description\")}</DialogContentText>\n        <FormControl fullWidth variant=\"standard\">\n          <Select\n            value={deleteMessages}\n            onChange={(ev) => setDeleteMessages(ev.target.value)}\n            sx={{\n              \"& .MuiSelect-select\": {\n                display: \"flex\",\n                alignItems: \"center\",\n                paddingTop: \"4px\",\n                paddingBottom: \"4px\",\n              },\n            }}\n          >\n            <MenuItem value={false}>\n              <ListItemIcon>\n                <Check />\n              </ListItemIcon>\n              <ListItemText primary={t(\"reservation_delete_dialog_action_keep_title\")} />\n            </MenuItem>\n            <MenuItem value>\n              <ListItemIcon>\n                <DeleteForever />\n              </ListItemIcon>\n              <ListItemText primary={t(\"reservation_delete_dialog_action_delete_title\")} />\n            </MenuItem>\n          </Select>\n        </FormControl>\n        {!deleteMessages && (\n          <Alert severity=\"info\" sx={{ mt: 1 }}>\n            {t(\"reservation_delete_dialog_action_keep_description\")}\n          </Alert>\n        )}\n        {deleteMessages && (\n          <Alert severity=\"warning\" sx={{ mt: 1 }}>\n            {t(\"reservation_delete_dialog_action_delete_description\")}\n          </Alert>\n        )}\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSubmit} color=\"error\">\n          {t(\"reservation_delete_dialog_submit_button\")}\n        </Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "web/src/components/ReserveIcons.jsx",
    "content": "import * as React from \"react\";\nimport { Lock, Public } from \"@mui/icons-material\";\nimport { Box } from \"@mui/material\";\n\nexport const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);\n\nexport const PermissionDenyAll = React.forwardRef((props, ref) => <PermissionInternal icon={Lock} ref={ref} {...props} />);\n\nexport const PermissionRead = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text=\"R\" ref={ref} {...props} />);\n\nexport const PermissionWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text=\"W\" ref={ref} {...props} />);\n\nconst PermissionInternal = React.forwardRef((props, ref) => {\n  const size = props.size ?? \"medium\";\n  const Icon = props.icon;\n  return (\n    <Box\n      ref={ref}\n      {...props}\n      style={{\n        position: \"relative\",\n        display: \"inline-flex\",\n        verticalAlign: \"middle\",\n        height: \"24px\",\n      }}\n    >\n      <Icon fontSize={size} sx={{ color: \"gray\" }} />\n      {props.text && (\n        <Box\n          sx={{\n            position: \"absolute\",\n            right: \"-6px\",\n            bottom: \"5px\",\n            fontSize: 10,\n            fontWeight: 600,\n            color: \"gray\",\n            width: \"8px\",\n            height: \"8px\",\n            marginTop: \"3px\",\n          }}\n        >\n          {props.text}\n        </Box>\n      )}\n    </Box>\n  );\n});\n"
  },
  {
    "path": "web/src/components/ReserveTopicSelect.jsx",
    "content": "import * as React from \"react\";\nimport { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from \"@mui/material\";\nimport { useTranslation } from \"react-i18next\";\nimport { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from \"./ReserveIcons\";\nimport { Permission } from \"../app/AccountApi\";\n\nconst ReserveTopicSelect = (props) => {\n  const { t } = useTranslation();\n  const sx = props.sx || {};\n  return (\n    <FormControl fullWidth variant=\"standard\" sx={sx}>\n      <Select\n        value={props.value}\n        onChange={(ev) => props.onChange(ev.target.value)}\n        aria-label={t(\"prefs_reservations_dialog_access_label\")}\n        sx={{\n          \"& .MuiSelect-select\": {\n            display: \"flex\",\n            alignItems: \"center\",\n            paddingTop: \"4px\",\n            paddingBottom: \"4px\",\n          },\n        }}\n      >\n        <MenuItem value={Permission.DENY_ALL}>\n          <ListItemIcon>\n            <PermissionDenyAll />\n          </ListItemIcon>\n          <ListItemText primary={t(\"prefs_reservations_table_everyone_deny_all\")} />\n        </MenuItem>\n        <MenuItem value={Permission.READ_ONLY}>\n          <ListItemIcon>\n            <PermissionRead />\n          </ListItemIcon>\n          <ListItemText primary={t(\"prefs_reservations_table_everyone_read_only\")} />\n        </MenuItem>\n        <MenuItem value={Permission.WRITE_ONLY}>\n          <ListItemIcon>\n            <PermissionWrite />\n          </ListItemIcon>\n          <ListItemText primary={t(\"prefs_reservations_table_everyone_write_only\")} />\n        </MenuItem>\n        <MenuItem value={Permission.READ_WRITE}>\n          <ListItemIcon>\n            <PermissionReadWrite />\n          </ListItemIcon>\n          <ListItemText primary={t(\"prefs_reservations_table_everyone_read_write\")} />\n        </MenuItem>\n      </Select>\n    </FormControl>\n  );\n};\n\nexport default ReserveTopicSelect;\n"
  },
  {
    "path": "web/src/components/Signup.jsx",
    "content": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { TextField, Button, Box, Typography, InputAdornment, IconButton } from \"@mui/material\";\nimport { NavLink } from \"react-router-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport WarningAmberIcon from \"@mui/icons-material/WarningAmber\";\nimport { Visibility, VisibilityOff } from \"@mui/icons-material\";\nimport accountApi from \"../app/AccountApi\";\nimport AvatarBox from \"./AvatarBox\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport { AccountCreateLimitReachedError, UserExistsError } from \"../app/errors\";\n\nconst Signup = () => {\n  const { t } = useTranslation();\n  const [error, setError] = useState(\"\");\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [confirm, setConfirm] = useState(\"\");\n  const [showPassword, setShowPassword] = useState(false);\n  const [showConfirm, setShowConfirm] = useState(false);\n\n  const handleSubmit = async (event) => {\n    event.preventDefault();\n    const user = { username, password };\n    try {\n      await accountApi.create(user.username, user.password);\n      const token = await accountApi.login(user);\n      console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);\n      await session.store(user.username, token);\n      window.location.href = routes.app;\n    } catch (e) {\n      console.log(`[Signup] Signup for user ${user.username} failed`, e);\n      if (e instanceof UserExistsError) {\n        setError(t(\"signup_error_username_taken\", { username: e.username }));\n      } else if (e instanceof AccountCreateLimitReachedError) {\n        setError(t(\"signup_error_creation_limit_reached\"));\n      } else {\n        setError(e.message);\n      }\n    }\n  };\n\n  if (!config.enable_signup) {\n    return (\n      <AvatarBox>\n        <Typography sx={{ typography: \"h6\" }}>{t(\"signup_disabled\")}</Typography>\n      </AvatarBox>\n    );\n  }\n\n  return (\n    <AvatarBox>\n      <Typography sx={{ typography: \"h6\" }}>{t(\"signup_title\")}</Typography>\n      <Box component=\"form\" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>\n        <TextField\n          margin=\"dense\"\n          required\n          fullWidth\n          id=\"username\"\n          label={t(\"signup_form_username\")}\n          name=\"username\"\n          value={username}\n          onChange={(ev) => setUsername(ev.target.value.trim())}\n          autoFocus\n        />\n        <TextField\n          margin=\"dense\"\n          required\n          fullWidth\n          name=\"password\"\n          label={t(\"signup_form_password\")}\n          type={showPassword ? \"text\" : \"password\"}\n          id=\"password\"\n          autoComplete=\"new-password\"\n          value={password}\n          onChange={(ev) => setPassword(ev.target.value.trim())}\n          InputProps={{\n            endAdornment: (\n              <InputAdornment position=\"end\">\n                <IconButton\n                  aria-label={t(\"signup_form_toggle_password_visibility\")}\n                  onClick={() => setShowPassword(!showPassword)}\n                  onMouseDown={(ev) => ev.preventDefault()}\n                  edge=\"end\"\n                >\n                  {showPassword ? <VisibilityOff /> : <Visibility />}\n                </IconButton>\n              </InputAdornment>\n            ),\n          }}\n        />\n        <TextField\n          margin=\"dense\"\n          required\n          fullWidth\n          name=\"password\"\n          label={t(\"signup_form_confirm_password\")}\n          type={showConfirm ? \"text\" : \"password\"}\n          id=\"confirm\"\n          autoComplete=\"new-password\"\n          value={confirm}\n          onChange={(ev) => setConfirm(ev.target.value.trim())}\n          InputProps={{\n            endAdornment: (\n              <InputAdornment position=\"end\">\n                <IconButton\n                  aria-label={t(\"signup_form_toggle_password_visibility\")}\n                  onClick={() => setShowConfirm(!showConfirm)}\n                  onMouseDown={(ev) => ev.preventDefault()}\n                  edge=\"end\"\n                >\n                  {showConfirm ? <VisibilityOff /> : <Visibility />}\n                </IconButton>\n              </InputAdornment>\n            ),\n          }}\n        />\n        <Button\n          type=\"submit\"\n          fullWidth\n          variant=\"contained\"\n          disabled={username === \"\" || password === \"\" || password !== confirm}\n          sx={{ mt: 2, mb: 2 }}\n        >\n          {t(\"signup_form_button_submit\")}\n        </Button>\n        {error && (\n          <Box\n            sx={{\n              mb: 1,\n              display: \"flex\",\n              flexGrow: 1,\n              justifyContent: \"center\",\n            }}\n          >\n            <WarningAmberIcon color=\"error\" sx={{ mr: 1 }} />\n            <Typography sx={{ color: \"error.main\" }}>{error}</Typography>\n          </Box>\n        )}\n      </Box>\n      {config.enable_login && (\n        <Typography sx={{ mb: 4 }}>\n          <NavLink to={routes.login} variant=\"body1\">\n            {t(\"signup_already_have_account\")}\n          </NavLink>\n        </Typography>\n      )}\n    </AvatarBox>\n  );\n};\n\nexport default Signup;\n"
  },
  {
    "path": "web/src/components/SubscribeDialog.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useState } from \"react\";\nimport {\n  Button,\n  TextField,\n  Dialog,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Autocomplete,\n  FormControlLabel,\n  FormGroup,\n  useMediaQuery,\n  Switch,\n  useTheme,\n} from \"@mui/material\";\nimport { useTranslation } from \"react-i18next\";\nimport { useLiveQuery } from \"dexie-react-hooks\";\nimport api from \"../app/Api\";\nimport { randomAlphanumericString, topicUrl, validTopic, validUrl } from \"../app/utils\";\nimport userManager from \"../app/UserManager\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport poller from \"../app/Poller\";\nimport DialogFooter from \"./DialogFooter\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport accountApi, { Permission, Role } from \"../app/AccountApi\";\nimport ReserveTopicSelect from \"./ReserveTopicSelect\";\nimport { AccountContext } from \"./App\";\nimport { TopicReservedError, UnauthorizedError } from \"../app/errors\";\nimport { ReserveLimitChip } from \"./SubscriptionPopup\";\nimport prefs from \"../app/Prefs\";\n\nconst publicBaseUrl = \"https://ntfy.sh\";\n\nexport const subscribeTopic = async (baseUrl, topic, opts) => {\n  const subscription = await subscriptionManager.add(baseUrl, topic, opts);\n  if (session.exists()) {\n    try {\n      await accountApi.addSubscription(baseUrl, topic);\n    } catch (e) {\n      console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      }\n    }\n  }\n  return subscription;\n};\n\nconst SubscribeDialog = (props) => {\n  const theme = useTheme();\n  const [baseUrl, setBaseUrl] = useState(\"\");\n  const [topic, setTopic] = useState(\"\");\n  const [showLoginPage, setShowLoginPage] = useState(false);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleSuccess = async () => {\n    console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);\n    const actualBaseUrl = baseUrl || config.base_url;\n    const subscription = await subscribeTopic(actualBaseUrl, topic, {});\n    poller.pollInBackground(subscription); // Dangle!\n    props.onSuccess(subscription);\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>\n      {!showLoginPage && (\n        <SubscribePage\n          baseUrl={baseUrl}\n          setBaseUrl={setBaseUrl}\n          topic={topic}\n          setTopic={setTopic}\n          subscriptions={props.subscriptions}\n          onCancel={props.onCancel}\n          onNeedsLogin={() => setShowLoginPage(true)}\n          onSuccess={handleSuccess}\n        />\n      )}\n      {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}\n    </Dialog>\n  );\n};\n\nconst SubscribePage = (props) => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const [error, setError] = useState(\"\");\n  const [reserveTopicVisible, setReserveTopicVisible] = useState(false);\n  const [anotherServerVisible, setAnotherServerVisible] = useState(false);\n  const [everyone, setEveryone] = useState(Permission.DENY_ALL);\n  const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;\n  const { topic } = props;\n  const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));\n  const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(\n    (s) => s !== config.base_url\n  );\n  const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);\n  const reserveTopicEnabled =\n    session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));\n\n  const webPushEnabled = useLiveQuery(() => prefs.webPushEnabled());\n\n  const handleSubscribe = async () => {\n    const user = await userManager.get(baseUrl); // May be undefined\n    const username = user ? user.username : t(\"subscribe_dialog_error_user_anonymous\");\n\n    // Check read access to topic\n    const success = await api.topicAuth(baseUrl, topic, user);\n    if (!success) {\n      console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);\n      if (user) {\n        setError(\n          t(\"subscribe_dialog_error_user_not_authorized\", {\n            username,\n          })\n        );\n        return;\n      }\n      props.onNeedsLogin();\n      return;\n    }\n\n    // Reserve topic (if requested)\n    if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {\n      console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);\n      try {\n        await accountApi.upsertReservation(topic, everyone);\n      } catch (e) {\n        console.log(`[SubscribeDialog] Error reserving topic`, e);\n        if (e instanceof UnauthorizedError) {\n          await session.resetAndRedirect(routes.login);\n        } else if (e instanceof TopicReservedError) {\n          setError(t(\"subscribe_dialog_error_topic_already_reserved\"));\n          return;\n        }\n      }\n    }\n\n    console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);\n    props.onSuccess();\n  };\n\n  const handleUseAnotherChanged = (e) => {\n    props.setBaseUrl(\"\");\n    setAnotherServerVisible(e.target.checked);\n  };\n\n  const subscribeButtonEnabled = (() => {\n    if (anotherServerVisible) {\n      const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));\n      return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;\n    }\n    const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));\n    return validTopic(topic) && !isExistingTopicUrl;\n  })();\n\n  const updateBaseUrl = (ev, newVal) => {\n    if (validUrl(newVal)) {\n      props.setBaseUrl(newVal.replace(/\\/$/, \"\")); // strip trailing slash after https?://\n    } else {\n      props.setBaseUrl(newVal);\n    }\n  };\n\n  return (\n    <>\n      <DialogTitle>{t(\"subscribe_dialog_subscribe_title\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"subscribe_dialog_subscribe_description\")}</DialogContentText>\n        <div style={{ display: \"flex\", paddingBottom: \"8px\" }} role=\"row\">\n          <TextField\n            autoFocus\n            margin=\"dense\"\n            id=\"topic\"\n            placeholder={t(\"subscribe_dialog_subscribe_topic_placeholder\")}\n            value={props.topic}\n            onChange={(ev) => props.setTopic(ev.target.value)}\n            type=\"text\"\n            fullWidth\n            variant=\"standard\"\n            inputProps={{\n              maxLength: 64,\n              \"aria-label\": t(\"subscribe_dialog_subscribe_topic_placeholder\"),\n            }}\n          />\n          <Button\n            onClick={() => {\n              props.setTopic(randomAlphanumericString(16));\n            }}\n            style={{ flexShrink: \"0\", marginTop: \"0.5em\" }}\n          >\n            {t(\"subscribe_dialog_subscribe_button_generate_topic_name\")}\n          </Button>\n        </div>\n        {showReserveTopicCheckbox && (\n          <FormGroup>\n            <FormControlLabel\n              variant=\"standard\"\n              control={\n                <Switch\n                  disabled={!reserveTopicEnabled}\n                  checked={reserveTopicVisible}\n                  onChange={(ev) => setReserveTopicVisible(ev.target.checked)}\n                  inputProps={{\n                    \"aria-label\": t(\"reserve_dialog_checkbox_label\"),\n                  }}\n                />\n              }\n              label={\n                <>\n                  {t(\"reserve_dialog_checkbox_label\")}\n                  <ReserveLimitChip />\n                </>\n              }\n            />\n            {reserveTopicVisible && <ReserveTopicSelect value={everyone} onChange={setEveryone} />}\n          </FormGroup>\n        )}\n        {!reserveTopicVisible && (\n          <FormGroup>\n            <FormControlLabel\n              control={\n                <Switch\n                  onChange={handleUseAnotherChanged}\n                  checked={anotherServerVisible}\n                  inputProps={{\n                    \"aria-label\": t(\"subscribe_dialog_subscribe_use_another_label\"),\n                  }}\n                />\n              }\n              label={t(\"subscribe_dialog_subscribe_use_another_label\")}\n            />\n            {anotherServerVisible && (\n              <Autocomplete\n                freeSolo\n                options={existingBaseUrls}\n                inputValue={props.baseUrl}\n                onInputChange={updateBaseUrl}\n                renderInput={(params) => (\n                  <>\n                    <TextField\n                      {...params}\n                      placeholder={config.base_url}\n                      variant=\"standard\"\n                      aria-label={t(\"subscribe_dialog_subscribe_base_url_label\")}\n                    />\n                    {webPushEnabled && (\n                      <div style={{ width: \"100%\", color: \"#aaa\", fontSize: \"0.75rem\", marginTop: \"0.5rem\" }}>\n                        {t(\"subscribe_dialog_subscribe_use_another_background_info\")}\n                      </div>\n                    )}\n                  </>\n                )}\n              />\n            )}\n          </FormGroup>\n        )}\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onCancel}>{t(\"subscribe_dialog_subscribe_button_cancel\")}</Button>\n        <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>\n          {t(\"subscribe_dialog_subscribe_button_subscribe\")}\n        </Button>\n      </DialogFooter>\n    </>\n  );\n};\n\nconst LoginPage = (props) => {\n  const { t } = useTranslation();\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [error, setError] = useState(\"\");\n  const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;\n  const { topic } = props;\n\n  const handleLogin = async () => {\n    const user = { baseUrl, username, password };\n    const success = await api.topicAuth(baseUrl, topic, user);\n    if (!success) {\n      console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);\n      setError(t(\"subscribe_dialog_error_user_not_authorized\", { username }));\n      return;\n    }\n    console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);\n    await userManager.save(user);\n    props.onSuccess();\n  };\n\n  return (\n    <>\n      <DialogTitle>{t(\"subscribe_dialog_login_title\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"subscribe_dialog_login_description\")}</DialogContentText>\n        <TextField\n          autoFocus\n          margin=\"dense\"\n          id=\"username\"\n          label={t(\"subscribe_dialog_login_username_label\")}\n          value={username}\n          onChange={(ev) => setUsername(ev.target.value)}\n          type=\"text\"\n          fullWidth\n          variant=\"standard\"\n          inputProps={{\n            \"aria-label\": t(\"subscribe_dialog_login_username_label\"),\n          }}\n        />\n        <TextField\n          margin=\"dense\"\n          id=\"password\"\n          label={t(\"subscribe_dialog_login_password_label\")}\n          type=\"password\"\n          value={password}\n          onChange={(ev) => setPassword(ev.target.value)}\n          fullWidth\n          variant=\"standard\"\n          inputProps={{\n            \"aria-label\": t(\"subscribe_dialog_login_password_label\"),\n          }}\n        />\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onBack}>{t(\"common_back\")}</Button>\n        <Button onClick={handleLogin}>{t(\"subscribe_dialog_login_button_login\")}</Button>\n      </DialogFooter>\n    </>\n  );\n};\n\nexport default SubscribeDialog;\n"
  },
  {
    "path": "web/src/components/SubscriptionPopup.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useState } from \"react\";\nimport {\n  Button,\n  TextField,\n  Dialog,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Chip,\n  InputAdornment,\n  Portal,\n  Snackbar,\n  useMediaQuery,\n  MenuItem,\n  IconButton,\n  ListItemIcon,\n  useTheme,\n} from \"@mui/material\";\nimport { useTranslation } from \"react-i18next\";\nimport { useNavigate } from \"react-router-dom\";\nimport {\n  Clear,\n  ClearAll,\n  Edit,\n  EnhancedEncryption,\n  Lock,\n  LockOpen,\n  Notifications,\n  NotificationsOff,\n  RemoveCircle,\n  Send,\n} from \"@mui/icons-material\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport DialogFooter from \"./DialogFooter\";\nimport accountApi, { Role } from \"../app/AccountApi\";\nimport session from \"../app/Session\";\nimport routes from \"./routes\";\nimport PopupMenu from \"./PopupMenu\";\nimport { formatShortDateTime, shuffle } from \"../app/utils\";\nimport api from \"../app/Api\";\nimport { AccountContext } from \"./App\";\nimport { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from \"./ReserveDialogs\";\nimport { UnauthorizedError } from \"../app/errors\";\n\nexport const SubscriptionPopup = (props) => {\n  const { t } = useTranslation();\n  const { account } = useContext(AccountContext);\n  const navigate = useNavigate();\n  const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);\n  const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);\n  const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);\n  const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);\n  const [showPublishError, setShowPublishError] = useState(false);\n  const { subscription } = props;\n  const placement = props.placement ?? \"left\";\n  const reservations = account?.reservations || [];\n\n  const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0;\n  const showReservationAddDisabled =\n    !showReservationAdd &&\n    config.enable_reservations &&\n    !subscription?.reservation &&\n    (config.enable_payments || account?.stats.reservations_remaining === 0);\n  const showReservationEdit = config.enable_reservations && !!subscription?.reservation;\n  const showReservationDelete = config.enable_reservations && !!subscription?.reservation;\n\n  const handleChangeDisplayName = async () => {\n    setDisplayNameDialogOpen(true);\n  };\n\n  const handleReserveAdd = async () => {\n    setReserveAddDialogOpen(true);\n  };\n\n  const handleReserveEdit = async () => {\n    setReserveEditDialogOpen(true);\n  };\n\n  const handleReserveDelete = async () => {\n    setReserveDeleteDialogOpen(true);\n  };\n\n  const handleSendTestMessage = async () => {\n    const { baseUrl, topic } = props.subscription;\n    const tags = shuffle([\n      \"grinning\",\n      \"octopus\",\n      \"upside_down_face\",\n      \"palm_tree\",\n      \"maple_leaf\",\n      \"apple\",\n      \"skull\",\n      \"warning\",\n      \"jack_o_lantern\",\n      \"de-server-1\",\n      \"backups\",\n      \"cron-script\",\n      \"script-error\",\n      \"phils-automation\",\n      \"mouse\",\n      \"go-rocks\",\n      \"hi-ben\",\n    ]).slice(0, Math.round(Math.random() * 4));\n    const priority = shuffle([1, 2, 3, 4, 5])[0];\n    const title = shuffle([\n      \"\",\n      \"\",\n      \"\", // Higher chance of no title\n      \"Oh my, another test message?\",\n      \"Titles are optional, did you know that?\",\n      \"ntfy is open source, and will always be free. Cool, right?\",\n      \"I don't really like apples\",\n      \"My favorite TV show is The Wire. You should watch it!\",\n      \"You can attach files and URLs to messages too\",\n      \"You can delay messages up to 3 days\",\n    ])[0];\n    const nowSeconds = Math.round(Date.now() / 1000);\n    const message = shuffle([\n      `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(\n        nowSeconds,\n        \"en-US\"\n      )} right now. Is that early or late?`,\n      `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,\n      `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,\n      `Alright then, it's ${formatShortDateTime(\n        nowSeconds,\n        \"en-US\"\n      )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,\n      `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,\n      `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,\n      `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,\n    ])[0];\n    try {\n      await api.publish(baseUrl, topic, message, {\n        title,\n        priority,\n        tags,\n      });\n    } catch (e) {\n      console.log(`[SubscriptionPopup] Error publishing message`, e);\n      setShowPublishError(true);\n    }\n  };\n\n  const handleClearAll = async () => {\n    console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);\n    await subscriptionManager.deleteNotifications(props.subscription.id);\n  };\n\n  const handleSetMutedUntil = async (mutedUntil) => {\n    await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);\n  };\n\n  const handleUnsubscribe = async () => {\n    console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);\n    await subscriptionManager.remove(props.subscription);\n    if (session.exists() && !subscription.internal) {\n      try {\n        await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);\n      } catch (e) {\n        console.log(`[SubscriptionPopup] Error unsubscribing`, e);\n        if (e instanceof UnauthorizedError) {\n          await session.resetAndRedirect(routes.login);\n        }\n      }\n    }\n    const newSelected = await subscriptionManager.first(); // May be undefined\n    if (newSelected && !newSelected.internal) {\n      navigate(routes.forSubscription(newSelected));\n    } else {\n      navigate(routes.app);\n    }\n  };\n\n  return (\n    <>\n      <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>\n        <MenuItem onClick={handleChangeDisplayName}>\n          <ListItemIcon>\n            <Edit fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_change_display_name\")}\n        </MenuItem>\n        {showReservationAdd && (\n          <MenuItem onClick={handleReserveAdd}>\n            <ListItemIcon>\n              <Lock fontSize=\"small\" />\n            </ListItemIcon>\n            {t(\"action_bar_reservation_add\")}\n          </MenuItem>\n        )}\n        {showReservationAddDisabled && (\n          <MenuItem sx={{ cursor: \"default\" }}>\n            <ListItemIcon>\n              <Lock fontSize=\"small\" color=\"disabled\" />\n            </ListItemIcon>\n            <span style={{ opacity: 0.3 }}>{t(\"action_bar_reservation_add\")}</span>\n            <ReserveLimitChip />\n          </MenuItem>\n        )}\n        {showReservationEdit && (\n          <MenuItem onClick={handleReserveEdit}>\n            <ListItemIcon>\n              <EnhancedEncryption fontSize=\"small\" />\n            </ListItemIcon>\n            {t(\"action_bar_reservation_edit\")}\n          </MenuItem>\n        )}\n        {showReservationDelete && (\n          <MenuItem onClick={handleReserveDelete}>\n            <ListItemIcon>\n              <LockOpen fontSize=\"small\" />\n            </ListItemIcon>\n            {t(\"action_bar_reservation_delete\")}\n          </MenuItem>\n        )}\n        <MenuItem onClick={handleSendTestMessage}>\n          <ListItemIcon>\n            <Send fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_send_test_notification\")}\n        </MenuItem>\n        <MenuItem onClick={handleClearAll}>\n          <ListItemIcon>\n            <ClearAll fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_clear_notifications\")}\n        </MenuItem>\n        {!!subscription.mutedUntil && (\n          <MenuItem onClick={() => handleSetMutedUntil(0)}>\n            <ListItemIcon>\n              <Notifications fontSize=\"small\" />\n            </ListItemIcon>\n            {t(\"action_bar_unmute_notifications\")}\n          </MenuItem>\n        )}\n        {!subscription.mutedUntil && (\n          <MenuItem onClick={() => handleSetMutedUntil(1)}>\n            <ListItemIcon>\n              <NotificationsOff fontSize=\"small\" />\n            </ListItemIcon>\n            {t(\"action_bar_mute_notifications\")}\n          </MenuItem>\n        )}\n        <MenuItem onClick={handleUnsubscribe}>\n          <ListItemIcon>\n            <RemoveCircle fontSize=\"small\" />\n          </ListItemIcon>\n          {t(\"action_bar_unsubscribe\")}\n        </MenuItem>\n      </PopupMenu>\n      <Portal>\n        <Snackbar\n          open={showPublishError}\n          autoHideDuration={3000}\n          onClose={() => setShowPublishError(false)}\n          message={t(\"message_bar_error_publishing\")}\n        />\n        <DisplayNameDialog open={displayNameDialogOpen} subscription={subscription} onClose={() => setDisplayNameDialogOpen(false)} />\n        {showReservationAdd && (\n          <ReserveAddDialog\n            open={reserveAddDialogOpen}\n            topic={subscription.topic}\n            reservations={reservations}\n            onClose={() => setReserveAddDialogOpen(false)}\n          />\n        )}\n        {showReservationEdit && (\n          <ReserveEditDialog\n            open={reserveEditDialogOpen}\n            reservation={subscription.reservation}\n            reservations={props.reservations}\n            onClose={() => setReserveEditDialogOpen(false)}\n          />\n        )}\n        {showReservationDelete && (\n          <ReserveDeleteDialog\n            open={reserveDeleteDialogOpen}\n            topic={subscription.topic}\n            onClose={() => setReserveDeleteDialogOpen(false)}\n          />\n        )}\n      </Portal>\n    </>\n  );\n};\n\nconst DisplayNameDialog = (props) => {\n  const theme = useTheme();\n  const { t } = useTranslation();\n  const { subscription } = props;\n  const [error, setError] = useState(\"\");\n  const [displayName, setDisplayName] = useState(subscription.displayName ?? \"\");\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  const handleSave = async () => {\n    await subscriptionManager.setDisplayName(subscription.id, displayName);\n    if (session.exists() && !subscription.internal) {\n      try {\n        console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);\n        await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });\n      } catch (e) {\n        console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);\n        if (e instanceof UnauthorizedError) {\n          await session.resetAndRedirect(routes.login);\n        } else {\n          setError(e.message);\n          return;\n        }\n      }\n    }\n    props.onClose();\n  };\n\n  return (\n    <Dialog open={props.open} onClose={props.onClose} maxWidth=\"sm\" fullWidth fullScreen={fullScreen}>\n      <DialogTitle>{t(\"display_name_dialog_title\")}</DialogTitle>\n      <DialogContent>\n        <DialogContentText>{t(\"display_name_dialog_description\")}</DialogContentText>\n        <TextField\n          autoFocus\n          placeholder={t(\"display_name_dialog_placeholder\")}\n          value={displayName}\n          onChange={(ev) => setDisplayName(ev.target.value)}\n          type=\"text\"\n          fullWidth\n          variant=\"standard\"\n          inputProps={{\n            maxLength: 64,\n            \"aria-label\": t(\"display_name_dialog_placeholder\"),\n          }}\n          InputProps={{\n            endAdornment: (\n              <InputAdornment position=\"end\">\n                <IconButton onClick={() => setDisplayName(\"\")} edge=\"end\">\n                  <Clear />\n                </IconButton>\n              </InputAdornment>\n            ),\n          }}\n        />\n      </DialogContent>\n      <DialogFooter status={error}>\n        <Button onClick={props.onClose}>{t(\"common_cancel\")}</Button>\n        <Button onClick={handleSave}>{t(\"common_save\")}</Button>\n      </DialogFooter>\n    </Dialog>\n  );\n};\n\nexport const ReserveLimitChip = () => {\n  const { account } = useContext(AccountContext);\n  if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {\n    return <></>;\n  }\n  if (config.enable_payments) {\n    return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;\n  }\n  if (account) {\n    return <LimitReachedChip />;\n  }\n  return <></>;\n};\n\nconst LimitReachedChip = () => {\n  const { t } = useTranslation();\n  return (\n    <Chip\n      label={t(\"action_bar_reservation_limit_reached\")}\n      variant=\"outlined\"\n      color=\"primary\"\n      sx={{\n        opacity: 0.8,\n        borderWidth: \"2px\",\n        height: \"24px\",\n        marginLeft: \"5px\",\n      }}\n    />\n  );\n};\n\nexport const ProChip = () => (\n  <Chip\n    label=\"ntfy Pro\"\n    variant=\"outlined\"\n    color=\"primary\"\n    sx={{\n      opacity: 0.8,\n      fontWeight: \"bold\",\n      borderWidth: \"2px\",\n      height: \"24px\",\n      marginLeft: \"5px\",\n    }}\n  />\n);\n"
  },
  {
    "path": "web/src/components/UpgradeDialog.jsx",
    "content": "import * as React from \"react\";\nimport { useContext, useEffect, useState } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogTitle,\n  Alert,\n  CardActionArea,\n  CardContent,\n  Chip,\n  Link,\n  ListItem,\n  Switch,\n  useMediaQuery,\n  Button,\n  Card,\n  Typography,\n  List,\n  ListItemIcon,\n  ListItemText,\n  Box,\n  DialogContentText,\n  DialogActions,\n  useTheme,\n} from \"@mui/material\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { Check, Close } from \"@mui/icons-material\";\nimport { NavLink } from \"react-router-dom\";\nimport { UnauthorizedError } from \"../app/errors\";\nimport { formatBytes, formatNumber, formatPrice, formatShortDate } from \"../app/utils\";\nimport { AccountContext } from \"./App\";\nimport routes from \"./routes\";\nimport session from \"../app/Session\";\nimport accountApi, { SubscriptionInterval } from \"../app/AccountApi\";\n\nconst Feature = (props) => <FeatureItem feature>{props.children}</FeatureItem>;\n\nconst NoFeature = (props) => <FeatureItem feature={false}>{props.children}</FeatureItem>;\n\nconst FeatureItem = (props) => (\n  <ListItem disableGutters sx={{ m: 0, p: 0 }}>\n    <ListItemIcon sx={{ minWidth: \"24px\" }}>\n      {props.feature && <Check fontSize=\"small\" sx={{ color: \"#338574\" }} />}\n      {!props.feature && <Close fontSize=\"small\" sx={{ color: \"gray\" }} />}\n    </ListItemIcon>\n    <ListItemText sx={{ mt: \"2px\", mb: \"2px\" }} primary={<Typography variant=\"body1\">{props.children}</Typography>} />\n  </ListItem>\n);\n\nconst Action = {\n  REDIRECT_SIGNUP: 1,\n  CREATE_SUBSCRIPTION: 2,\n  UPDATE_SUBSCRIPTION: 3,\n  CANCEL_SUBSCRIPTION: 4,\n};\n\nconst Banner = {\n  CANCEL_WARNING: 1,\n  PRORATION_INFO: 2,\n  RESERVATIONS_WARNING: 3,\n};\n\nconst UpgradeDialog = (props) => {\n  const theme = useTheme();\n  const { t, i18n } = useTranslation();\n  const { account } = useContext(AccountContext); // May be undefined!\n  const [error, setError] = useState(\"\");\n  const [tiers, setTiers] = useState(null);\n  const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);\n  const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined\n  const [loading, setLoading] = useState(false);\n  const fullScreen = useMediaQuery(theme.breakpoints.down(\"sm\"));\n\n  useEffect(() => {\n    const fetchTiers = async () => {\n      setTiers(await accountApi.billingTiers());\n    };\n    fetchTiers(); // Dangle\n  }, []);\n\n  if (!tiers) {\n    return <></>;\n  }\n\n  const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier })));\n  const newTier = tiersMap[newTierCode]; // May be undefined\n  const currentTier = account?.tier; // May be undefined\n  const currentInterval = account?.billing?.interval; // May be undefined\n  const currentTierCode = currentTier?.code; // May be undefined\n\n  // Figure out buttons, labels and the submit action\n  let submitAction;\n  let submitButtonLabel;\n  let banner;\n  if (!account) {\n    submitButtonLabel = t(\"account_upgrade_dialog_button_redirect_signup\");\n    submitAction = Action.REDIRECT_SIGNUP;\n    banner = null;\n  } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {\n    submitButtonLabel = t(\"account_upgrade_dialog_button_update_subscription\");\n    submitAction = null;\n    banner = currentTierCode ? Banner.PRORATION_INFO : null;\n  } else if (!currentTierCode) {\n    submitButtonLabel = t(\"account_upgrade_dialog_button_pay_now\");\n    submitAction = Action.CREATE_SUBSCRIPTION;\n    banner = null;\n  } else if (!newTierCode) {\n    submitButtonLabel = t(\"account_upgrade_dialog_button_cancel_subscription\");\n    submitAction = Action.CANCEL_SUBSCRIPTION;\n    banner = Banner.CANCEL_WARNING;\n  } else {\n    submitButtonLabel = t(\"account_upgrade_dialog_button_update_subscription\");\n    submitAction = Action.UPDATE_SUBSCRIPTION;\n    banner = Banner.PRORATION_INFO;\n  }\n\n  // Exceptional conditions\n  if (loading) {\n    submitAction = null;\n  } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) {\n    submitAction = null;\n    banner = Banner.RESERVATIONS_WARNING;\n  }\n\n  const handleSubmit = async () => {\n    if (submitAction === Action.REDIRECT_SIGNUP) {\n      window.location.href = routes.signup;\n      return;\n    }\n    try {\n      setLoading(true);\n      if (submitAction === Action.CREATE_SUBSCRIPTION) {\n        const response = await accountApi.createBillingSubscription(newTierCode, interval);\n        window.location.href = response.redirect_url;\n      } else if (submitAction === Action.UPDATE_SUBSCRIPTION) {\n        await accountApi.updateBillingSubscription(newTierCode, interval);\n      } else if (submitAction === Action.CANCEL_SUBSCRIPTION) {\n        await accountApi.deleteBillingSubscription();\n      }\n      props.onCancel();\n    } catch (e) {\n      console.log(`[UpgradeDialog] Error changing billing subscription`, e);\n      if (e instanceof UnauthorizedError) {\n        await session.resetAndRedirect(routes.login);\n      } else {\n        setError(e.message);\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Figure out discount\n  let discount = 0;\n  let upto = false;\n  if (newTier?.prices) {\n    discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100);\n  } else {\n    let n = 0;\n    for (const tier of tiers) {\n      if (tier.prices) {\n        const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100);\n        if (tierDiscount > discount) {\n          discount = tierDiscount;\n          n += 1;\n        }\n      }\n    }\n    upto = n > 1;\n  }\n\n  return (\n    <Dialog open={props.open} onClose={props.onCancel} maxWidth=\"lg\" fullScreen={fullScreen}>\n      <DialogTitle>\n        <div style={{ display: \"flex\", flexDirection: \"row\" }}>\n          <div style={{ flexGrow: 1 }}>{t(\"account_upgrade_dialog_title\")}</div>\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"row\",\n              alignItems: \"center\",\n              marginTop: \"4px\",\n            }}\n          >\n            <Typography component=\"span\" variant=\"subtitle1\">\n              {t(\"account_upgrade_dialog_interval_monthly\")}\n            </Typography>\n            <Switch\n              checked={interval === SubscriptionInterval.YEAR}\n              onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}\n            />\n            <Typography component=\"span\" variant=\"subtitle1\">\n              {t(\"account_upgrade_dialog_interval_yearly\")}\n            </Typography>\n            {discount > 0 && (\n              <Chip\n                label={\n                  upto\n                    ? t(\"account_upgrade_dialog_interval_yearly_discount_save_up_to\", { discount })\n                    : t(\"account_upgrade_dialog_interval_yearly_discount_save\", { discount })\n                }\n                color=\"primary\"\n                size=\"small\"\n                variant={interval === SubscriptionInterval.YEAR ? \"filled\" : \"outlined\"}\n                sx={{ marginLeft: \"5px\" }}\n              />\n            )}\n          </div>\n        </div>\n      </DialogTitle>\n      <DialogContent>\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"row\",\n            marginBottom: \"8px\",\n            width: \"100%\",\n          }}\n        >\n          {tiers.map((tier) => (\n            <TierCard\n              key={`tierCard${tier.code || \"_free\"}`}\n              tier={tier}\n              current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!\n              selected={newTierCode === tier.code} // tier.code may be undefined!\n              interval={interval}\n              onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!\n            />\n          ))}\n        </div>\n        {banner === Banner.CANCEL_WARNING && (\n          <Alert severity=\"warning\" sx={{ fontSize: \"1rem\" }}>\n            <Trans\n              i18nKey=\"account_upgrade_dialog_cancel_warning\"\n              values={{\n                date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),\n              }}\n            />\n          </Alert>\n        )}\n        {banner === Banner.PRORATION_INFO && (\n          <Alert severity=\"info\" sx={{ fontSize: \"1rem\" }}>\n            <Trans i18nKey=\"account_upgrade_dialog_proration_info\" />\n          </Alert>\n        )}\n        {banner === Banner.RESERVATIONS_WARNING && (\n          <Alert severity=\"warning\" sx={{ fontSize: \"1rem\" }}>\n            <Trans\n              i18nKey=\"account_upgrade_dialog_reservations_warning\"\n              count={(account?.reservations.length ?? 0) - (newTier?.limits.reservations ?? 0)}\n              components={{\n                Link: <NavLink to={routes.settings} />,\n              }}\n            />\n          </Alert>\n        )}\n      </DialogContent>\n      <Box\n        sx={{\n          display: \"flex\",\n          flexDirection: \"row\",\n          justifyContent: \"space-between\",\n          paddingLeft: \"24px\",\n          paddingBottom: \"8px\",\n        }}\n      >\n        <DialogContentText\n          component=\"div\"\n          aria-live=\"polite\"\n          sx={{\n            margin: \"0px\",\n            paddingTop: \"12px\",\n            paddingBottom: \"4px\",\n          }}\n        >\n          {config.billing_contact.indexOf(\"@\") !== -1 && (\n            <>\n              <Trans\n                i18nKey=\"account_upgrade_dialog_billing_contact_email\"\n                components={{\n                  Link: <Link href={`mailto:${config.billing_contact}`} />,\n                }}\n              />{\" \"}\n            </>\n          )}\n          {config.billing_contact.match(`^http?s://`) && (\n            <>\n              <Trans\n                i18nKey=\"account_upgrade_dialog_billing_contact_website\"\n                components={{\n                  Link: <Link href={config.billing_contact} target=\"_blank\" />,\n                }}\n              />{\" \"}\n            </>\n          )}\n          {error}\n        </DialogContentText>\n        <DialogActions sx={{ paddingRight: 2 }}>\n          <Button onClick={props.onCancel}>{t(\"account_upgrade_dialog_button_cancel\")}</Button>\n          <Button onClick={handleSubmit} disabled={!submitAction}>\n            {submitButtonLabel}\n          </Button>\n        </DialogActions>\n      </Box>\n    </Dialog>\n  );\n};\n\nconst TierCard = (props) => {\n  const { t } = useTranslation();\n  const { tier } = props;\n\n  let cardStyle;\n  let labelStyle;\n  let labelText;\n  if (props.selected) {\n    cardStyle = { background: \"#eee\", border: \"3px solid #338574\" };\n    labelStyle = { background: \"#338574\", color: \"white\" };\n    labelText = t(\"account_upgrade_dialog_tier_selected_label\");\n  } else if (props.current) {\n    cardStyle = { border: \"3px solid #eee\" };\n    labelStyle = { background: \"#eee\", color: \"black\" };\n    labelText = t(\"account_upgrade_dialog_tier_current_label\");\n  } else {\n    cardStyle = { border: \"3px solid transparent\" };\n  }\n\n  let monthlyPrice;\n  if (!tier.prices) {\n    monthlyPrice = 0;\n  } else if (props.interval === SubscriptionInterval.YEAR) {\n    monthlyPrice = tier.prices.year / 12;\n  } else if (props.interval === SubscriptionInterval.MONTH) {\n    monthlyPrice = tier.prices.month;\n  }\n\n  return (\n    <Box\n      sx={{\n        m: \"7px\",\n        minWidth: \"240px\",\n        flexGrow: 1,\n        flexShrink: 1,\n        flexBasis: 0,\n        borderRadius: \"5px\",\n        \"&:first-of-type\": { ml: 0 },\n        \"&:last-of-type\": { mr: 0 },\n        ...cardStyle,\n      }}\n    >\n      <Card sx={{ height: \"100%\" }}>\n        <CardActionArea sx={{ height: \"100%\" }}>\n          <CardContent onClick={props.onClick} sx={{ height: \"100%\" }}>\n            {labelStyle && (\n              <div\n                style={{\n                  position: \"absolute\",\n                  top: \"0\",\n                  right: \"15px\",\n                  padding: \"2px 10px\",\n                  borderRadius: \"3px\",\n                  ...labelStyle,\n                }}\n              >\n                {labelText}\n              </div>\n            )}\n            <Typography variant=\"subtitle1\" component=\"div\">\n              {tier.name || t(\"account_basics_tier_free\")}\n            </Typography>\n            <div>\n              <Typography component=\"span\" variant=\"h4\" sx={{ fontWeight: 500, marginRight: \"3px\" }}>\n                {formatPrice(monthlyPrice)}\n              </Typography>\n              {monthlyPrice > 0 && <>/ {t(\"account_upgrade_dialog_tier_price_per_month\")}</>}\n            </div>\n            <List dense>\n              {tier.limits.reservations > 0 && (\n                <Feature>\n                  {t(\"account_upgrade_dialog_tier_features_reservations\", {\n                    reservations: tier.limits.reservations,\n                    count: tier.limits.reservations,\n                  })}\n                </Feature>\n              )}\n              <Feature>\n                {t(\"account_upgrade_dialog_tier_features_messages\", {\n                  messages: formatNumber(tier.limits.messages),\n                  count: tier.limits.messages,\n                })}\n              </Feature>\n              <Feature>\n                {t(\"account_upgrade_dialog_tier_features_emails\", {\n                  emails: formatNumber(tier.limits.emails),\n                  count: tier.limits.emails,\n                })}\n              </Feature>\n              {tier.limits.calls > 0 && (\n                <Feature>\n                  {t(\"account_upgrade_dialog_tier_features_calls\", {\n                    calls: formatNumber(tier.limits.calls),\n                    count: tier.limits.calls,\n                  })}\n                </Feature>\n              )}\n              <Feature>\n                {t(\"account_upgrade_dialog_tier_features_attachment_file_size\", {\n                  filesize: formatBytes(tier.limits.attachment_file_size, 0),\n                })}\n              </Feature>\n              {tier.limits.reservations === 0 && <NoFeature>{t(\"account_upgrade_dialog_tier_features_no_reservations\")}</NoFeature>}\n              {tier.limits.calls === 0 && <NoFeature>{t(\"account_upgrade_dialog_tier_features_no_calls\")}</NoFeature>}\n            </List>\n            {tier.prices && props.interval === SubscriptionInterval.MONTH && (\n              <Typography variant=\"body2\" color=\"gray\">\n                {t(\"account_upgrade_dialog_tier_price_billed_monthly\", {\n                  price: formatPrice(tier.prices.month * 12),\n                })}\n              </Typography>\n            )}\n            {tier.prices && props.interval === SubscriptionInterval.YEAR && (\n              <Typography variant=\"body2\" color=\"gray\">\n                {t(\"account_upgrade_dialog_tier_price_billed_yearly\", {\n                  price: formatPrice(tier.prices.year),\n                  save: formatPrice(tier.prices.month * 12 - tier.prices.year),\n                })}\n              </Typography>\n            )}\n          </CardContent>\n        </CardActionArea>\n      </Card>\n    </Box>\n  );\n};\n\nexport default UpgradeDialog;\n"
  },
  {
    "path": "web/src/components/hooks.js",
    "content": "import { useParams } from \"react-router-dom\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useLiveQuery } from \"dexie-react-hooks\";\nimport subscriptionManager from \"../app/SubscriptionManager\";\nimport { disallowedTopic, expandSecureUrl, topicUrl } from \"../app/utils\";\nimport routes from \"./routes\";\nimport connectionManager from \"../app/ConnectionManager\";\nimport poller from \"../app/Poller\";\nimport pruner from \"../app/Pruner\";\nimport session from \"../app/Session\";\nimport accountApi from \"../app/AccountApi\";\nimport versionChecker from \"../app/VersionChecker\";\nimport { UnauthorizedError } from \"../app/errors\";\nimport notifier from \"../app/Notifier\";\nimport prefs from \"../app/Prefs\";\nimport { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from \"../app/events\";\n\n/**\n * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection\n * state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead\n * to the connection being re-established).\n *\n * When Web Push is enabled, we do not need to connect to our home server via WebSocket, since notifications\n * will be delivered via Web Push. However, we still need to connect to other servers via WebSocket, or for internal\n * topics, such as sync topics (st_...).\n */\nexport const useConnectionListeners = (account, subscriptions, users, webPushTopics) => {\n  const wsSubscriptions = useMemo(\n    () => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []),\n    // wsSubscriptions should stay stable unless the list of subscription IDs changes. Without the memo, the connection\n    // listener calls a refresh for no reason. This isn't a problem due to the makeConnectionId, but it triggers an\n    // unnecessary recomputation for every received message.\n    [JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })]\n  );\n\n  // Register listeners for incoming messages, and connection state changes\n  useEffect(\n    () => {\n      const handleInternalMessage = async (message) => {\n        console.log(`[ConnectionListener] Received message on sync topic`, message.message);\n        try {\n          const data = JSON.parse(message.message);\n          if (data.event === \"sync\") {\n            console.log(`[ConnectionListener] Triggering account sync`);\n            await accountApi.sync();\n          } else {\n            console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);\n          }\n        } catch (e) {\n          console.log(`[ConnectionListener] Error parsing sync topic message`, e);\n        }\n      };\n\n      const handleNotification = async (subscription, notification) => {\n        // This logic is (partially) duplicated in\n        // - Android: SubscriberService::onNotificationReceived()\n        // - Android: FirebaseService::onMessageReceived()\n        // - Web app: hooks.js:handleNotification()\n        // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...\n\n        if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {\n          await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);\n          await notifier.cancel(subscription, notification);\n        } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {\n          await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);\n          await notifier.cancel(subscription, notification);\n        } else {\n          // Regular message: delete existing and add new\n          const sequenceId = notification.sequence_id || notification.id;\n          if (sequenceId) {\n            await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);\n          }\n          const added = await subscriptionManager.addNotification(subscription.id, notification);\n          if (added) {\n            await subscriptionManager.notify(subscription.id, notification);\n          }\n        }\n      };\n\n      const handleMessage = async (subscriptionId, message) => {\n        const subscription = await subscriptionManager.get(subscriptionId);\n\n        // Race condition: sometimes the subscription is already unsubscribed from account\n        // sync before the message is handled\n        if (!subscription) {\n          return;\n        }\n\n        if (subscription.internal) {\n          await handleInternalMessage(message);\n        } else {\n          await handleNotification(subscription, message);\n        }\n      };\n\n      connectionManager.registerStateListener((id, state) => subscriptionManager.updateState(id, state));\n      connectionManager.registerMessageListener(handleMessage);\n\n      return () => {\n        connectionManager.resetStateListener();\n        connectionManager.resetMessageListener();\n      };\n    },\n    // We have to disable dep checking for \"navigate\". This is fine, it never changes.\n\n    []\n  );\n\n  // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic\n  useEffect(() => {\n    if (!account || !account.sync_topic) {\n      return;\n    }\n    subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!\n  }, [account]);\n\n  // When subscriptions or users change, refresh the connections\n  useEffect(() => {\n    connectionManager.refresh(wsSubscriptions, users); // Dangle\n  }, [wsSubscriptions, users]);\n};\n\n/**\n * Automatically adds a subscription if we navigate to a page that has not been subscribed to.\n * This will only be run once after the initial page load.\n */\nexport const useAutoSubscribe = (subscriptions, selected) => {\n  const [hasRun, setHasRun] = useState(false);\n  const params = useParams();\n\n  useEffect(() => {\n    const loaded = subscriptions !== null && subscriptions !== undefined;\n    if (!loaded || hasRun) {\n      return;\n    }\n    setHasRun(true);\n    const eligible = params.topic && !selected && !disallowedTopic(params.topic);\n    if (eligible) {\n      const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;\n      console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);\n      (async () => {\n        const subscription = await subscriptionManager.add(baseUrl, params.topic);\n        if (session.exists()) {\n          try {\n            await accountApi.addSubscription(baseUrl, params.topic);\n          } catch (e) {\n            console.log(`[Hooks] Auto-subscribing failed`, e);\n            if (e instanceof UnauthorizedError) {\n              await session.resetAndRedirect(routes.login);\n            }\n          }\n        }\n        poller.pollInBackground(subscription); // Dangle!\n      })();\n    }\n  }, [params, subscriptions, selected, hasRun]);\n};\n\nconst webPushBroadcastChannel = new BroadcastChannel(\"web-push-broadcast\");\n\n/**\n * Hook to return a value that's refreshed when the notification permission changes\n */\nexport const useNotificationPermissionListener = (query) => {\n  const [result, setResult] = useState(query());\n\n  useEffect(() => {\n    const handler = () => {\n      setResult(query());\n    };\n\n    if (\"permissions\" in navigator) {\n      navigator.permissions.query({ name: \"notifications\" }).then((permission) => {\n        permission.addEventListener(\"change\", handler);\n\n        return () => {\n          permission.removeEventListener(\"change\", handler);\n        };\n      });\n    }\n  }, []);\n\n  return result;\n};\n\n/**\n * Updates the Web Push subscriptions when the list of topics changes,\n * as well as plays a sound when a new broadcast message is received from\n * the service worker, since the service worker cannot play sounds.\n */\nconst useWebPushListener = (topics) => {\n  const [prevUpdate, setPrevUpdate] = useState();\n  const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());\n\n  useEffect(() => {\n    const nextUpdate = JSON.stringify({ topics, pushPossible });\n    if (topics === undefined || nextUpdate === prevUpdate) {\n      return;\n    }\n\n    (async () => {\n      try {\n        console.log(\"[useWebPushListener] Refreshing web push subscriptions\", topics);\n        await subscriptionManager.updateWebPushSubscriptions(topics);\n        setPrevUpdate(nextUpdate);\n      } catch (e) {\n        console.error(\"[useWebPushListener] Error refreshing web push subscriptions\", e);\n      }\n    })();\n  }, [topics, pushPossible, prevUpdate]);\n\n  useEffect(() => {\n    const onMessage = () => {\n      notifier.playSound(); // Service Worker cannot play sound, so we do it here!\n    };\n\n    webPushBroadcastChannel.addEventListener(\"message\", onMessage);\n\n    return () => {\n      webPushBroadcastChannel.removeEventListener(\"message\", onMessage);\n    };\n  });\n};\n\n/**\n * Hook to return a list of Web Push enabled topics using a live query. This hook will return an empty list if\n * permissions are not granted, or if the browser does not support Web Push. Notification permissions are acted upon\n * automatically.\n */\nexport const useWebPushTopics = () => {\n  const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());\n\n  const topics = useLiveQuery(\n    async () => subscriptionManager.webPushTopics(pushPossible),\n    // invalidate (reload) query when these values change\n    [pushPossible]\n  );\n\n  useWebPushListener(topics);\n\n  return topics;\n};\n\nconst matchMedia = window.matchMedia(\"(display-mode: standalone)\");\nconst isIOSStandalone = window.navigator.standalone === true;\n\n/*\n * Watches the \"display-mode\" to detect if the app is running as a standalone app (PWA).\n */\nexport const useIsLaunchedPWA = () => {\n  const [isStandalone, setIsStandalone] = useState(matchMedia.matches);\n\n  useEffect(() => {\n    if (isIOSStandalone) {\n      return () => {\n        // No need to listen for events on iOS\n      };\n    }\n    const handler = (evt) => {\n      console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? \"standalone\" : \"in the browser\"}`);\n      setIsStandalone(evt.matches);\n    };\n    matchMedia.addEventListener(\"change\", handler);\n    return () => {\n      matchMedia.removeEventListener(\"change\", handler);\n    };\n  }, []);\n\n  return isIOSStandalone || isStandalone;\n};\n\n/**\n * Watches the result of `useIsLaunchedPWA` and enables \"Web Push\" if it is.\n */\nexport const useStandaloneWebPushAutoSubscribe = () => {\n  const isLaunchedPWA = useIsLaunchedPWA();\n\n  useEffect(() => {\n    if (isLaunchedPWA) {\n      console.log(`[useStandaloneWebPushAutoSubscribe] Turning on web push automatically`);\n      prefs.setWebPushEnabled(true); // Dangle!\n    }\n  }, [isLaunchedPWA]);\n};\n\n/**\n * Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js\n * and Poller.js, because side effect imports are not a thing in JS, and \"Optimize imports\" cleans\n * up \"unused\" imports. See https://github.com/binwiederhier/ntfy/issues/186.\n */\n\nconst startWorkers = () => {\n  poller.startWorker();\n  pruner.startWorker();\n  accountApi.startWorker();\n  versionChecker.startWorker();\n};\n\nconst stopWorkers = () => {\n  poller.stopWorker();\n  pruner.stopWorker();\n  accountApi.stopWorker();\n  versionChecker.stopWorker();\n};\n\nexport const useBackgroundProcesses = () => {\n  useStandaloneWebPushAutoSubscribe();\n\n  useEffect(() => {\n    console.log(\"[useBackgroundProcesses] mounting\");\n    startWorkers();\n\n    return () => {\n      console.log(\"[useBackgroundProcesses] unloading\");\n      stopWorkers();\n    };\n  }, []);\n};\n\nexport const useAccountListener = (setAccount) => {\n  useEffect(() => {\n    accountApi.registerListener(setAccount);\n    accountApi.sync(); // Dangle\n    return () => {\n      accountApi.resetListener();\n    };\n  }, []);\n};\n\n/**\n * Hook to detect version/config changes and call the provided callback when a change is detected.\n */\nexport const useVersionChangeListener = (onVersionChange) => {\n  useEffect(() => {\n    versionChecker.registerListener(onVersionChange);\n    return () => {\n      versionChecker.resetListener();\n    };\n  }, [onVersionChange]);\n};\n"
  },
  {
    "path": "web/src/components/routes.js",
    "content": "import config from \"../app/config\";\nimport { shortUrl } from \"../app/utils\";\n\nconst routes = {\n  login: \"/login\",\n  signup: \"/signup\",\n  app: config.app_root,\n  account: \"/account\",\n  settings: \"/settings\",\n  subscription: \"/:topic\",\n  subscriptionExternal: \"/:baseUrl/:topic\",\n  forSubscription: (subscription) => {\n    if (subscription.baseUrl !== config.base_url) {\n      return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;\n    }\n    return `/${subscription.topic}`;\n  },\n};\n\nexport default routes;\n"
  },
  {
    "path": "web/src/components/styles.js",
    "content": "import { Typography, Container, Backdrop, styled } from \"@mui/material\";\n\nexport const Paragraph = styled(Typography)({\n  paddingTop: 8,\n  paddingBottom: 8,\n});\n\nexport const VerticallyCenteredContainer = styled(Container)(({ theme }) => ({\n  display: \"flex\",\n  flexGrow: 1,\n  flexDirection: \"column\",\n  justifyContent: \"center\",\n  alignContent: \"center\",\n  color: theme.palette.text.primary,\n}));\n\nexport const LightboxBackdrop = styled(Backdrop)({\n  backgroundColor: \"rgba(0, 0, 0, 0.8)\", // was: 0.5\n});\n"
  },
  {
    "path": "web/src/components/theme.js",
    "content": "/** @type {import(\"@mui/material\").ThemeOptions} */\nconst baseThemeOptions = {\n  components: {\n    MuiListItemIcon: {\n      styleOverrides: {\n        root: {\n          minWidth: \"36px\",\n        },\n      },\n    },\n    MuiCardContent: {\n      styleOverrides: {\n        root: {\n          \":last-child\": {\n            paddingBottom: \"16px\",\n          },\n        },\n      },\n    },\n    MuiCardActions: {\n      styleOverrides: {\n        root: {\n          overflowX: \"auto\",\n        },\n      },\n    },\n  },\n};\n\n// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/colors.xml\n\n/** @type {import(\"@mui/material\").ThemeOptions} */\nexport const lightTheme = {\n  ...baseThemeOptions,\n  components: {\n    ...baseThemeOptions.components,\n  },\n  palette: {\n    mode: \"light\",\n    primary: {\n      main: \"#338574\",\n    },\n    secondary: {\n      main: \"#6cead0\",\n    },\n    error: {\n      main: \"#c30000\",\n    },\n  },\n};\n\n/** @type {import(\"@mui/material\").ThemeOptions} */\nexport const darkTheme = {\n  ...baseThemeOptions,\n  components: {\n    ...baseThemeOptions.components,\n    MuiSnackbarContent: {\n      styleOverrides: {\n        root: {\n          color: \"#000\",\n          backgroundColor: \"#aeaeae\",\n        },\n      },\n    },\n  },\n  palette: {\n    mode: \"dark\",\n    background: {\n      paper: \"#1b2124\",\n    },\n    primary: {\n      main: \"#65b5a3\",\n    },\n    secondary: {\n      main: \"#6cead0\",\n    },\n    error: {\n      main: \"#fe4d2e\",\n    },\n  },\n};\n"
  },
  {
    "path": "web/src/index.jsx",
    "content": "import * as React from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport App from \"./components/App\";\nimport registerSW from \"./registerSW\";\n\nregisterSW();\n\nconst root = createRoot(document.querySelector(\"#root\"));\nroot.render(<App />);\n"
  },
  {
    "path": "web/src/registerSW.js",
    "content": "// eslint-disable-next-line import/no-unresolved\nimport { registerSW as viteRegisterSW } from \"virtual:pwa-register\";\n\n// fetch new sw every hour, i.e. update app every hour while running\nconst intervalMS = 60 * 60 * 1000;\n\n// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html\nconst registerSW = () => {\n  console.log(\"[ServiceWorker] Registering service worker\");\n  if (!(\"serviceWorker\" in navigator)) {\n    console.warn(\"[ServiceWorker] Service workers not supported\");\n    return;\n  }\n\n  viteRegisterSW({\n    onRegisteredSW(swUrl, registration) {\n      console.log(\"[ServiceWorker] Registered:\", { swUrl, registration });\n\n      if (!registration) {\n        console.warn(\"[ServiceWorker] No registration returned\");\n        return;\n      }\n\n      setInterval(async () => {\n        if (registration.installing || navigator?.onLine === false) return;\n\n        const resp = await fetch(swUrl, {\n          cache: \"no-store\",\n          headers: {\n            cache: \"no-store\",\n            \"cache-control\": \"no-cache\",\n          },\n        });\n\n        if (resp?.status === 200) {\n          console.log(\"[ServiceWorker] Updating service worker\");\n          await registration.update();\n        }\n      }, intervalMS);\n    },\n    onRegisterError(error) {\n      console.error(\"[ServiceWorker] Registration error:\", error);\n    },\n  });\n};\n\nexport default registerSW;\n"
  },
  {
    "path": "web/vite.config.js",
    "content": "/* eslint-disable import/no-extraneous-dependencies */\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { VitePWA } from \"vite-plugin-pwa\";\n\nexport default defineConfig(({ mode }) => ({\n  build: {\n    outDir: \"build\",\n    assetsDir: \"static/media\",\n    sourcemap: true,\n  },\n  server: {\n    port: 3000,\n  },\n  plugins: [\n    react(),\n    VitePWA({\n      registerType: \"autoUpdate\",\n      // see registerSW.js imported by index.jsx\n      injectRegister: null,\n      strategies: \"injectManifest\",\n      devOptions: {\n        enabled: true,\n        /* when using generateSW the PWA plugin will switch to classic */\n        type: \"module\",\n        navigateFallback: \"index.html\",\n      },\n      injectManifest: {\n        globPatterns: [\"**/*.{js,css,html,ico,png,svg,json}\"],\n        globIgnores: [\"config.js\"],\n        manifestTransforms: [\n          (entries) => ({\n            manifest: entries.map((entry) =>\n              // this matches the build step in the Makefile.\n              // since ntfy needs the ability to serve another page on /index.html,\n              // it's renamed and served from server.go as app.html as well.\n              entry.url === \"index.html\"\n                ? {\n                    ...entry,\n                    url: \"app.html\",\n                  }\n                : entry\n            ),\n          }),\n        ],\n      },\n      // The actual prod manifest is served from the go server, see server.go handleWebManifest.\n      manifest: mode === \"development\" && {\n        theme_color: \"#317f6f\",\n        icons: [\n          {\n            src: \"/static/images/pwa-192x192.png\",\n            sizes: \"192x192\",\n            type: \"image/png\",\n          },\n        ],\n      },\n    }),\n  ],\n}));\n"
  },
  {
    "path": "webpush/store.go",
    "content": "package webpush\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"net/netip\"\n\t\"time\"\n\n\t\"heckel.io/ntfy/v2/db\"\n\t\"heckel.io/ntfy/v2/util\"\n)\n\nconst (\n\tsubscriptionIDPrefix                     = \"wps_\"\n\tsubscriptionIDLength                     = 10\n\tsubscriptionEndpointLimitPerSubscriberIP = 10\n)\n\n// Errors returned by the store\nvar (\n\tErrWebPushTooManySubscriptions = errors.New(\"too many subscriptions\")\n\tErrWebPushUserIDCannotBeEmpty  = errors.New(\"user ID cannot be empty\")\n)\n\n// Store holds the database connection and queries for web push subscriptions.\ntype Store struct {\n\tdb      *db.DB\n\tqueries queries\n}\n\n// queries holds the database-specific SQL queries.\ntype queries struct {\n\tselectSubscriptionIDByEndpoint             string\n\tselectSubscriptionCountBySubscriberIP      string\n\tselectSubscriptionsForTopic                string\n\tselectSubscriptionsExpiringSoon            string\n\tupsertSubscription                         string\n\tupdateSubscriptionWarningSent              string\n\tupdateSubscriptionUpdatedAt                string\n\tdeleteSubscriptionByEndpoint               string\n\tdeleteSubscriptionByUserID                 string\n\tdeleteSubscriptionByAge                    string\n\tinsertSubscriptionTopic                    string\n\tdeleteSubscriptionTopicAll                 string\n\tdeleteSubscriptionTopicWithoutSubscription string\n}\n\n// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID.\nfunc (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {\n\treturn db.ExecTx(s.db, func(tx *sql.Tx) error {\n\t\t// Read number of subscriptions for subscriber IP address\n\t\tvar subscriptionCount int\n\t\tif err := tx.QueryRow(s.queries.selectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Read existing subscription ID for endpoint (or create new ID)\n\t\tvar subscriptionID string\n\t\tif err := tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID); errors.Is(err, sql.ErrNoRows) {\n\t\t\tif subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {\n\t\t\t\treturn ErrWebPushTooManySubscriptions\n\t\t\t}\n\t\t\tsubscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)\n\t\t} else if err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Insert or update subscription, and read back the actual ID (which may differ from\n\t\t// the generated one if another request for the same endpoint raced us and inserted first)\n\t\tupdatedAt, warnedAt := time.Now().Unix(), 0\n\t\tif err := tx.QueryRow(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt).Scan(&subscriptionID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Replace all subscription topics\n\t\tif _, err := tx.Exec(s.queries.deleteSubscriptionTopicAll, subscriptionID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, topic := range topics {\n\t\t\tif _, err := tx.Exec(s.queries.insertSubscriptionTopic, subscriptionID, topic); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// SubscriptionsForTopic returns all subscriptions for the given topic.\nfunc (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) {\n\trows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsForTopic, topic)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treturn subscriptionsFromRows(rows)\n}\n\n// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period.\nfunc (s *Store) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) {\n\trows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\treturn subscriptionsFromRows(rows)\n}\n\n// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon.\nfunc (s *Store) MarkExpiryWarningSent(subscriptions []*Subscription) error {\n\treturn db.ExecTx(s.db, func(tx *sql.Tx) error {\n\t\tfor _, subscription := range subscriptions {\n\t\t\tif _, err := tx.Exec(s.queries.updateSubscriptionWarningSent, time.Now().Unix(), subscription.ID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint.\nfunc (s *Store) RemoveSubscriptionsByEndpoint(endpoint string) error {\n\t_, err := s.db.Exec(s.queries.deleteSubscriptionByEndpoint, endpoint)\n\treturn err\n}\n\n// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID.\nfunc (s *Store) RemoveSubscriptionsByUserID(userID string) error {\n\tif userID == \"\" {\n\t\treturn ErrWebPushUserIDCannotBeEmpty\n\t}\n\t_, err := s.db.Exec(s.queries.deleteSubscriptionByUserID, userID)\n\treturn err\n}\n\n// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period.\nfunc (s *Store) RemoveExpiredSubscriptions(expireAfter time.Duration) error {\n\treturn db.ExecTx(s.db, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix()); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := tx.Exec(s.queries.deleteSubscriptionTopicWithoutSubscription)\n\t\treturn err\n\t})\n}\n\n// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is\n// exported for testing purposes.\nfunc (s *Store) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error {\n\t_, err := s.db.Exec(s.queries.updateSubscriptionUpdatedAt, updatedAt, endpoint)\n\treturn err\n}\n\n// Close closes the underlying database connection.\nfunc (s *Store) Close() error {\n\treturn s.db.Close()\n}\n\nfunc subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) {\n\tsubscriptions := make([]*Subscription, 0)\n\tfor rows.Next() {\n\t\tvar id, endpoint, auth, p256dh, userID string\n\t\tif err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsubscriptions = append(subscriptions, &Subscription{\n\t\t\tID:       id,\n\t\t\tEndpoint: endpoint,\n\t\t\tAuth:     auth,\n\t\t\tP256dh:   p256dh,\n\t\t\tUserID:   userID,\n\t\t})\n\t}\n\treturn subscriptions, nil\n}\n"
  },
  {
    "path": "webpush/store_postgres.go",
    "content": "package webpush\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"heckel.io/ntfy/v2/db\"\n)\n\nconst (\n\tpostgresCreateTablesQuery = `\n\t\tCREATE TABLE IF NOT EXISTS webpush_subscription (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tendpoint TEXT NOT NULL UNIQUE,\n\t\t\tkey_auth TEXT NOT NULL,\n\t\t\tkey_p256dh TEXT NOT NULL,\n\t\t\tuser_id TEXT NOT NULL,\n\t\t\tsubscriber_ip TEXT NOT NULL,\n\t\t\tupdated_at BIGINT NOT NULL,\n\t\t\twarned_at BIGINT NOT NULL DEFAULT 0\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);\n\t\tCREATE TABLE IF NOT EXISTS webpush_subscription_topic (\n\t\t\tsubscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tPRIMARY KEY (subscription_id, topic)\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);\n\t\tCREATE TABLE IF NOT EXISTS schema_version (\n\t\t\tstore TEXT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t`\n\n\tpostgresSelectSubscriptionIDByEndpointQuery        = `SELECT id FROM webpush_subscription WHERE endpoint = $1`\n\tpostgresSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1`\n\tpostgresSelectSubscriptionsForTopicQuery           = `\n\t\tSELECT s.id, s.endpoint, s.key_auth, s.key_p256dh, s.user_id\n\t\tFROM webpush_subscription_topic st\n\t\tJOIN webpush_subscription s ON s.id = st.subscription_id\n\t\tWHERE st.topic = $1\n\t\tORDER BY s.endpoint\n\t`\n\tpostgresSelectSubscriptionsExpiringSoonQuery = `\n\t\tSELECT id, endpoint, key_auth, key_p256dh, user_id\n\t\tFROM webpush_subscription\n\t\tWHERE warned_at = 0 AND updated_at <= $1\n\t`\n\tpostgresUpsertSubscriptionQuery = `\n\t\tINSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n\t\tON CONFLICT (endpoint)\n\t\tDO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at\n\t\tRETURNING id\n\t`\n\tpostgresUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2`\n\tpostgresUpdateSubscriptionUpdatedAtQuery   = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2`\n\tpostgresDeleteSubscriptionByEndpointQuery  = `DELETE FROM webpush_subscription WHERE endpoint = $1`\n\tpostgresDeleteSubscriptionByUserIDQuery    = `DELETE FROM webpush_subscription WHERE user_id = $1`\n\tpostgresDeleteSubscriptionByAgeQuery       = `DELETE FROM webpush_subscription WHERE updated_at <= $1`\n\n\tpostgresInsertSubscriptionTopicQuery                    = `INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2)`\n\tpostgresDeleteSubscriptionTopicAllQuery                 = `DELETE FROM webpush_subscription_topic WHERE subscription_id = $1`\n\tpostgresDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)`\n)\n\n// PostgreSQL schema management queries\nconst (\n\tpgCurrentSchemaVersion           = 1\n\tpostgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)`\n\tpostgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'`\n)\n\n// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool.\nfunc NewPostgresStore(d *db.DB) (*Store, error) {\n\tif err := setupPostgres(d.Primary()); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Store{\n\t\tdb: d,\n\t\tqueries: queries{\n\t\t\tselectSubscriptionIDByEndpoint:             postgresSelectSubscriptionIDByEndpointQuery,\n\t\t\tselectSubscriptionCountBySubscriberIP:      postgresSelectSubscriptionCountBySubscriberIPQuery,\n\t\t\tselectSubscriptionsForTopic:                postgresSelectSubscriptionsForTopicQuery,\n\t\t\tselectSubscriptionsExpiringSoon:            postgresSelectSubscriptionsExpiringSoonQuery,\n\t\t\tupsertSubscription:                         postgresUpsertSubscriptionQuery,\n\t\t\tupdateSubscriptionWarningSent:              postgresUpdateSubscriptionWarningSentQuery,\n\t\t\tupdateSubscriptionUpdatedAt:                postgresUpdateSubscriptionUpdatedAtQuery,\n\t\t\tdeleteSubscriptionByEndpoint:               postgresDeleteSubscriptionByEndpointQuery,\n\t\t\tdeleteSubscriptionByUserID:                 postgresDeleteSubscriptionByUserIDQuery,\n\t\t\tdeleteSubscriptionByAge:                    postgresDeleteSubscriptionByAgeQuery,\n\t\t\tinsertSubscriptionTopic:                    postgresInsertSubscriptionTopicQuery,\n\t\t\tdeleteSubscriptionTopicAll:                 postgresDeleteSubscriptionTopicAllQuery,\n\t\t\tdeleteSubscriptionTopicWithoutSubscription: postgresDeleteSubscriptionTopicWithoutSubscriptionQuery,\n\t\t},\n\t}, nil\n}\n\nfunc setupPostgres(d *sql.DB) error {\n\tvar schemaVersion int\n\terr := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)\n\tif err != nil {\n\t\treturn setupNewPostgres(d)\n\t}\n\tif schemaVersion > pgCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, pgCurrentSchemaVersion)\n\t}\n\treturn nil\n}\n\nfunc setupNewPostgres(d *sql.DB) error {\n\treturn db.ExecTx(d, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(postgresCreateTablesQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "webpush/store_sqlite.go",
    "content": "package webpush\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t_ \"github.com/mattn/go-sqlite3\" // SQLite driver\n\n\t\"heckel.io/ntfy/v2/db\"\n)\n\nconst (\n\tsqliteCreateTablesQuery = `\n\t\tCREATE TABLE IF NOT EXISTS subscription (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\tendpoint TEXT NOT NULL,\n\t\t\tkey_auth TEXT NOT NULL,\n\t\t\tkey_p256dh TEXT NOT NULL,\n\t\t\tuser_id TEXT NOT NULL,\t\t\n\t\t\tsubscriber_ip TEXT NOT NULL,\n\t\t\tupdated_at INT NOT NULL,\n\t\t\twarned_at INT NOT NULL DEFAULT 0\n\t\t);\n\t\tCREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);\n\t\tCREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);\n\t\tCREATE TABLE IF NOT EXISTS subscription_topic (\n\t\t\tsubscription_id TEXT NOT NULL,\n\t\t\ttopic TEXT NOT NULL,\n\t\t\tPRIMARY KEY (subscription_id, topic),\n\t\t\tFOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);\n\t\tCREATE TABLE IF NOT EXISTS schemaVersion (\n\t\t\tid INT PRIMARY KEY,\n\t\t\tversion INT NOT NULL\n\t\t);\n\t`\n\tsqliteBuiltinStartupQueries = `\n\t\tPRAGMA foreign_keys = ON;\n\t`\n\n\tsqliteSelectSubscriptionIDByEndpointQuery        = `SELECT id FROM subscription WHERE endpoint = ?`\n\tsqliteSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`\n\tsqliteSelectSubscriptionsForTopicQuery           = `\n\t\tSELECT id, endpoint, key_auth, key_p256dh, user_id\n\t\tFROM subscription_topic st\n\t\tJOIN subscription s ON s.id = st.subscription_id\n\t\tWHERE st.topic = ?\n\t\tORDER BY endpoint\n\t`\n\tsqliteSelectSubscriptionsExpiringSoonQuery = `\n\t\tSELECT id, endpoint, key_auth, key_p256dh, user_id \n\t\tFROM subscription \n\t\tWHERE warned_at = 0 AND updated_at <= ?\n\t`\n\tsqliteUpsertSubscriptionQuery = `\n\t\tINSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (endpoint)\n\t\tDO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at\n\t\tRETURNING id\n\t`\n\tsqliteUpdateSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`\n\tsqliteUpdateSubscriptionUpdatedAtQuery   = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?`\n\tsqliteDeleteSubscriptionByEndpointQuery  = `DELETE FROM subscription WHERE endpoint = ?`\n\tsqliteDeleteSubscriptionByUserIDQuery    = `DELETE FROM subscription WHERE user_id = ?`\n\tsqliteDeleteSubscriptionByAgeQuery       = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!\n\n\tsqliteInsertSubscriptionTopicQuery                    = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`\n\tsqliteDeleteSubscriptionTopicAllQuery                 = `DELETE FROM subscription_topic WHERE subscription_id = ?`\n\tsqliteDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`\n)\n\n// SQLite schema management queries\nconst (\n\tsqliteCurrentSchemaVersion     = 1\n\tsqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`\n\tsqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`\n)\n\n// NewSQLiteStore creates a new SQLite-backed web push store.\nfunc NewSQLiteStore(filename, startupQueries string) (*Store, error) {\n\td, err := sql.Open(\"sqlite3\", filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := setupSQLite(d); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := runSQLiteStartupQueries(d, startupQueries); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Store{\n\t\tdb: db.New(&db.Host{DB: d}, nil),\n\t\tqueries: queries{\n\t\t\tselectSubscriptionIDByEndpoint:             sqliteSelectSubscriptionIDByEndpointQuery,\n\t\t\tselectSubscriptionCountBySubscriberIP:      sqliteSelectSubscriptionCountBySubscriberIPQuery,\n\t\t\tselectSubscriptionsForTopic:                sqliteSelectSubscriptionsForTopicQuery,\n\t\t\tselectSubscriptionsExpiringSoon:            sqliteSelectSubscriptionsExpiringSoonQuery,\n\t\t\tupsertSubscription:                         sqliteUpsertSubscriptionQuery,\n\t\t\tupdateSubscriptionWarningSent:              sqliteUpdateSubscriptionWarningSentQuery,\n\t\t\tupdateSubscriptionUpdatedAt:                sqliteUpdateSubscriptionUpdatedAtQuery,\n\t\t\tdeleteSubscriptionByEndpoint:               sqliteDeleteSubscriptionByEndpointQuery,\n\t\t\tdeleteSubscriptionByUserID:                 sqliteDeleteSubscriptionByUserIDQuery,\n\t\t\tdeleteSubscriptionByAge:                    sqliteDeleteSubscriptionByAgeQuery,\n\t\t\tinsertSubscriptionTopic:                    sqliteInsertSubscriptionTopicQuery,\n\t\t\tdeleteSubscriptionTopicAll:                 sqliteDeleteSubscriptionTopicAllQuery,\n\t\t\tdeleteSubscriptionTopicWithoutSubscription: sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery,\n\t\t},\n\t}, nil\n}\n\nfunc setupSQLite(db *sql.DB) error {\n\tvar schemaVersion int\n\tif err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {\n\t\treturn setupNewSQLite(db)\n\t} else if schemaVersion > sqliteCurrentSchemaVersion {\n\t\treturn fmt.Errorf(\"unexpected schema version: version %d is higher than current version %d\", schemaVersion, sqliteCurrentSchemaVersion)\n\t}\n\treturn nil\n}\n\nfunc setupNewSQLite(sqlDB *sql.DB) error {\n\treturn db.ExecTx(sqlDB, func(tx *sql.Tx) error {\n\t\tif _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {\n\tif _, err := db.Exec(startupQueries); err != nil {\n\t\treturn err\n\t}\n\tif _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "webpush/store_test.go",
    "content": "package webpush_test\n\nimport (\n\t\"fmt\"\n\t\"net/netip\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\tdbtest \"heckel.io/ntfy/v2/db/test\"\n\t\"heckel.io/ntfy/v2/webpush\"\n)\n\nconst testWebPushEndpoint = \"https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF\"\n\nfunc forEachBackend(t *testing.T, f func(t *testing.T, store *webpush.Store)) {\n\tt.Run(\"sqlite\", func(t *testing.T) {\n\t\tstore, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), \"webpush.db\"), \"\")\n\t\trequire.Nil(t, err)\n\t\tt.Cleanup(func() { store.Close() })\n\t\tf(t, store)\n\t})\n\tt.Run(\"postgres\", func(t *testing.T) {\n\t\ttestDB := dbtest.CreateTestPostgres(t)\n\t\tstore, err := webpush.NewPostgresStore(testDB)\n\t\trequire.Nil(t, err)\n\t\tf(t, store)\n\t})\n}\n\nfunc TestStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"test-topic\", \"mytopic\"}))\n\n\t\tsubs, err := store.SubscriptionsForTopic(\"test-topic\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, subs[0].Endpoint, testWebPushEndpoint)\n\t\trequire.Equal(t, subs[0].P256dh, \"p256dh-key\")\n\t\trequire.Equal(t, subs[0].Auth, \"auth-key\")\n\t\trequire.Equal(t, subs[0].UserID, \"u_1234\")\n\n\t\tsubs2, err := store.SubscriptionsForTopic(\"mytopic\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs2, 1)\n\t\trequire.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)\n\t})\n}\n\nfunc TestStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert 10 subscriptions with the same IP address\n\t\tfor i := 0; i < 10; i++ {\n\t\t\tendpoint := fmt.Sprintf(testWebPushEndpoint+\"%d\", i)\n\t\t\trequire.Nil(t, store.UpsertSubscription(endpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"test-topic\", \"mytopic\"}))\n\t\t}\n\n\t\t// Another one for the same endpoint should be fine\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"0\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"test-topic\", \"mytopic\"}))\n\n\t\t// But with a different endpoint it should fail\n\t\trequire.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+\"11\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"test-topic\", \"mytopic\"}))\n\n\t\t// But with a different IP address it should be fine again\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"99\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"9.9.9.9\"), []string{\"test-topic\", \"mytopic\"}))\n\t})\n}\n\nfunc TestStoreUpsertSubscriptionUpdateTopics(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics, and another with one topic\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"0\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"1\", \"auth-key\", \"p256dh-key\", \"\", netip.MustParseAddr(\"9.9.9.9\"), []string{\"topic1\"}))\n\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 2)\n\t\trequire.Equal(t, testWebPushEndpoint+\"0\", subs[0].Endpoint)\n\t\trequire.Equal(t, testWebPushEndpoint+\"1\", subs[1].Endpoint)\n\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic2\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, testWebPushEndpoint+\"0\", subs[0].Endpoint)\n\n\t\t// Update the first subscription to have only one topic\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"0\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\"}))\n\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 2)\n\t\trequire.Equal(t, testWebPushEndpoint+\"0\", subs[0].Endpoint)\n\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic2\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 0)\n\t})\n}\n\nfunc TestStoreUpsertSubscriptionUpdateFields(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert a subscription\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\"}))\n\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, \"auth-key\", subs[0].Auth)\n\t\trequire.Equal(t, \"p256dh-key\", subs[0].P256dh)\n\t\trequire.Equal(t, \"u_1234\", subs[0].UserID)\n\n\t\t// Re-upsert the same endpoint with different auth, p256dh, and userID\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"new-auth\", \"new-p256dh\", \"u_5678\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\"}))\n\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, testWebPushEndpoint, subs[0].Endpoint)\n\t\trequire.Equal(t, \"new-auth\", subs[0].Auth)\n\t\trequire.Equal(t, \"new-p256dh\", subs[0].P256dh)\n\t\trequire.Equal(t, \"u_5678\", subs[0].UserID)\n\t})\n}\n\nfunc TestStoreRemoveByUserIDMultiple(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert two subscriptions for u_1234 and one for u_5678\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"0\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\"}))\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"1\", \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\"}))\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint+\"2\", \"auth-key\", \"p256dh-key\", \"u_5678\", netip.MustParseAddr(\"9.9.9.9\"), []string{\"topic1\"}))\n\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 3)\n\n\t\t// Remove all subscriptions for u_1234\n\t\trequire.Nil(t, store.RemoveSubscriptionsByUserID(\"u_1234\"))\n\n\t\t// Only u_5678's subscription should remain\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, testWebPushEndpoint+\"2\", subs[0].Endpoint)\n\t\trequire.Equal(t, \"u_5678\", subs[0].UserID)\n\t})\n}\n\nfunc TestStoreRemoveByEndpoint(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\n\t\t// And remove it again\n\t\trequire.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 0)\n\t})\n}\n\nfunc TestStoreRemoveByUserID(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\n\t\t// And remove it again\n\t\trequire.Nil(t, store.RemoveSubscriptionsByUserID(\"u_1234\"))\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 0)\n\t})\n}\n\nfunc TestStoreRemoveByUserIDEmpty(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\trequire.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID(\"\"))\n\t})\n}\n\nfunc TestStoreExpiryWarningSent(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\n\t\t// Set updated_at to the past so it shows up as expiring\n\t\trequire.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix()))\n\n\t\t// Verify subscription appears in expiring list (warned_at == 0)\n\t\tsubs, err := store.SubscriptionsExpiring(7 * 24 * time.Hour)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, testWebPushEndpoint, subs[0].Endpoint)\n\n\t\t// Mark them as warning sent\n\t\trequire.Nil(t, store.MarkExpiryWarningSent(subs))\n\n\t\t// Verify subscription no longer appears in expiring list (warned_at > 0)\n\t\tsubs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 0)\n\t})\n}\n\nfunc TestStoreExpiring(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\n\t\t// Fake-mark them as soon-to-expire\n\t\trequire.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix()))\n\n\t\t// Should not be cleaned up yet\n\t\trequire.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour))\n\n\t\t// Run expiration\n\t\tsubs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour)\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\t\trequire.Equal(t, testWebPushEndpoint, subs[0].Endpoint)\n\t})\n}\n\nfunc TestStoreRemoveExpired(t *testing.T) {\n\tforEachBackend(t, func(t *testing.T, store *webpush.Store) {\n\t\t// Insert subscription with two topics\n\t\trequire.Nil(t, store.UpsertSubscription(testWebPushEndpoint, \"auth-key\", \"p256dh-key\", \"u_1234\", netip.MustParseAddr(\"1.2.3.4\"), []string{\"topic1\", \"topic2\"}))\n\t\tsubs, err := store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 1)\n\n\t\t// Fake-mark them as expired\n\t\trequire.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-10*24*time.Hour).Unix()))\n\n\t\t// Run expiration\n\t\trequire.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour))\n\n\t\t// List again, should be 0\n\t\tsubs, err = store.SubscriptionsForTopic(\"topic1\")\n\t\trequire.Nil(t, err)\n\t\trequire.Len(t, subs, 0)\n\t})\n}\n"
  },
  {
    "path": "webpush/types.go",
    "content": "package webpush\n\nimport \"heckel.io/ntfy/v2/log\"\n\n// Subscription represents a web push subscription.\ntype Subscription struct {\n\tID       string\n\tEndpoint string\n\tAuth     string\n\tP256dh   string\n\tUserID   string\n}\n\n// Context returns the logging context for the subscription.\nfunc (w *Subscription) Context() log.Context {\n\treturn map[string]any{\n\t\t\"web_push_subscription_id\":       w.ID,\n\t\t\"web_push_subscription_user_id\":  w.UserID,\n\t\t\"web_push_subscription_endpoint\": w.Endpoint,\n\t}\n}\n"
  }
]