1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2024-12-14 11:47:33 +00:00

Merge branch 'main' into zhzy0077-patch-1

This commit is contained in:
binwiederhier 2024-03-07 10:38:19 -05:00
commit 17709f2fb7
127 changed files with 7025 additions and 2272 deletions

View file

@ -1,30 +1,24 @@
name: build name: build
on: [push, pull_request] on: [ push, pull_request ]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- - name: Checkout code
name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- - name: Install Go
name: Install Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.20.x' go-version: '1.22.x'
- - name: Install node
name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: './web/package-lock.json' cache-dependency-path: './web/package-lock.json'
- - name: Install dependencies
name: Install dependencies
run: make build-deps-ubuntu run: make build-deps-ubuntu
- - name: Build all the things
name: Build all the things
run: make build run: make build
- - name: Print build results and checksums
name: Print build results and checksums
run: make cli-build-results run: make cli-build-results

View file

@ -7,35 +7,28 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- - name: Checkout code
name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- - name: Install Go
name: Install Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.20.x' go-version: '1.22.x'
- - name: Install node
name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: './web/package-lock.json' cache-dependency-path: './web/package-lock.json'
- - name: Docker login
name: Docker login
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} password: ${{ secrets.DOCKER_HUB_TOKEN }}
- - name: Install dependencies
name: Install dependencies
run: make build-deps-ubuntu run: make build-deps-ubuntu
- - name: Build and publish
name: Build and publish
run: make release run: make release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Print build results and checksums
name: Print build results and checksums
run: make cli-build-results run: make cli-build-results

View file

@ -1,39 +1,30 @@
name: test name: test
on: [push, pull_request] on: [ push, pull_request ]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- - name: Checkout code
name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- - name: Install Go
name: Install Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.20.x' go-version: '1.22.x'
- - name: Install node
name: Install node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: './web/package-lock.json' cache-dependency-path: './web/package-lock.json'
- - name: Install dependencies
name: Install dependencies
run: make build-deps-ubuntu run: make build-deps-ubuntu
- - name: Build docs (required for tests)
name: Build docs (required for tests)
run: make docs run: make docs
- - name: Build web app (required for tests)
name: Build web app (required for tests)
run: make web run: make web
- - name: Run tests, formatting, vetting and linting
name: Run tests, formatting, vetting and linting
run: make check run: make check
- - name: Run coverage
name: Run coverage
run: make coverage run: make coverage
- - name: Upload coverage to codecov.io
name: Upload coverage to codecov.io
run: make coverage-upload run: make coverage-upload

3
.gitignore vendored
View file

@ -13,4 +13,5 @@ secrets/
node_modules/ node_modules/
.DS_Store .DS_Store
__pycache__ __pycache__
web/dev-dist/ web/dev-dist/
venv/

View file

@ -164,14 +164,14 @@ dockers:
- image_templates: - image_templates:
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
use: buildx use: buildx
dockerfile: Dockerfile dockerfile: Dockerfile-arm
goarch: arm64 goarch: arm64
build_flag_templates: build_flag_templates:
- "--platform=linux/arm64/v8" - "--platform=linux/arm64/v8"
- image_templates: - image_templates:
- &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7" - &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7"
use: buildx use: buildx
dockerfile: Dockerfile dockerfile: Dockerfile-arm
goarch: arm goarch: arm
goarm: 7 goarm: 7
build_flag_templates: build_flag_templates:
@ -179,7 +179,7 @@ dockers:
- image_templates: - image_templates:
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6" - &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
use: buildx use: buildx
dockerfile: Dockerfile dockerfile: Dockerfile-arm
goarch: arm goarch: arm
goarm: 6 goarm: 6
build_flag_templates: build_flag_templates:

View file

@ -9,6 +9,8 @@ LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy" LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
RUN apk add --no-cache tzdata \
&& adduser -D -u 1000 ntfy
COPY ntfy /usr/bin COPY ntfy /usr/bin
EXPOSE 80/tcp EXPOSE 80/tcp

19
Dockerfile-arm Normal file
View file

@ -0,0 +1,19 @@
FROM alpine
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
LABEL org.opencontainers.image.url="https://ntfy.sh/"
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
# Alpine does not support adding "tzdata" on ARM anymore, see
# https://github.com/binwiederhier/ntfy/issues/894
RUN adduser -D -u 1000 ntfy
COPY ntfy /usr/bin
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View file

@ -1,14 +1,20 @@
FROM golang:1.20-bullseye as builder FROM golang:1.21-bullseye as builder
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=unknown ARG COMMIT=unknown
ARG NODE_MAJOR=18
RUN apt-get update RUN apt-get update && apt-get install -y \
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash build-essential ca-certificates curl gnupg \
RUN apt-get install -y \ && mkdir -p /etc/apt/keyrings \
build-essential \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
nodejs \ && 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 \
python3-pip && apt-get update \
&& apt-get install -y \
python3-pip \
python3-venv \
nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
ADD Makefile . ADD Makefile .
@ -19,7 +25,7 @@ RUN make docs-deps
ADD ./mkdocs.yml . ADD ./mkdocs.yml .
ADD ./docs ./docs ADD ./docs ./docs
RUN make docs-build RUN make docs-build
# web # web
ADD ./web/package.json ./web/package-lock.json ./web/ ADD ./web/package.json ./web/package-lock.json ./web/
RUN make web-deps RUN make web-deps
@ -47,6 +53,7 @@ LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy" LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
RUN adduser -D -u 1000 ntfy
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
EXPOSE 80/tcp EXPOSE 80/tcp

View file

@ -1,4 +1,6 @@
MAKEFLAGS := --jobs=1 MAKEFLAGS := --jobs=1
PYTHON := python3
PIP := pip3
VERSION := $(shell git describe --tag) VERSION := $(shell git describe --tag)
COMMIT := $(shell git rev-parse --short HEAD) COMMIT := $(shell git rev-parse --short HEAD)
@ -39,8 +41,8 @@ help:
@echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app" @echo " make web-build - Actually build the web app"
@echo " make web-lint - Run eslint on the web app" @echo " make web-lint - Run eslint on the web app"
@echo " make web-format - Run prettier on the web app" @echo " make web-fmt - Run prettier on the web app"
@echo " make web-format-check - Run prettier on the web app, but don't change anything" @echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
@echo @echo
@echo "Build documentation:" @echo "Build documentation:"
@echo " make docs - Build the documentation" @echo " make docs - Build the documentation"
@ -95,6 +97,7 @@ docker-dev:
--build-arg COMMIT=$(COMMIT) \ --build-arg COMMIT=$(COMMIT) \
./ ./
# Ubuntu-specific # Ubuntu-specific
build-deps-ubuntu: build-deps-ubuntu:
@ -103,32 +106,27 @@ build-deps-ubuntu:
curl \ curl \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \ gcc-arm-linux-gnueabi \
python3 \
python3-venv \
jq jq
which pip3 || sudo apt-get install -y python3-pip which pip3 || sudo apt-get install -y python3-pip
# Documentation # Documentation
docs: docs-deps docs-build docs: docs-deps docs-build
docs-build: .PHONY docs-venv: .PHONY
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ $(PYTHON) -m venv ./venv
if which python3.8; then \
echo "python3.8 $(shell which mkdocs) build"; \
python3.8 $(shell which mkdocs) build; \
else \
echo "ERROR: Python version too low. mkdocs-material needs >= 3.8"; \
exit 1; \
fi; \
else \
echo "mkdocs build"; \
mkdocs build; \
fi
docs-deps: .PHONY docs-build: docs-venv
pip3 install -r requirements.txt (. venv/bin/activate && $(PYTHON) -m mkdocs build)
docs-deps: docs-venv
(. venv/bin/activate && $(PIP) install -r requirements.txt)
docs-deps-update: .PHONY docs-deps-update: .PHONY
pip3 install -r requirements.txt --upgrade (. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
# Web app # Web app
@ -151,10 +149,10 @@ web-deps:
web-deps-update: web-deps-update:
cd web && npm update cd web && npm update
web-format: web-fmt:
cd web && npm run format cd web && npm run format
web-format-check: web-fmt-check:
cd web && npm run format:check cd web && npm run format:check
web-lint: web-lint:
@ -248,7 +246,7 @@ cli-build-results:
# Test/check targets # Test/check targets
check: test web-format-check fmt-check vet web-lint lint staticcheck check: test web-fmt-check fmt-check vet web-lint lint staticcheck
test: .PHONY test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
@ -275,7 +273,7 @@ coverage-upload:
# Lint/formatting targets # Lint/formatting targets
fmt: fmt: web-fmt
gofmt -s -w . gofmt -s -w .
fmt-check: fmt-check:

View file

@ -2,7 +2,7 @@
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST # ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest) [![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)
[![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy) [![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy/v2)
[![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions) [![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions)
[![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy) [![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy) [![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy)
@ -18,7 +18,7 @@ notification service. With ntfy, you can **send notifications to your phone or d
**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 **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
so since ntfy is open source. so since ntfy is open source.
You 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) You 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)
available 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/), available 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/),
as 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). as 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).
@ -31,7 +31,10 @@ as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) a
</p> </p>
## [ntfy Pro](https://ntfy.sh/app) 💸 🎉 ## [ntfy Pro](https://ntfy.sh/app) 💸 🎉
I 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 ntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $3.33/month** (if you use promo code `MYTOPIC`, limited time only). You can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy). I would be very humbled by your sponsorship. ❤️ I 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
ntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $5/month**.
You can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy).
I would be very humbled by your sponsorship. ❤️
## **[Documentation](https://ntfy.sh/docs/)** ## **[Documentation](https://ntfy.sh/docs/)**
@ -41,7 +44,7 @@ I now offer paid plans for [ntfy.sh](https://ntfy.sh/) if you don't want to self
[Install / Self-hosting](https://ntfy.sh/docs/install/) | [Install / Self-hosting](https://ntfy.sh/docs/install/) |
[Building](https://ntfy.sh/docs/develop/) [Building](https://ntfy.sh/docs/develop/)
## Chat / forum ## Chat/forum
There 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 There 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
works best for you: works best for you:
@ -50,13 +53,13 @@ works best for you:
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_) * [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs * [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
## Announcements / beta testers ## Announcements/beta testers
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements) For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas, topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
join Discord/Matrix (I'll eventually make a testing channel in Google Play). join Discord/Matrix (I'll eventually make a testing channel in Google Play).
## Contributing ## Contributing
I welcome any and all contributions. Just create a PR or an issue. For larger features/ideas, please reach out I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/) on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/). [Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
@ -143,6 +146,28 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/KevinWang15"><img src="https://github.com/KevinWang15.png" width="40px" /></a> <a href="https://github.com/KevinWang15"><img src="https://github.com/KevinWang15.png" width="40px" /></a>
<a href="https://github.com/darkmattercoder"><img src="https://github.com/darkmattercoder.png" width="40px" /></a> <a href="https://github.com/darkmattercoder"><img src="https://github.com/darkmattercoder.png" width="40px" /></a>
<a href="https://github.com/bmcgonag"><img src="https://github.com/bmcgonag.png" width="40px" /></a> <a href="https://github.com/bmcgonag"><img src="https://github.com/bmcgonag.png" width="40px" /></a>
<a href="https://github.com/skorokithakis"><img src="https://github.com/skorokithakis.png" width="40px" /></a>
<a href="https://github.com/eenturk"><img src="https://github.com/eenturk.png" width="40px" /></a>
<a href="https://github.com/spirossi"><img src="https://github.com/spirossi.png" width="40px" /></a>
<a href="https://github.com/teomarcdhio"><img src="https://github.com/teomarcdhio.png" width="40px" /></a>
<a href="https://github.com/MarcMichalsky"><img src="https://github.com/MarcMichalsky.png" width="40px" /></a>
<a href="https://github.com/LuckVintage"><img src="https://github.com/LuckVintage.png" width="40px" /></a>
<a href="https://github.com/spartan"><img src="https://github.com/spartan.png" width="40px" /></a>
<a href="https://github.com/alexandzors"><img src="https://github.com/alexandzors.png" width="40px" /></a>
<a href="https://github.com/dkramer95"><img src="https://github.com/dkramer95.png" width="40px" /></a>
<a href="https://github.com/YezGotIt"><img src="https://github.com/YezGotIt.png" width="40px" /></a>
<a href="https://github.com/thomasskou"><img src="https://github.com/thomasskou.png" width="40px" /></a>
<a href="https://github.com/surfernv"><img src="https://github.com/surfernv.png" width="40px" /></a>
<a href="https://github.com/richardleach"><img src="https://github.com/richardleach.png" width="40px" /></a>
<a href="https://github.com/bear"><img src="https://github.com/bear.png" width="40px" /></a>
<a href="https://github.com/cminter"><img src="https://github.com/cminter.png" width="40px" /></a>
<a href="https://github.com/bahur142"><img src="https://github.com/bahur142.png" width="40px" /></a>
<a href="https://github.com/pgwiebes"><img src="https://github.com/pgwiebes.png" width="40px" /></a>
<a href="https://github.com/ralhei"><img src="https://github.com/ralhei.png" width="40px" /></a>
<a href="https://github.com/TechMDW"><img src="https://github.com/TechMDW.png" width="40px" /></a>
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/), I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project: and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
@ -160,7 +185,7 @@ _Please be sure to read the complete [Code of Conduct](CODE_OF_CONDUCT.md)._
Made with ❤️ by [Philipp C. Heckel](https://heckel.io). Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
Third party libraries and resources: Third-party libraries and resources:
* [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI * [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds * [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds * [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds

View file

@ -7,8 +7,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"

View file

@ -3,9 +3,9 @@ package client_test
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"os" "os"
"testing" "testing"
"time" "time"

View file

@ -2,7 +2,7 @@ package client_test
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"

View file

@ -2,7 +2,7 @@ package client
import ( import (
"fmt" "fmt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/http" "net/http"
"strings" "strings"
"time" "time"

View file

@ -6,8 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
func init() { func init() {

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"testing" "testing"
) )

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"os" "os"
"regexp" "regexp"
) )

View file

@ -4,8 +4,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"os" "os"
"strings" "strings"
"testing" "testing"

View file

@ -5,7 +5,7 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"os" "os"
) )

View file

@ -4,9 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"os" "os"
"os/exec" "os/exec"

View file

@ -3,8 +3,8 @@ package cmd
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"

View file

@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"io/fs" "io/fs"
"math" "math"
"net" "net"
@ -17,12 +17,12 @@ import (
"syscall" "syscall"
"time" "time"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
func init() { func init() {

View file

@ -12,15 +12,11 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
func init() {
rand.Seed(time.Now().UnixMilli())
}
func TestCLI_Serve_Unix_Curl(t *testing.T) { func TestCLI_Serve_Unix_Curl(t *testing.T) {
sockFile := filepath.Join(t.TempDir(), "ntfy.sock") sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system

View file

@ -4,9 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"

View file

@ -6,8 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
func init() { func init() {

View file

@ -3,8 +3,8 @@ package cmd
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"testing" "testing"
) )

View file

@ -6,8 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
"time" "time"
) )

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"regexp" "regexp"
"testing" "testing"
) )

View file

@ -6,13 +6,13 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"os" "os"
"strings" "strings"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
const ( const (
@ -198,7 +198,6 @@ func execUserAdd(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
password = p password = p
} }
if err := manager.AddUser(username, password, role); err != nil { if err := manager.AddUser(username, password, role); err != nil {
@ -343,6 +342,8 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
password, err := util.ReadPassword(c.App.Reader) password, err := util.ReadPassword(c.App.Reader)
if err != nil { if err != nil {
return "", err return "", err
} else if len(password) == 0 {
return "", errors.New("password cannot be empty")
} }
fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25)) fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25))
confirm, err := util.ReadPassword(c.App.Reader) confirm, err := util.ReadPassword(c.App.Reader)

View file

@ -3,9 +3,9 @@ package cmd
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/test" "heckel.io/ntfy/v2/test"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"

View file

@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
) )
func TestCLI_WebPush_GenerateKeys(t *testing.T) { func TestCLI_WebPush_GenerateKeys(t *testing.T) {

View file

@ -24,7 +24,7 @@ get a list of [command line options](#command-line-options).
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http` The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features. and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
Here are a few working sample configs: Here are a few working sample configs using a `/etc/ntfy/server.yml` file:
=== "server.yml (HTTP-only, with cache + attachments)" === "server.yml (HTTP-only, with cache + attachments)"
``` yaml ``` yaml
@ -44,6 +44,14 @@ Here are a few working sample configs:
attachment-cache-dir: "/var/cache/ntfy/attachments" attachment-cache-dir: "/var/cache/ntfy/attachments"
``` ```
=== "server.yml (behind proxy, with cache + attachments)"
``` yaml
base-url: "http://ntfy.example.com"
listen-http: ":2586"
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
```
=== "server.yml (ntfy.sh config)" === "server.yml (ntfy.sh config)"
``` yaml ``` yaml
# All the things: Behind a proxy, Firebase, cache, attachments, # All the things: Behind a proxy, Firebase, cache, attachments,
@ -65,6 +73,58 @@ Here are a few working sample configs:
keepalive-interval: "45s" keepalive-interval: "45s"
``` ```
Alternatively, you can also use command line arguments or environment variables to configure the server. Here's an example
using Docker Compose (i.e. `docker-compose.yml`):
=== "Docker Compose (w/ auth, cache, attachments)"
``` yaml
version: '3'
services:
ntfy:
image: binwiederhier/ntfy
restart: unless-stopped
environment:
NTFY_BASE_URL: http://ntfy.example.com
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true
volumes:
- ./:/var/lib/ntfy
ports:
- 80:80
command: serve
```
=== "Docker Compose (w/ auth, cache, web push, iOS)"
``` yaml
version: '3'
services:
ntfy:
image: binwiederhier/ntfy
restart: unless-stopped
environment:
NTFY_BASE_URL: http://ntfy.example.com
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true
NTFY_UPSTREAM_BASE_URL: https://ntfy.sh
NTFY_WEB_PUSH_PUBLIC_KEY: <public_key>
NTFY_WEB_PUSH_PRIVATE_KEY: <private_key>
NTFY_WEB_PUSH_FILE: /var/lib/ntfy/webpush.db
NTFY_WEB_PUSH_EMAIL_ADDRESS: <email>
volumes:
- ./:/var/lib/ntfy
ports:
- 8093:80
command: serve
```
## Message cache ## Message cache
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
@ -344,10 +404,10 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
``` ```
### Example: UnifiedPush ### Example: UnifiedPush
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) [UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages. has anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages.
The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details. **[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.
To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either
allow anonymous write access for the entire prefix or explicitly per topic: allow anonymous write access for the entire prefix or explicitly per topic:
@ -458,6 +518,31 @@ $ dig A mx1.ntfy.sh +short
3.139.215.220 3.139.215.220
``` ```
### Local-only email
If you want to send emails from an internal service on the same network as your ntfy instance, you do not need to
worry about DNS records at all. Define a port for the SMTP server and pick an SMTP server domain (can be
anything).
=== "/etc/ntfy/server.yml"
``` yaml
smtp-server-listen: ":25"
smtp-server-domain: "example.com"
smtp-server-addr-prefix: "ntfy-" # optional
```
Then, in the email settings of your internal service, set the SMTP server address to the IP address of your
ntfy instance. Set the port to the value you defined in `smtp-server-listen`. Leave any username and password
fields empty. In the "From" address, pick anything (e.g., "alerts@ntfy.sh"); the value doesn't matter.
In the "To" address, put in an email address that follows this pattern: `[topic]@[smtp-server-domain]` (or
`[smtp-server-addr-prefix][topic]@[smtp-server-domain]` if you set `smtp-server-addr-prefix`).
So if you used `example.com` as the SMTP server domain, and you want to send a message to the `email-alerts`
topic, set the "To" address to `email-alerts@example.com`. If the topic has access restrictions, you will need
to include an access token in the "To" address, such as `email-alerts+tk_AbC123dEf456@example.com`.
If the internal service lets you use define an email "Subject", it will become the title of the notification.
The body of the email will become the message of the notification.
## Behind a proxy (TLS, etc.) ## Behind a proxy (TLS, etc.)
!!! warning !!! warning
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
@ -649,8 +734,8 @@ or the root domain:
<VirtualHost *:80> <VirtualHost *:80>
ServerName ntfy.sh ServerName ntfy.sh
# Proxy connections to ntfy (requires "a2enmod proxy") # Proxy connections to ntfy (requires "a2enmod proxy proxy_http")
ProxyPass / http://127.0.0.1:2586/ ProxyPass / http://127.0.0.1:2586/ upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2586/ ProxyPassReverse / http://127.0.0.1:2586/
SetEnv proxy-nokeepalive 1 SetEnv proxy-nokeepalive 1
@ -658,19 +743,13 @@ or the root domain:
# Higher than the max message size of 4096 bytes # Higher than the max message size of 4096 bytes
LimitRequestBody 102400 LimitRequestBody 102400
# Enable mod_rewrite (requires "a2enmod rewrite")
RewriteEngine on
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
# it to work with curl without the annoying https:// prefix # it to work with curl without the annoying https:// prefix (requires "a2enmod alias")
RewriteCond %{REQUEST_METHOD} GET <If "%{REQUEST_METHOD} == 'GET'">
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L] RedirectMatch permanent "^/([-_A-Za-z0-9]{0,64})$" "https://%{SERVER_NAME}/$1"
</If>
</VirtualHost> </VirtualHost>
<VirtualHost *:443> <VirtualHost *:443>
@ -681,8 +760,8 @@ or the root domain:
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
# Proxy connections to ntfy (requires "a2enmod proxy") # Proxy connections to ntfy (requires "a2enmod proxy proxy_http")
ProxyPass / http://127.0.0.1:2586/ ProxyPass / http://127.0.0.1:2586/ upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2586/ ProxyPassReverse / http://127.0.0.1:2586/
SetEnv proxy-nokeepalive 1 SetEnv proxy-nokeepalive 1
@ -690,14 +769,7 @@ or the root domain:
# Higher than the max message size of 4096 bytes # Higher than the max message size of 4096 bytes
LimitRequestBody 102400 LimitRequestBody 102400
# Enable mod_rewrite (requires "a2enmod rewrite")
RewriteEngine on
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
</VirtualHost> </VirtualHost>
``` ```
@ -1006,20 +1078,23 @@ By default, ntfy puts almost all rate limits on the message publisher, e.g. numb
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
of a topic's subscriber, instead of the limits of the publisher.** of a topic's subscriber, instead of the limits of the publisher.**
If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed If subscriber-based rate limiting is enabled, **messages published on UnifiedPush topics** (topics starting with `up`, e.g. `up123456789012`)
to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
publishers (e.g. Matrix/Mastodon servers) are allowed to send.
Once enabled, a client may send a `Rate-Topics: <topic1>,<topic2>,...` header when subscribing to topics via Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits a "rate visitor", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor
to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic. requires **read-write permission** on the topic.
UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage` If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`
response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's
`visitor-message-daily-limit`. `visitor-message-daily-limit`.
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`. To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
!!! info
Due to a denial-of-service issue, support for the `Rate-Topics` header was removed entirely. This is unfortunate,
but subscriber-based rate limiting will still work for `up*` topics.
## Tuning for scale ## Tuning for scale
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@ -1160,10 +1235,10 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
## Health checks ## Health checks
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below. A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
If a non-200 HTTP status code is returned or if the returned `health` field is `false` the ntfy service should be considered as unhealthy. If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.
```json ```json
{"health":true} {"healthy":true}
``` ```
See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment. See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment.

View file

@ -1,4 +1,4 @@
# Deprecation notices # Deprecations and breaking changes
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated **removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
before the behavior is changed depends on the severity of the change, and how prominent the feature is. before the behavior is changed depends on the severity of the change, and how prominent the feature is.

View file

@ -363,7 +363,7 @@ To build your own version with Firebase, you must:
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) * And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
* Then run: * Then run:
``` ```
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk) # To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)
./gradlew assemblePlayRelease ./gradlew assemblePlayRelease
# To build a bundle .aab (app/play/release/*.aab) # To build a bundle .aab (app/play/release/*.aab)
@ -429,7 +429,7 @@ steps:
### XCode setup ### XCode setup
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the 1. Follow step 4 of [Add Firebase to your Apple project](https://firebase.google.com/docs/ios/setup) to install the
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging `firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
1. Similarly, install the SQLite.swift package dependency in XCode 1. Similarly, install the SQLite.swift package dependency in XCode
1. 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 1. 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

View file

@ -2,9 +2,9 @@
<!-- This file was generated by scripts/emoji-convert.sh --> <!-- This file was generated by scripts/emoji-convert.sh -->
You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically You can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically
converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the
[tagging and emojis page](../publish/#tags-emojis). [tagging and emojis page](publish.md#tags-emojis).
<table class="remove-md-box emoji-table"><tr> <table class="remove-md-box emoji-table"><tr>

View file

@ -135,6 +135,21 @@ You can send a message during a workflow run with curl. Here is an example sendi
${{ secrets.NTFY_URL }} ${{ secrets.NTFY_URL }}
``` ```
## Changedetection.io
ntfy is an excellent choice for getting notifications when a website has a change sent to your mobile (or desktop),
[changedetection.io](https://changedetection.io) or on GitHub ([dgtlmoon/changedetection.io](https://github.com/dgtlmoon/changedetection.io))
uses [apprise](https://github.com/caronc/apprise) library for notification integrations.
To add any ntfy(s) notification to a website change simply add the [ntfy style URL](https://github.com/caronc/apprise/wiki/Notify_ntfy)
to the notification list.
For example `ntfy://{topic}` or `ntfy://{user}:{password}@{host}:{port}/{topics}`
In your changedetection.io installation, click `Edit` > `Notifications` on a single website watch (or group) then add
the special ntfy Apprise Notification URL to the Notification List.
![ntfy alerts on website change](static/img/cdio-setup.jpg)
## Watchtower (shoutrrr) ## Watchtower (shoutrrr)
You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. [Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
@ -147,14 +162,23 @@ services:
image: containrrr/watchtower image: containrrr/watchtower
environment: environment:
- WATCHTOWER_NOTIFICATIONS=shoutrrr - WATCHTOWER_NOTIFICATIONS=shoutrrr
- WATCHTOWER_NOTIFICATION_SKIP_TITLE=True
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
``` ```
The 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.
Or, if you only want to send notifications using shoutrrr: Or, if you only want to send notifications using shoutrrr:
``` ```
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
``` ```
Authentication tokens are also supported via the generic webhook and authorization header using this url format (replace the domain, topic and token with your own):
```
generic+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`
```
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd ## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable --> <!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->

View file

@ -76,6 +76,18 @@ However, if you still want to disable it, you can do so with the `web-root: disa
Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without
a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless. a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless.
## If topic names are public, could I not just brute force them?
If you don't have [ACLs set up](config.md#access-control), the topic name is your password, it says so everywhere. If you
choose a easy-to-guess/dumb topic name, people will be able to guess it. If you choose a randomly generated topic name,
the topic is as good as a good password.
As for brute forcing: It's not possible to brute force a ntfy server for very long, as you'll get quickly rate limited.
In the default configuration, you'll be able to do 60 requests as a burst, and then 1 request per 10 seconds. Assuming you
choose 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
could do hundreds of requests per seconds (which you cannot), it would take many years to brute force a topic name.
For ntfy.sh, there's even a fail2ban in place which will ban your IP pretty quickly.
## Where can I donate? ## Where can I donate?
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier). I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much

View file

@ -1,6 +1,7 @@
import os import os
import shutil import shutil
def copy_fonts(config, **kwargs):
site_dir = config['site_dir'] def on_post_build(config, **kwargs):
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get')) site_dir = config["site_dir"]
shutil.copytree("docs/static/fonts", os.path.join(site_dir, "get"))

View file

@ -3,9 +3,9 @@ ntfy lets you **send push notifications to your phone or desktop via scripts fro
or POST requests. I use it to notify myself when scripts fail, or long-running commands complete. or POST requests. I use it to notify myself when scripts fail, or long-running commands complete.
## Step 1: Get the app ## Step 1: Get the app
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a> <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a> <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a> <a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid. To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid.
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just

View file

@ -14,14 +14,15 @@ We support amd64, armv7 and arm64.
1. Install ntfy using one of the methods described below 1. Install ntfy using one of the methods described below
2. 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)) 2. 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))
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (for the 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)) 3. 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))
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm). To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md) To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md)
for details). for details).
If you like video tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU). If you like tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU) on YouTube, or
It's short and to the point. _I am not affiliated with Kris, I just liked the video._ [Alex's Docker-based setup guide](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/). Both are great
resources to get started. _I am not affiliated with Kris or Alex, I just liked their video/post._
## Linux binaries ## Linux binaries
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
@ -29,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.tar.gz
tar zxvf ntfy_2.6.2_linux_amd64.tar.gz tar zxvf ntfy_2.8.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.6.2_linux_amd64/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.8.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_amd64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.tar.gz
tar zxvf ntfy_2.6.2_linux_armv6.tar.gz tar zxvf ntfy_2.8.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.8.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.tar.gz
tar zxvf ntfy_2.6.2_linux_armv7.tar.gz tar zxvf ntfy_2.8.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.8.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.tar.gz
tar zxvf ntfy_2.6.2_linux_arm64.tar.gz tar zxvf ntfy_2.8.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.6.2_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.8.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
@ -109,7 +110,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@ -117,7 +118,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@ -125,7 +126,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@ -133,7 +134,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@ -143,28 +144,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@ -194,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS ## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash ```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz > ntfy_2.6.2_darwin_all.tar.gz curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_darwin_all.tar.gz > ntfy_2.8.0_darwin_all.tar.gz
tar zxvf ntfy_2.6.2_darwin_all.tar.gz tar zxvf ntfy_2.8.0_darwin_all.tar.gz
sudo cp -a ntfy_2.6.2_darwin_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.8.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.6.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.8.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help ntfy --help
``` ```
@ -223,7 +224,7 @@ brew install ntfy
## Windows ## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_windows_amd64.zip), To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

View file

@ -23,6 +23,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python - [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier - [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server - [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
## Integration via HTTP/SMTP/etc. ## Integration via HTTP/SMTP/etc.
@ -56,7 +58,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS) - [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS)
- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart) - [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart)
- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go) - [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP) - [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
## CLIs + GUIs ## CLIs + GUIs
@ -80,7 +83,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell) - [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP) - [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP)
- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C) - [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C)
- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell)
- [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) - [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)
- [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python) - [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python)
- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python) - [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python)
@ -127,10 +129,35 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash) - [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP) - [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager - [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager
- [NtfyMe-Blender](https://github.com/NotNanook/NtfyMe-Blender) - Blender addon to send notifications to NtfyMe (Python)
- [ntfy-ios-url-share](https://www.icloud.com/shortcuts/be8a7f49530c45f79733cfe3e41887e6) - An iOS shortcut that lets you share URLs easily and quickly.
- [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.
- [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
- [RouterOS Scripts](https://git.eworm.de/cgit/routeros-scripts/about/) - a collection of scripts for MikroTik RouterOS
- [ntfy-android-builder](https://github.com/TheBlusky/ntfy-android-builder) - Script for building ntfy-android with custom Firebase configuration (Docker/Shell)
- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)
- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)
- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust)
## Blog + forum posts ## Blog + forum posts
- [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 - 9/2021 - [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
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
- [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
- [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
- [Deploy Ntfy using Docker](https://www.linkedin.com/pulse/deploy-ntfy-mohamed-sharfy/) - linkedin.com - 9/2023
- [Send Notifications With Ntfy for New WordPress Posts](https://www.activepieces.com/blog/ntfy-notifications-for-wordpress-new-posts) - activepieces.com - 9/2023
- [Get Ntfy Notifications About New Zendesk Ticket](https://www.activepieces.com/blog/ntfy-notifications-about-new-zendesk-tickets) - activepieces.com - 9/2023
- [Set reminder for recurring events using ntfy & Cron](https://www.youtube.com/watch?v=J3O4aQ-EcYk) - youtube.com - 9/2023
- [ntfy - Installation and full configuration setup](https://www.youtube.com/watch?v=QMy14rGmpFI) - youtube.com - 9/2023
- [How to install Ntfy.sh on Portainer / Docker Compose](https://www.youtube.com/watch?v=utD9GNbAwyg) - youtube.com - 9/2023
- [ntfy - Push-Benachrichtigungen // Push Notifications](https://www.youtube.com/watch?v=LE3vRPPqZOU) - youtube.com - 9/2023
- [Podman Update Notifications via Ntfy](https://rair.dev/podman-upadte-notifications-ntfy/) - rair.dev - 9/2023
- [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
- [NetworkChunk - how did I NOT know about this?](https://www.youtube.com/watch?v=poDIT2ruQ9M) ⭐ - youtube.com - 8/2023
- [NTFY - Command-Line Notifications](https://academy.networkchuck.com/blog/ntfy/) - academy.networkchuck.com - 8/2023
- [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
- [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
- [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023 - [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023
- [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 - [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
- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023 - [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023
@ -214,6 +241,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | | [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany | | [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany |
| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France | | [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France |
| [ntfy.fossman.de](https://ntfy.fossman.de/) | 🇩🇪 Germany |
Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability
and uptime of third party servers, so use of each server is **at your own discretion**. and uptime of third party servers, so use of each server is **at your own discretion**.

View file

@ -27,11 +27,12 @@ Be sure that in your selfhosted server:
* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**) * Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**)
* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to * Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to
## Firefox on Android not automatically subscribing to web push (see [#789](https://github.com/binwiederhier/ntfy/issues/789)) ## iOS app seeing "New message", but not real message content
ntfy defaults to web-push based subscriptions when installed as a [progressive web app](./subscribe/pwa.md). Firefox If you see `New message` notifications on iOS, your iPhone can likely not talk to your self-hosted server. Be sure that
Android has an [open bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1796434) where it reports the PWA mode incorrectly. your iOS device and your ntfy server are either on the same network, or that your phone can actually reach the server.
This causes ntfy to not automatically subscribe to web push, and requires you to go to the ntfy Settings page to enable
it manually. Turn on tracing/debugging on the server (via `log-level: trace` or `log-level: debug`, see [troubleshooting](troubleshooting.md)),
and read docs on [iOS instant notifications](https://docs.ntfy.sh/config/#ios-instant-notifications).
## Safari does not play sounds for web push notifications ## Safari does not play sounds for web push notifications
Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with

View file

@ -457,6 +457,7 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
=== "PowerShell" === "PowerShell"
``` powershell ``` powershell
$Request = @{ $Request = @{
Method = 'POST'
URI = "https://ntfy.sh/phil_alerts" URI = "https://ntfy.sh/phil_alerts"
Headers = @{ Headers = @{
Priority = "5" Priority = "5"
@ -1033,7 +1034,7 @@ is the only required one:
$Request = @{ $Request = @{
Method = "POST" Method = "POST"
URI = "https://ntfy.sh" URI = "https://ntfy.sh"
Body = @{ Body = ConvertTo-JSON @{
Topic = "mytopic" Topic = "mytopic"
Title = "Low disk space alert" Title = "Low disk space alert"
Message = "Disk space is low at 5.1 GB" Message = "Disk space is low at 5.1 GB"
@ -1042,7 +1043,7 @@ is the only required one:
FileName = "diskspace.jpg" FileName = "diskspace.jpg"
Tags = @("warning", "cd") Tags = @("warning", "cd")
Click = "https://homecamera.lan/xasds1h2xsSsa/" Click = "https://homecamera.lan/xasds1h2xsSsa/"
Actions = ConvertTo-JSON @( Actions = @(
@{ @{
Action = "view" Action = "view"
Label = "Admin panel" Label = "Admin panel"
@ -1130,7 +1131,7 @@ As of today, the following actions are supported:
when the action button is tapped (only supported on Android) when the action button is tapped (only supported on Android)
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped * [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
Here's an example of what that a notification with actions can look like: Here's an example of what a notification with actions can look like:
<figure markdown> <figure markdown>
![notification with actions](static/img/android-screenshot-notification-actions.png){ width=500 } ![notification with actions](static/img/android-screenshot-notification-actions.png){ width=500 }
@ -1919,10 +1920,10 @@ And the same example using [JSON publishing](#publish-as-json):
$Request = @{ $Request = @{
Method = "POST" Method = "POST"
URI = "https://ntfy.sh" URI = "https://ntfy.sh"
Body = @{ Body = ConvertTo-Json -Depth 3 @{
Topic = "wifey" Topic = "wifey"
Message = "Your wife requested you send a picture of yourself." Message = "Your wife requested you send a picture of yourself."
Actions = ConvertTo-Json -Depth 3 @( Actions = @(
@{ @{
Action = "broadcast" Action = "broadcast"
Label = "Take picture" Label = "Take picture"
@ -2072,7 +2073,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
'method' => 'POST', 'method' => 'POST',
'header' => 'header' =>
"Content-Type: text/plain\r\n" . "Content-Type: text/plain\r\n" .
"Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}", 'Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}',
'content' => 'Garage door has been open for 15 minutes. Close it?' 'content' => 'Garage door has been open for 15 minutes. Close it?'
] ]
])); ]));
@ -2199,10 +2200,10 @@ And the same example using [JSON publishing](#publish-as-json):
$Request = @{ $Request = @{
Method = "POST" Method = "POST"
URI = "https://ntfy.sh" URI = "https://ntfy.sh"
Body = @{ Body = ConvertTo-Json -Depth 3 @{
Topic = "myhome" Topic = "myhome"
Message = "Garage door has been open for 15 minutes. Close it?" Message = "Garage door has been open for 15 minutes. Close it?"
Actions = ConvertTo-Json -Depth 3 @( Actions = @(
@{ @{
Action = "http" Action = "http"
Label = "Close door" Label = "Close door"
@ -2287,7 +2288,7 @@ You can define which URL to open when a notification is clicked. This may be use
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
the web browser (or the app) and open the website. the web browser (or the app) and open the website.
To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its aliase `Click`). To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`).
If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled
by another app, the responsible app may open. by another app, the responsible app may open.

View file

@ -2,6 +2,68 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy iOS app v1.3
Released Nov 26, 2023
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs for a long time, and I hope that they are finally fixed.
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
**Bug fixes:**
* 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))
### ntfy server v2.8.0
Released November 19, 2023
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
**Bug fixes + maintenance:**
* 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)
* 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)
* 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))
* 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))
* 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))
* PWA: hide install prompt on macOS 14 Safari ([#899](https://github.com/binwiederhier/ntfy/pull/899), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves))
* 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))
**Additional languages:**
* Finnish (thanks to [@Seppo](https://hosted.weblate.org/user/Seppo/))
## ntfy server v2.7.0
Released August 17, 2023
This release ships Markdown support for the web app (not in the Android app yet), and adds support for
right-to-left languages (RTL) in the web app. It also fixes a few issues around date/time formatting,
internationalization support, a CLI auth bug.
Furthermore, it fixes a security issue around access tokens getting erroneously deleted for other users
in a specific scenario. This was a denial-of-service-type security issue, since it **effectively allowed a
single user to deny access to all other users of a ntfy instance**. Please note that while tokens were
erroneously deleted, **nobody but the token owner ever had access to it.** Please refer to [the ticket](https://github.com/binwiederhier/ntfy/issues/838)
for details. **Please upgrade your ntfy instance if you run a multi-user system.**
**Features:**
* 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))
* 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))
**Security:** ⚠️
* Fixes issue with access tokens getting deleted ([#838](https://github.com/binwiederhier/ntfy/issues/838))
**Bug fixes + maintenance:**
* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost))
* 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))
* 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)
**Documentation:**
* Update docs for Apache config ([#819](https://github.com/binwiederhier/ntfy/pull/819), thanks to [@nisbet-hubbard](https://github.com/nisbet-hubbard))
## ntfy server v2.6.2 ## ntfy server v2.6.2
Released June 30, 2023 Released June 30, 2023
@ -78,7 +140,7 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source.
## ntfy server v2.4.0 ## ntfy server v2.4.0
Released Apr 26, 2023 Released Apr 26, 2023
This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`, This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds support to encode the `X-Title`,
`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. `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.
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) ❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
@ -1241,7 +1303,7 @@ Released Dec 28, 2021
**Features & bug fixes:** **Features & bug fixes:**
* [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66 * [Publish messages via e-mail](publish.md#e-mail-publishing) #66
* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64 * Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64
* Fixing the Santa bug #65 * Fixing the Santa bug #65
@ -1251,18 +1313,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.7.0 (UNRELEASED) ### ntfy server v2.9.0
**Features:**
* 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))
* 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))
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost)) * Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
* 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)) * Add non-root user to Docker image, ntfy can be run as non-root ([#967](https://github.com/binwiederhier/ntfy/pull/967)/[#966](https://github.com/binwiederhier/ntfy/issues/966), thanks to [@arahja](https://github.com/arahja))
* 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)
**Documentation:**
* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))
* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))
* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))
### ntfy Android app v1.16.1 (UNRELEASED) ### ntfy Android app v1.16.1 (UNRELEASED)

BIN
docs/static/img/cdio-setup.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -190,9 +190,10 @@ format. Keepalive messages are sent as empty lines.
## WebSockets ## WebSockets
You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely
supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line, supported in many languages. Most notably, WebSockets are natively supported in JavaScript. You may also want to
I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-websocket).
for WebSockets. On the command line, I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat`
or `curl`, but specifically for WebSockets.
The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the
[JSON stream endpoint](#subscribe-as-json-stream). [JSON stream endpoint](#subscribe-as-json-stream).

View file

@ -10,7 +10,7 @@ to topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that c
## Install + configure ## Install + configure
To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and
client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client
by creating `~/.config/ntfy/client.yml` (for the non-root user), or `/etc/ntfy/client.yml` (for the root user). You by 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
can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub. can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub.
If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**, If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**,

View file

@ -26,6 +26,13 @@ app drawer:
<a href="../../static/img/pwa-badge.png"><img src="../../static/img/pwa-badge.png"/></a> <a href="../../static/img/pwa-badge.png"><img src="../../static/img/pwa-badge.png"/></a>
</div> </div>
### Safari on macOS
To 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.
<div id="pwa-screenshots-safari-desktop" class="screenshots">
<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>
</div>
### Chrome/Firefox on Android ### Chrome/Firefox on Android
For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app" For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app"
in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer, in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer,

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ntfy.sh: WebSocket Example</title>
<meta name="robots" content="noindex, nofollow" />
<style>
body { font-size: 1.2em; line-height: 130%; }
#events { font-family: monospace; }
</style>
</head>
<body>
<h1>ntfy.sh: WebSocket Example</h1>
<p>
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
<a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket">WebSocket</a>.<br/>
This example doesn't need a server. You can just save the HTML page and run it from anywhere.
</p>
<button id="publishButton">Send test notification</button>
<p><b>Log:</b></p>
<div id="events"></div>
<script type="text/javascript">
const publishURL = `https://ntfy.sh/example`;
const subscribeURL = `wss://ntfy.sh/example/ws`;
const events = document.getElementById('events');
const websocket = new WebSocket(subscribeURL);
// Publish button
document.getElementById("publishButton").onclick = () => {
fetch(publishURL, {
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
body: `It is ${new Date().toString()}. This is a test.`
})
};
// Incoming events
websocket.onopen = () => {
let event = document.createElement('div');
event.innerHTML = `WebSocket connected to ${subscribeURL}`;
events.appendChild(event);
};
websocket.onerror = (e) => {
let event = document.createElement('div');
event.innerHTML = `WebSocket error: Failed to connect to ${subscribeURL}`;
events.appendChild(event);
};
websocket.onmessage = (e) => {
let event = document.createElement('div');
event.innerHTML = e.data;
events.appendChild(event);
};
</script>
</body>
</html>

108
go.mod
View file

@ -1,78 +1,90 @@
module heckel.io/ntfy module heckel.io/ntfy/v2
go 1.18 go 1.21
toolchain go1.21.3
require ( require (
cloud.google.com/go/firestore v1.12.0 // indirect cloud.google.com/go/firestore v1.15.0 // indirect
cloud.google.com/go/storage v1.31.0 // indirect cloud.google.com/go/storage v1.39.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect github.com/BurntSushi/toml v1.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/emersion/go-smtp v0.17.0 github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.2 github.com/gabriel-vasile/mimetype v1.4.3
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.1
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.22
github.com/olebedev/when v1.0.0 github.com/olebedev/when v1.0.0
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7 github.com/urfave/cli/v2 v2.27.1
golang.org/x/crypto v0.11.0 golang.org/x/crypto v0.21.0
golang.org/x/oauth2 v0.10.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect
golang.org/x/sync v0.3.0 golang.org/x/sync v0.6.0
golang.org/x/term v0.10.0 golang.org/x/term v0.18.0
golang.org/x/time v0.3.0 golang.org/x/time v0.5.0
google.golang.org/api v0.134.0 google.golang.org/api v0.168.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pin version due to breaking changes, see #839
require github.com/pkg/errors v0.9.1 // indirect require github.com/pkg/errors v0.9.1 // indirect
require ( require (
firebase.google.com/go/v4 v4.12.0 firebase.google.com/go/v4 v4.13.0
github.com/SherClockHolmes/webpush-go v1.2.0 github.com/SherClockHolmes/webpush-go v1.3.0
github.com/prometheus/client_golang v1.16.0 github.com/microcosm-cc/bluemonday v1.0.26
github.com/stripe/stripe-go/v74 v74.28.0 github.com/prometheus/client_golang v1.19.0
github.com/stripe/stripe-go/v74 v74.30.0
) )
require ( require (
cloud.google.com/go v0.110.7 // indirect cloud.google.com/go v0.112.1 // indirect
cloud.google.com/go/compute v1.23.0 // indirect cloud.google.com/go/compute v1.25.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.1.1 // indirect cloud.google.com/go/iam v1.1.6 // indirect
cloud.google.com/go/longrunning v0.5.1 // indirect cloud.google.com/go/longrunning v0.5.5 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/google/s2a-go v0.1.7 // indirect
github.com/google/s2a-go v0.1.4 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/googleapis/gax-go/v2 v2.12.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/common v0.50.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect github.com/prometheus/procfs v0.13.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.13.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
golang.org/x/sys v0.10.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
golang.org/x/text v0.11.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect
google.golang.org/appengine v1.6.7 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect
google.golang.org/appengine/v2 v2.0.4 // indirect golang.org/x/net v0.22.0 // indirect
google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf // indirect golang.org/x/sys v0.18.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf // indirect golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/grpc v1.57.0 // indirect google.golang.org/appengine/v2 v2.0.5 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
google.golang.org/grpc v1.62.1 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

305
go.sum
View file

@ -1,31 +1,20 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
cloud.google.com/go v0.110.3 h1:wwearW+L7sAPSomPIgJ3bVn6Ck00HGQnn5HMLwf0azo= cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
cloud.google.com/go v0.110.3/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
cloud.google.com/go v0.110.4 h1:1JYyxKMN9hd5dR2MYTPWkGUgcoxVVhg0LKNKEo0qvmk= cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
cloud.google.com/go v0.110.4/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.11.0 h1:PPgtwcYUOXV2jFe1bV3nda3RCrOa8cvBjTOn2MQVfW8= cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
cloud.google.com/go/firestore v1.12.0 h1:aeEA/N7DW7+l2u5jtkO8I0qv0D95YwjggD8kUHrTHO4= cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/firestore v1.12.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y= cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI= firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI=
cloud.google.com/go/storage v1.31.0/go.mod h1:81ams1PrhW16L4kF7qg+4mTq7SRs5HsbDTM0bWvrwJ0= firebase.google.com/go/v4 v4.13.0/go.mod h1:e1/gaR6EnbQfsmTnAMx1hnz+ninJIrrr/RAh59Tpfn8=
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
firebase.google.com/go/v4 v4.12.0 h1:I6dCkcWUMFNkFdWgzlf8SLWecQnKdFgJhMv5fT9l1qI=
firebase.google.com/go/v4 v4.12.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -33,44 +22,41 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA= github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w= github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@ -82,21 +68,18 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -104,150 +87,147 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.2.5/go.mod h1:RxW0N9901Cko1VOCW3SXCpWP+mlIEkk2tP7jnHy9a3w= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4= github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w= github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk= github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.24.0 h1:h+hXEI5avC5moAh2YLtphMFTBnp11TfXTcP4suuWDLk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stripe/stripe-go/v74 v74.24.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stripe/stripe-go/v74 v74.25.0 h1:mGJp9L1ymxjFvq5MlmG6ynv/fAGX6LLU8MyMVsiRAMY= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.25.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/stripe/stripe-go/v74 v74.28.0 h1:ItzPPy+cjMKbR3Oihknt/8dv6PANp3hTThUGZjhF9lc= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/stripe/stripe-go/v74 v74.28.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw=
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -255,61 +235,35 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
google.golang.org/api v0.129.0 h1:2XbdjjNfFPXQyufzQVwPf1RRnHH8Den2pfNE2jw7L8w= google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
google.golang.org/api v0.129.0/go.mod h1:dFjiXlanKwWE3612X97llhsoI36FAoIiRj3aTl5b/zE=
google.golang.org/api v0.130.0 h1:A50ujooa1h9iizvfzA4rrJr2B7uRmWexwbekQ2+5FPQ=
google.golang.org/api v0.130.0/go.mod h1:J/LCJMYSDFvAVREGCbrESb53n4++NMBDetSHGL5I5RY=
google.golang.org/api v0.134.0 h1:ktL4Goua+UBgoP1eL1/60LwZJqa1sIzkLmvoR3hR6Gw=
google.golang.org/api v0.134.0/go.mod h1:sjRL3UnjTx5UqNQS9EWr9N8p7xbHpy1k0XGRLCf3Spk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/appengine/v2 v2.0.3 h1:AyY/mipuqiyCIAqOevfmu5fMDc5/9P/QggWfCQYdkSA= google.golang.org/appengine/v2 v2.0.5 h1:4C+F3Cd3L2nWEfSmFEZDPjQvDwL8T0YCeZBysZifP3k=
google.golang.org/appengine/v2 v2.0.3/go.mod h1:2Z0TTdcXxnHdXzmp8drrmOExUDM2WQgyT33c6JDUlJM= google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/appengine/v2 v2.0.4 h1:aAAPYixP9EfTJjNO6F46afaxp+jfzb0VgwVjMeLBtF4=
google.golang.org/appengine/v2 v2.0.4/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs= google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 h1:Au6te5hbKUV8pIYWHqOUZ1pva5qK/rwbIhoXEUB9Lu8= google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y= google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf h1:v5Cf4E9+6tawYrs/grq1q1hFpGtzlGFzgWHqwt6NFiU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ=
google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf h1:xkVZ5FdZJF4U82Q/JS+DcZA83s/GRVL+QrFMlexk9Yo=
google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:5DZzOUPCLYL3mNkQ0ms0F3EuUNZ7py1Bqeq6sxzI7/Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf h1:guOdSPaeFgN+jEJwTo1dQ71hdBm+yKSCCKuTRkJzcVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
google.golang.org/grpc v1.56.1 h1:z0dNfjIl0VpaZ9iSVjA6daGatAYwPGstTjt5vkRMFkQ=
google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/grpc v1.56.2 h1:fVRFRnXvU+x6C4IlHZewvJOVHoOv1TUuQyoRsYnB4bI=
google.golang.org/grpc v1.56.2/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -322,12 +276,11 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -3,7 +3,7 @@ package log
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"log" "log"
"os" "os"
"sort" "sort"

View file

@ -3,7 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/cmd" "heckel.io/ntfy/v2/cmd"
"os" "os"
"runtime" "runtime"
) )

View file

@ -64,40 +64,40 @@ markdown_extensions:
- attr_list - attr_list
- md_in_html - md_in_html
- pymdownx.emoji: - pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg emoji_generator: !!python/name:material.extensions.emoji.to_svg
hooks:
- docs/hooks.py
plugins: plugins:
- search - search
- minify: - minify:
minify_html: true minify_html: true
- mkdocs-simple-hooks:
hooks:
on_post_build: "docs.hooks:copy_fonts"
nav: nav:
- "Getting started": index.md - "Getting started": index.md
- "Publishing": - "Publishing":
- "Sending messages": publish.md - "Sending messages": publish.md
- "Subscribing": - "Subscribing":
- "From your phone": subscribe/phone.md - "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md - "From the Web app": subscribe/web.md
- "From the Desktop": subscribe/pwa.md - "From the Desktop": subscribe/pwa.md
- "From the CLI": subscribe/cli.md - "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md - "Using the API": subscribe/api.md
- "Self-hosting": - "Self-hosting":
- "Installation": install.md - "Installation": install.md
- "Configuration": config.md - "Configuration": config.md
- "Other things": - "Other things":
- "FAQs": faq.md - "FAQs": faq.md
- "Examples": examples.md - "Examples": examples.md
- "Integrations + projects": integrations.md - "Integrations + projects": integrations.md
- "Release notes": releases.md - "Release notes": releases.md
- "Emojis 🥳 🎉": emojis.md - "Emojis 🥳 🎉": emojis.md
- "Troubleshooting": troubleshooting.md - "Troubleshooting": troubleshooting.md
- "Known issues": known-issues.md - "Known issues": known-issues.md
- "Deprecation notices": deprecations.md - "Deprecation notices": deprecations.md
- "Development": develop.md - "Development": develop.md
- "Privacy policy": privacy.md - "Privacy policy": privacy.md

View file

@ -1,4 +1,3 @@
# The documentation uses 'mkdocs', which is written in Python # The documentation uses 'mkdocs', which is written in Python
mkdocs-material mkdocs-material
mkdocs-minify-plugin mkdocs-minify-plugin
mkdocs-simple-hooks

View file

@ -25,9 +25,9 @@ elif [[ "$1" == *.md ]]; then
<!-- This file was generated by scripts/emoji-convert.sh --> <!-- This file was generated by scripts/emoji-convert.sh -->
You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically You can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically
converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the
[tagging and emojis page](../publish/#tags-emojis). [tagging and emojis page](publish.md#tags-emojis).
<table class=\"remove-md-box emoji-table\"><tr> <table class=\"remove-md-box emoji-table\"><tr>
" > "$1" " > "$1"

View file

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"regexp" "regexp"
"strings" "strings"
"unicode/utf8" "unicode/utf8"

View file

@ -5,7 +5,7 @@ import (
"net/netip" "net/netip"
"time" "time"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
) )
// Defines default config settings (excluding limits, see below) // Defines default config settings (excluding limits, see below)

View file

@ -2,7 +2,7 @@ package server_test
import ( import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"testing" "testing"
) )

View file

@ -3,7 +3,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"net/http" "net/http"
) )

View file

@ -3,8 +3,8 @@ package server
import ( import (
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"

View file

@ -4,7 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"os" "os"
"strings" "strings"
"testing" "testing"

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/http" "net/http"
"strings" "strings"
"unicode/utf8" "unicode/utf8"

View file

@ -10,8 +10,8 @@ import (
"time" "time"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
var ( var (

View file

@ -30,9 +30,9 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
// Server is the main server, providing the UI and API for ntfy // Server is the main server, providing the UI and API for ntfy
@ -743,8 +743,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, e.With(t) return nil, e.With(t)
} }
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil { if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see // UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting.
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove // The 5xx response is because some app servers (in particular Mastodon) will remove
// the subscription as invalid if any 400-499 code (except 429/408) is returned. // the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46 // See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t) return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
@ -1182,7 +1182,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if err != nil { if err != nil {
return err return err
} }
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil { if err != nil {
return err return err
} }
@ -1212,7 +1212,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
} }
return nil return nil
} }
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err return err
} }
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@ -1278,7 +1278,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
if err != nil { if err != nil {
return err return err
} }
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil { if err != nil {
return err return err
} }
@ -1364,7 +1364,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
} }
return conn.WriteJSON(msg) return conn.WriteJSON(msg)
} }
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err return err
} }
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@ -1397,7 +1397,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return err return err
} }
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) { func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
poll = readBoolParam(r, false, "x-poll", "poll", "po") poll = readBoolParam(r, false, "x-poll", "poll", "po")
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched") scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
since, err = parseSince(r, poll) since, err = parseSince(r, poll)
@ -1408,7 +1408,6 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
if err != nil { if err != nil {
return return
} }
rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
return return
} }
@ -1420,9 +1419,8 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
// - or the topic is reserved, and v.user is the owner // - or the topic is reserved, and v.user is the owner
// - or the topic is not reserved, and v.user has write access // - or the topic is not reserved, and v.user has write access
// //
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition // This only applies to UnifiedPush topics ("up...").
// until the Android app will send the "Rate-Topics" header. func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic) error {
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
// Bail out if not enabled // Bail out if not enabled
if !s.config.VisitorSubscriberRateLimiting { if !s.config.VisitorSubscriberRateLimiting {
return nil return nil
@ -1431,7 +1429,7 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top
// Make a list of topics that we'll actually set the RateVisitor on // Make a list of topics that we'll actually set the RateVisitor on
eligibleRateTopics := make([]*topic, 0) eligibleRateTopics := make([]*topic, 0)
for _, t := range topics { for _, t := range topics {
if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) { if strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength {
eligibleRateTopics = append(eligibleRateTopics, t) eligibleRateTopics = append(eligibleRateTopics, t)
} }
} }

View file

@ -277,15 +277,14 @@
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush) # Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
# #
# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed # If subscriber-based rate limiting is enabled, messages published on UnifiedPush topics** (topics starting with "up")
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume # will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
# publishers (e.g. Matrix/Mastodon servers) are allowed to send.
# #
# Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via # Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits # a "rate visitor", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor
# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic. # requires **read-write permission** on the topic.
# #
# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if # If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit". # no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
# #
# visitor-subscriber-rate-limiting: false # visitor-subscriber-rate-limiting: false
@ -342,6 +341,10 @@
# - "field -> level" to match any value, e.g. "time_taken_ms -> debug" # - "field -> level" to match any value, e.g. "time_taken_ms -> debug"
# Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging. # Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging.
# #
# Check your permissions:
# If you are running ntfy with systemd, make sure this log file is owned by the
# ntfy user and group by running: chown ntfy.ntfy <filename>.
#
# Example (good for production): # Example (good for production):
# log-level: info # log-level: info
# log-format: json # log-format: json

View file

@ -2,9 +2,9 @@ package server
import ( import (
"encoding/json" "encoding/json"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/http" "net/http"
"net/netip" "net/netip"
"strings" "strings"

View file

@ -3,9 +3,9 @@ package server
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/netip" "net/netip"
"path/filepath" "path/filepath"

View file

@ -1,7 +1,7 @@
package server package server
import ( import (
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"net/http" "net/http"
) )

View file

@ -2,8 +2,8 @@ package server
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"

View file

@ -8,8 +8,8 @@ import (
"firebase.google.com/go/v4/messaging" "firebase.google.com/go/v4/messaging"
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"strings" "strings"
) )

View file

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"net/netip" "net/netip"
"strings" "strings"
"sync" "sync"

View file

@ -1,8 +1,8 @@
package server package server
import ( import (
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"strings" "strings"
) )

View file

@ -4,7 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"strings" "strings"

View file

@ -3,7 +3,7 @@ package server
import ( import (
"net/http" "net/http"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
type contextKey int type contextKey int

View file

@ -11,9 +11,9 @@ import (
"github.com/stripe/stripe-go/v74/price" "github.com/stripe/stripe-go/v74/price"
"github.com/stripe/stripe-go/v74/subscription" "github.com/stripe/stripe-go/v74/subscription"
"github.com/stripe/stripe-go/v74/webhook" "github.com/stripe/stripe-go/v74/webhook"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/netip" "net/netip"

View file

@ -6,8 +6,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/netip" "net/netip"
"path/filepath" "path/filepath"

View file

@ -3,13 +3,13 @@ package server
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"io" "io"
"math/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@ -24,8 +24,8 @@ import (
"github.com/SherClockHolmes/webpush-go" "github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@ -329,6 +329,27 @@ func TestServer_PublishPriority(t *testing.T) {
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
} }
func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"Priority": "u=4",
"X-Priority": "5",
})
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "POST", "/mytopic?priority=4", "test", map[string]string{
"Priority": "u=9",
})
require.Equal(t, 4, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "POST", "/mytopic", "test", map[string]string{
"p": "2",
"priority": "u=9, i",
})
require.Equal(t, 2, toMessage(t, response.Body.String()).Priority)
}
func TestServer_PublishGETOnlyOneTopic(t *testing.T) { func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
// This tests a bug that allowed publishing topics with a comma in the name (no ticket) // This tests a bug that allowed publishing topics with a comma in the name (no ticket)
@ -491,6 +512,8 @@ func TestServer_PublishAtAndPrune(t *testing.T) {
messages := toMessages(t, response.Body.String()) messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) // Not affected by pruning require.Equal(t, 1, len(messages)) // Not affected by pruning
require.Equal(t, "a message", messages[0].Message) require.Equal(t, "a message", messages[0].Message)
time.Sleep(time.Second) // FIXME CI failing not sure why
} }
func TestServer_PublishAndMultiPoll(t *testing.T) { func TestServer_PublishAndMultiPoll(t *testing.T) {
@ -1323,9 +1346,7 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber // Register a UnifiedPush subscriber
response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{ response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil)
"Rate-Topics": "up123456789012",
})
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
// Publish message to topic // Publish message to topic
@ -1356,9 +1377,7 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber // Register a UnifiedPush subscriber
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
"Rate-Topics": "mytopic",
})
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
// Publish message to topic // Publish message to topic
@ -1377,9 +1396,7 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber // Register a UnifiedPush subscriber
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
"Rate-Topics": "mytopic",
})
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
// Publish UnifiedPush text message // Publish UnifiedPush text message
@ -1411,9 +1428,7 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
func TestServer_MatrixGateway_Push_Success(t *testing.T) { func TestServer_MatrixGateway_Push_Success(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
"Rate-Topics": "mytopic", // Register first!
})
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
@ -2243,16 +2258,14 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
c.VisitorSubscriberRateLimiting = true c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c) s := newTestServer(t, c)
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor // "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor
subscriber1Fn := func(r *http.Request) { subscriber1Fn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4" r.RemoteAddr = "1.2.3.4"
} }
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
"Rate-Topics": "subscriber1topic",
}, subscriber1Fn)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String()) require.Equal(t, "", rr.Body.String())
require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String()) require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) // "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
subscriber2Fn := func(r *http.Request) { subscriber2Fn := func(r *http.Request) {
@ -2266,10 +2279,10 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the // Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
// GET request before is also counted towards the request limiter. // GET request before is also counted towards the request limiter.
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) rr := request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
} }
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) rr = request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 429, rr.Code) require.Equal(t, 429, rr.Code)
// Publish another 2 messages to "up012345678912" as visitor 9.9.9.9 // Publish another 2 messages to "up012345678912" as visitor 9.9.9.9
@ -2302,14 +2315,12 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
// Subscriber rate limiting is disabled! // Subscriber rate limiting is disabled!
// Registering visitor 1.2.3.4 to topic has no effect // Registering visitor 1.2.3.4 to topic has no effect
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) {
"Rate-Topics": "subscriber1topic",
}, func(r *http.Request) {
r.RemoteAddr = "1.2.3.4" r.RemoteAddr = "1.2.3.4"
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String()) require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["subscriber1topic"].rateVisitor) require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Registering visitor 8.7.7.1 to topic has no effect // Registering visitor 8.7.7.1 to topic has no effect
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
@ -2319,7 +2330,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
require.Equal(t, "", rr.Body.String()) require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["up012345678912"].rateVisitor) require.Nil(t, s.topics["up012345678912"].rateVisitor)
// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9 // Publish 3 messages to "upAAAAAAAAAAAA" as visitor 9.9.9.9
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@ -2392,80 +2403,30 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
subscriberFn := func(r *http.Request) { subscriberFn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4" r.RemoteAddr = "1.2.3.4"
} }
rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
"rate-topics": "mytopic",
}, subscriberFn)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String()) require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Publish message, observe rate visitor tokens being decreased // Publish message, observe rate visitor tokens being decreased
response := request(t, s, "POST", "/mytopic", "some message", nil) response := request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value()) require.Equal(t, int64(1), s.topics["upAAAAAAAAAAAA"].rateVisitor.messagesLimiter.Value())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Expire visitor // Expire visitor
s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour) s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour)
s.pruneVisitors() s.pruneVisitors()
// Publish message again, observe that rateVisitor is not used anymore and is reset // Publish message again, observe that rateVisitor is not used anymore and is reset
response = request(t, s, "POST", "/mytopic", "some message", nil) response = request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
require.Nil(t, s.topics["mytopic"].rateVisitor) require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor)
require.Nil(t, s.visitors["ip:1.2.3.4"]) require.Nil(t, s.visitors["ip:1.2.3.4"])
} }
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// Create some ACLs
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
MessageLimit: 5,
}))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("ben", "test"))
require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite))
// Set rate visitor as user "phil" on topic
// - "reserved-for-phil": Allowed, because I am the owner
// - "public_topic": Allowed, because it has read-write permissions for everyone
// - "announcements": NOT allowed, because it has read-only permissions for everyone
rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name)
require.Nil(t, s.topics["announcements"].rateVisitor)
// Set rate visitor as user "ben" on topic
// - "reserved-for-phil": NOT allowed, because I am not the owner
// - "public_topic": Allowed, because it has read-write permissions for everyone
// - "announcements": Allowed, because I have read-write permissions
rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name)
require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name)
}
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionReadWrite c.AuthDefault = user.PermissionReadWrite

View file

@ -4,9 +4,9 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"

View file

@ -2,8 +2,8 @@ package server
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"

View file

@ -8,8 +8,8 @@ import (
"strings" "strings"
"github.com/SherClockHolmes/webpush-go" "github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
) )
const ( const (

View file

@ -4,8 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"

View file

@ -11,8 +11,8 @@ import (
"sync" "sync"
"time" "time"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
type mailer interface { type mailer interface {

View file

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
@ -14,6 +15,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/mail" "net/mail"
"regexp"
"strings" "strings"
"sync" "sync"
) )
@ -27,6 +29,11 @@ var (
errUnsupportedContentType = errors.New("unsupported content type") errUnsupportedContentType = errors.New("unsupported content type")
) )
var (
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
)
const ( const (
maxMultipartDepth = 2 maxMultipartDepth = 2
) )
@ -232,37 +239,66 @@ func readMailBody(body io.Reader, header mail.Header) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if strings.ToLower(contentType) == "text/plain" { canonicalContentType := strings.ToLower(contentType)
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) if canonicalContentType == "text/plain" || canonicalContentType == "text/html" {
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") { return readTextMailBody(body, canonicalContentType, header.Get("Content-Transfer-Encoding"))
return readMultipartMailBody(body, params, 0) } else if strings.HasPrefix(canonicalContentType, "multipart/") {
return readMultipartMailBody(body, params)
} }
return "", errUnsupportedContentType return "", errUnsupportedContentType
} }
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) { func readMultipartMailBody(body io.Reader, params map[string]string) (string, error) {
parts := make(map[string]string)
if err := readMultipartMailBodyParts(body, params, 0, parts); err != nil && err != io.EOF {
return "", err
} else if s, ok := parts["text/plain"]; ok {
return s, nil
} else if s, ok := parts["text/html"]; ok {
return s, nil
}
return "", io.EOF
}
func readMultipartMailBodyParts(body io.Reader, params map[string]string, depth int, parts map[string]string) error {
if depth >= maxMultipartDepth { if depth >= maxMultipartDepth {
return "", errMultipartNestedTooDeep return errMultipartNestedTooDeep
} }
mr := multipart.NewReader(body, params["boundary"]) mr := multipart.NewReader(body, params["boundary"])
for { for {
part, err := mr.NextPart() part, err := mr.NextPart()
if err != nil { // may be io.EOF if err != nil { // may be io.EOF
return "", err return err
} }
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type")) partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil { if err != nil {
return "", err return err
} }
if strings.ToLower(partContentType) == "text/plain" { canonicalPartContentType := strings.ToLower(partContentType)
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) if canonicalPartContentType == "text/plain" || canonicalPartContentType == "text/html" {
s, err := readTextMailBody(part, canonicalPartContentType, part.Header.Get("Content-Transfer-Encoding"))
if err != nil {
return err
}
parts[canonicalPartContentType] = s
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") { } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
return readMultipartMailBody(part, partParams, depth+1) if err := readMultipartMailBodyParts(part, partParams, depth+1, parts); err != nil {
return err
}
} }
// Continue with next part // Continue with next part
} }
} }
func readTextMailBody(reader io.Reader, contentType, transferEncoding string) (string, error) {
if contentType == "text/plain" {
return readPlainTextMailBody(reader, transferEncoding)
} else if contentType == "text/html" {
return readHTMLMailBody(reader, transferEncoding)
}
return "", fmt.Errorf("unsupported content type: %s", contentType)
}
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) { func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
if strings.ToLower(transferEncoding) == "base64" { if strings.ToLower(transferEncoding) == "base64" {
reader = base64.NewDecoder(base64.StdEncoding, reader) reader = base64.NewDecoder(base64.StdEncoding, reader)
@ -275,3 +311,21 @@ func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, e
} }
return string(body), nil return string(body), nil
} }
func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) {
body, err := readPlainTextMailBody(reader, transferEncoding)
if err != nil {
return "", err
}
stripped := bluemonday.
StrictPolicy().
AddSpaceWhenStrippingTag(true).
Sanitize(body)
return removeExtraEmptyLines(stripped), nil
}
func removeExtraEmptyLines(s string) string {
s = onlySpacesRegex.ReplaceAllString(s, "")
s = consecutiveNewLinesRegex.ReplaceAllString(s, "\n\n")
return s
}

View file

@ -568,6 +568,803 @@ L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep") writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep")
} }
func TestSmtpBackend_HTMLEmail(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Message-Id: <51610934ss4.mmailer@fritz.box>
From: <email@email.com>
To: <email@email.com>,
<ntfy-subjectatntfy@ntfy.sh>
Date: Thu, 30 Mar 2023 02:56:53 +0000
Subject: A HTML email
Mime-Version: 1.0
Content-Type: text/html;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<=21DOCTYPE html>
<html>
<head>
<title>Alerttitle</title>
<meta http-equiv=3D"content-type" content=3D"text/html;charset=3Dutf-8"/>
</head>
<body style=3D"color: =23000000; background-color: =23f0eee6;">
<table width=3D"100%" align=3D"center" style=3D"border:solid 2px =23eeeeee=
; border-collapse: collapse;">
<tr>
<td>
<table style=3D"border-collapse: collapse;">
<tr>
<td style=3D"background: =23FFFFFF;">
<table style=3D"color: =23FFFFFF; background-color: =23006EC0; border-coll=
apse: collapse;">
<tr>
<td style=3D"width: 1000px; text-align: center; font-size: 18pt; font-fami=
ly: Arial, Helvetica, sans-serif; padding: 10px;">
headertext of table
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
<table style=3D"border-collapse: collapse;">
<tr>
<td style=3D"width: 940px; font-size: 13pt; font-family: Arial, Helvetica,=
sans-serif; text-align: left;">
" Very important information about a change in your
home automation setup
Now the light is on
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
<table>
<tr>
<td style=3D"width: 960px; font-size: 10pt; font-family: Arial, Helvetica,=
sans-serif; text-align: left;">
<hr />
If you don't want to receive this message anymore, stop the push
services in your <a href=3D"https:fritzbox" target=3D"_=
blank">FRITZ=21Box</a>=2E<br />
Here you can see the active push services: "System > Push Service"=2E
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table style=3D"color: =23FFFFFF; background-color: =23006EC0;">
<tr>
<td style=3D"width: 1000px; font-size: 10pt; font-family: Arial, Helvetica=
, sans-serif; text-align: center; padding: 10px;">
This mail has ben sent by your <a style=3D"color: =23FFFFFF;" href=3D"https:=
//fritzbox" target=3D"_blank">FRITZ=21Box</a=
> automatically=2E
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "A HTML email", r.Header.Get("Title"))
expected := `headertext of table
&#34; Very important information about a change in your
home automation setup
Now the light is on
If you don&#39;t want to receive this message anymore, stop the push
services in your FRITZ!Box .
Here you can see the active push services: &#34;System &gt; Push Service&#34;.
This mail has ben sent by your FRITZ!Box automatically.`
require.Equal(t, expected, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
const spamEmail = `
EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Delivered-To: somebody@gmail.com
Received: by 2002:a05:651c:1248:b0:2bf:c263:285 with SMTP id h8csp1096496ljh;
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
X-Google-Smtp-Source: AGHT+IFsB3WqbwbeefbeefbeefbeefbeefiXRNDHnIy2xBeaYHZCM3EC8DfPv55qDtgq9djTeBCF
X-Received: by 2002:a05:6808:147:b0:3af:66e5:5d3c with SMTP id h7-20020a056808014700b003af66e55d3cmr11662458oie.26.1698672188132;
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1698672188; cv=none;
d=google.com; s=arc-20160816;
b=XM96KvnTbr4h6bqrTPTuuDNXmFCr9Be/HvVhu+UsSQjP9RxPk0wDTPUPZ/HWIJs52y
beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef
BUmQ==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
h=list-unsubscribe-post:list-unsubscribe:mime-version:subject:to
:reply-to:from:date:message-id:dkim-signature:dkim-signature;
bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=;
fh=+kTCcNpX22TOI/SVSLygnrDqWeUt4zW7QKiv0TOVSGs=;
b=lyIBRuOxPOTY2s36OqP7M7awlBKd4t5PX9mJOEJB0eTnTZqML+cplrXUIg2ZTlAAi9
beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef
tgVQ==
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;
dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;
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";
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com
Return-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>
Received: from ccm30.constantcontact.com (ccm30.constantcontact.com. [208.75.123.226])
by mx.google.com with ESMTPS id h2-20020a05620a21c200b0076eeed38118si5450962qka.131.2023.10.30.06.23.07
for <somebody@gmail.com>
(version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128);
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
Received-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;
Authentication-Results: mx.google.com;
dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;
dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;
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";
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com
Return-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>
Received: 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
DKIM-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=
DKIM-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=
Message-ID: <1140728754828.1133104752381.1941549819.0.260913JL.2002@synd.ccsend.com>
Date: Mon, 30 Oct 2023 09:23:07 -0400 (EDT)
From: spamspam Loan Servicing <marklake@spamspam.com>
Reply-To: marklake@spamspam.com
To: somebody@gmail.com
Subject: Buying a home? You deserve the confidence of Pre-Approval
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_Part_75055660_144854819.1698672187348"
List-Unsubscribe: <https://visitor.constantcontact.com/do?p=un&m=beefbeefbeef>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
X-Campaign-Activity-ID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650
X-250ok-CID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650
X-Channel-ID: b1441c50-a541-11ec-a79b-fa163e5bc304
X-Return-Path-Hint: AbeefbeefbeefbeefbeefUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com
X-Roving-Campaignid: 1140728754811
X-Roving-Id: 1133104752381.1111111111
X-Feedback-ID: b1441c50-a541-11ec-beef-beefbeefbeefbeef5de2a-5c88-44b1-be0e-f5a444cb0650:1133104752381:CTCT
X-CTCT-ID: b13a9586-a541-11ec-beef-beefbeefbeef
------=_Part_75055660_144854819.1698672187348
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
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* G=
et Pre-Approved When you're buying a home, Pre-Approval gives you confidenc=
e you're in the right price range and shows sellers you mean business. xxx=
xxxxxxGet Pre-Approved today! Click or Call to Get Pre-Approved 844-590-227=
5 Get Pre-Approved nmlsconsumeraccess.org/ *The 24 hour timeframe is for mo=
st approvals, however if additional information is needed or a request is o=
n a holiday, the time for preapproval may be greater than 24 hours. This em=
ail is for informational purposes only and is not an offer, loan approval o=
r loan commitment. Mortgage rates are subject to change without notice. Som=
e terms and restrictions may apply to certain loan programs. Refinancing ex=
isting loans may result in total finance charges being higher over the life=
of the loan, reduction in payments may partially reflect a longer loan ter=
m. This information is provided as guidance and illustrative purposes only =
and does not constitute legal or financial advice. We are not liable or bou=
nd 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 periodi=
cally and posted in the online forum. spamspam Loan Servicing, LLC. NMLS#39=
1521. nmlsconsumeraccess.org. You are receiving this information as a curre=
nt loan customer with spamspam Loan Servicing, LLC. Not licensed for lendin=
g activities in any of the U.S. territories. Not authorized to originate lo=
ans in the State of New York. Licensed by the Dept. of Financial Protection=
and Innovation under the California Residential Mortgage .Lending Act #413=
1216. This email was sent to somebody@gmail.com Version 103023PCHPrAp=
9 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@spamspa=
m.com
------=_Part_75055660_144854819.1698672187348
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE HTML>
<html lang=3D"en-US"> <head> <meta http-equiv=3D"Content-Type" content=3D"=
text/html; charset=3Dutf-8"> <meta name=3D"viewport" content=3D"width=3Ddev=
ice-width, initial-scale=3D1, maximum-scale=3D1"> <style type=3D"text/css=
" data-premailer=3D"ignore">=20
@media only screen and (max-width:480px) { .footer-main-width { width: 100%=
!important; } .footer-mobile-hidden { display: none !important; } .foote=
r-mobile-hidden { display: none !important; } .footer-column { display: bl=
ock !important; } .footer-mobile-stack { display: block !important; } .fo=
oter-mobile-stack-padding { padding-top: 3px; } }=20
/* IE: correctly scale images with w/h attbs */ img { -ms-interpolation-mod=
e: bicubic; }=20
.layout { min-width: 100%; }=20
table { table-layout: fixed; } .shell_outer-row { table-layout: auto; }=20
/* Gmail/Web viewport fix */ u + .body .shell_outer-row { width: 620px; }=
=20
/* LIST AND p STYLE OVERRIDES */ .text .text_content-cell p { margin: 0; pa=
dding: 0; margin-bottom: 0; } .text .text_content-cell ul, .text .text_cont=
ent-cell ol { padding: 0; margin: 0 0 0 40px; } .text .text_content-cell li=
{ padding: 0; margin: 0; /* line-height: 1.2; Remove after testing */ } /*=
Text Link Style Reset */ a { text-decoration: underline; } /* iOS: Autolin=
k styles inherited */ a[x-apple-data-detectors] { text-decoration: underlin=
e !important; font-size: inherit !important; font-family: inherit !importan=
t; font-weight: inherit !important; line-height: inherit !important; color:=
inherit !important; } /* FF/Chrome: Smooth font rendering */ .text .text_c=
ontent-cell { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing:=
grayscale; }=20
</style> <!--[if gte mso 9]> <style id=3D"ol-styles">=20
/* OUTLOOK-SPECIFIC STYLES */ li { text-indent: -1em; padding: 0; margin: 0=
; /* line-height: 1.2; Remove after testing */ } ul, ol { padding: 0; margi=
n: 0 0 0 40px; } p { margin: 0; padding: 0; margin-bottom: 0; }=20
</style> <![endif]--> <style>@media only screen and (max-width:480px) {
.button_content-cell {
padding-top: 10px !important; padding-right: 20px !important; padding-botto=
m: 10px !important; padding-left: 20px !important;
}
.button_border-row .button_content-cell {
padding-top: 10px !important; padding-right: 20px !important; padding-botto=
m: 10px !important; padding-left: 20px !important;
}
.column .content-padding-horizontal {
padding-left: 20px !important; padding-right: 20px !important;
}
.layout .column .content-padding-horizontal .content-padding-horizontal {
padding-left: 0px !important; padding-right: 0px !important;
}
.layout .column .content-padding-horizontal .block-wrapper_border-row .cont=
ent-padding-horizontal {
padding-left: 20px !important; padding-right: 20px !important;
}
.dataTable {
overflow: auto !important;
}
.dataTable .dataTable_content {
width: auto !important;
}
.image--mobile-scale .image_container img {
width: auto !important;
}
.image--mobile-center .image_container img {
margin-left: auto !important; margin-right: auto !important;
}
.layout-margin .layout-margin_cell {
padding: 0px 20px !important;
}
.layout-margin--uniform .layout-margin_cell {
padding: 20px 20px !important;
}
.scale {
width: 100% !important;
}
.stack {
display: block !important; box-sizing: border-box;
}
.hide {
display: none !important;
}
u + .body .shell_outer-row {
width: 100% !important;
}
.socialFollow_container {
text-align: center !important;
}
.text .text_content-cell {
font-size: 16px !important;
}
.text .text_content-cell h1 {
font-size: 24px !important;
}
.text .text_content-cell h2 {
font-size: 20px !important;
}
.text .text_content-cell h3 {
font-size: 20px !important;
}
.text--sectionHeading .text_content-cell {
font-size: 26px !important;
}
.text--heading .text_content-cell {
font-size: 26px !important;
}
.text--feature .text_content-cell h2 {
font-size: 20px !important;
}
.text--articleHeading .text_content-cell {
font-size: 20px !important;
}
.text--article .text_content-cell h3 {
font-size: 20px !important;
}
.text--featureHeading .text_content-cell {
font-size: 20px !important;
}
.text--feature .text_content-cell h3 {
font-size: 20px !important;
}
.text--dataTable .text_content-cell .dataTable .dataTable_content-cell {
font-size: 12px !important;
}
.text--dataTable .text_content-cell .dataTable th.dataTable_content-cell {
font-size: px !important;
}
}
</style>
</head> <body class=3D"body template template--en-US" data-template-version=
=3D"1.38.0" data-canonical-name=3D"CPE10001" lang=3D"en-US" align=3D"center=
" style=3D"-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; min-=
width: 100%; width: 100%; margin: 0px; padding: 0px;"> <div id=3D"preheader=
" style=3D"color: transparent; display: none; font-size: 1px; line-height: =
1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;"><span =
data-entity-ref=3D"preheader">When you&#x27;re buying a home, Pre-Approval =
gives you confidence you&#x27;re in the right price range and shows sellers=
you mean business. </span></div> <div id=3D"tracking-image" style=3D"color=
: transparent; display: none; font-size: 1px; line-height: 1px; max-height:=
0px; max-width: 0px; opacity: 0; overflow: hidden;"><img src=3D"https://r2=
0.rs6.net/on.jsp?ca=beefbeefbe-beef-44b1-be0e-f5a444cb0650&a=3D113310475238=
1&c=3Db13a9586-a541-11ec-a79b-fa163e5bc304&ch=3Db1441c50-a541-11ec-a79b-fa1=
63e5bc304" / alt=3D""></div> <div class=3D"shell" lang=3D"en-US" style=3D"b=
ackground-color: #015288;"> <table class=3D"shell_panel-row" width=3D"100%=
" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"background-colo=
r: #015288;" bgcolor=3D"#015288"> <tr class=3D""> <td class=3D"shell_panel-=
cell" style=3D"" align=3D"center" valign=3D"top"> <table class=3D"shell_wid=
th-row scale" style=3D"width: 620px;" align=3D"center" border=3D"0" cellpad=
ding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"shell_width-cell" style=3D"=
padding: 15px 10px;" align=3D"center" valign=3D"top"> <table class=3D"shell=
_content-row" width=3D"100%" align=3D"center" border=3D"0" cellpadding=3D"0=
" cellspacing=3D"0"> <tr> <td class=3D"shell_content-cell" style=3D"border-=
radius: 0px; background-color: #FFFFFF; padding: 0; border: 0px solid #0096=
d6;" align=3D"center" valign=3D"top" bgcolor=3D"#FFFFFF"> <table class=3D"l=
ayout layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" borde=
r=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colum=
n--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"divider" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0"=
border=3D"0"> <tr> <td class=3D"divider_container" style=3D"padding-top: 0=
px; padding-bottom: 10px;" width=3D"100%" align=3D"center" valign=3D"top"> =
<table class=3D"divider_content-row" style=3D"width: 100%; height: 1px;" ce=
llpadding=3D"0" cellspacing=3D"0" border=3D"0"> <tr> <td class=3D"divider_c=
ontent-cell" style=3D"padding-bottom: 5px; height: 1px; line-height: 1px; b=
ackground-color: #0096D6; border-bottom-width: 0px;" height=3D"1" align=3D"=
center" bgcolor=3D"#0096D6"> <img alt=3D"" width=3D"5" height=3D"1" border=
=3D"0" hspace=3D"0" vspace=3D"0" src=3D"https://imgssl.constantcontact.com/=
letters/images/1101116784221/S.gif" style=3D"display: block; height: 1px; w=
idth: 5px;"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=
<table class=3D"layout layout--1-column" style=3D"table-layout: fixed;" wi=
dth=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td cla=
ss=3D"column column--1 scale stack" style=3D"width: 100%;" align=3D"center"=
valign=3D"top"><div class=3D"spacer" style=3D"line-height: 10px; height: 1=
0px;">&#x200a;</div></td> </tr> </table> <table class=3D"layout layout--1-c=
olumn" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpaddi=
ng=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack"=
style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"image image--padding-vertical image--mobile-scale image--mo=
bile-center" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0=
"> <tr> <td class=3D"image_container" align=3D"center" valign=3D"top" style=
=3D"padding-top: 10px; padding-bottom: 10px;"> <a href=3D"https://r20.rs6.n=
et/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvTEiCsv-QEMhmJe9=
ch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D" data-tra=
ckable=3D"true"><img data-image-content class=3D"image_content" width=3D"26=
2" src=3D"https://files.constantcontact.com/beefbeefbee/057bff2a-bdba-4165-=
b108-a7baa91c42c6.jpg" alt=3D"" style=3D"display: block; height: auto; max-=
width: 100%;"></a> </td> </tr> </table> </td> </tr> </table> <table class=
=3D"layout layout--heading layout--1-column" style=3D"background-color: #00=
527e; table-layout: fixed;" width=3D"100%" border=3D"0" cellpadding=3D"0" c=
ellspacing=3D"0" bgcolor=3D"#00527e"> <tr> <td class=3D"column column--1 sc=
ale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
center; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; f=
ont-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; pa=
dding: 10px 20px;" align=3D"center" valign=3D"top">
<h1 style=3D"font-family: Arial,Verdana,Helvetica,sans-serif; color: #606d7=
8; font-size: 26px; font-weight: bold; margin: 0;"><span style=3D"color: rg=
b(0, 150, 214);">SELLING or BUYING?</span></h1>
<p style=3D"margin: 0;"><span style=3D"font-size: 16px; color: rgb(255, 255=
, 255); font-weight: bold;">Call: 844-590-2275</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--ar=
ticle layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" borde=
r=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colum=
n--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--heading text--padding-vertical" width=3D"100%" b=
order=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixe=
d;"> <tr> <td class=3D"text_content-cell content-padding-horizontal" style=
=3D"text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif; co=
lor: #606d78; font-size: 26px; line-height: 1.2; display: block; word-wrap:=
break-word; font-weight: bold; padding: 10px 20px;" align=3D"center" valig=
n=3D"top">
<p style=3D"margin: 0;"><span style=3D"font-size: 30px; color: rgb(0, 150, =
214);">Get Your Homebuying</span></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 30px; color: rgb(0, 82, 1=
26);">PRE-APPROVAL IN 24-HOURS</span><span style=3D"font-size: 30px; color:=
rgb(0, 82, 126); font-weight: normal;">*</span></p>
</td> </tr> </table> <table class=3D"image image--padding-vertical image--m=
obile-scale image--mobile-center" width=3D"100%" border=3D"0" cellpadding=
=3D"0" cellspacing=3D"0"> <tr> <td class=3D"image_container content-padding=
-horizontal" align=3D"center" valign=3D"top" style=3D"padding: 10px 20px;">=
<img data-image-content class=3D"image_content" width=3D"548" src=3D"https=
://files.constantcontact.com/df66e42d701/2092a2d7-0bda-4289-910b-bf50a2398d=
60.jpg" alt=3D"" style=3D"display: block; height: auto; max-width: 100%;"> =
</td> </tr> </table> <table class=3D"button button--padding-vertical" widt=
h=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"table-=
layout: fixed;"> <tr> <td class=3D"button_container content-padding-horizon=
tal" align=3D"center" style=3D"padding: 10px 20px;"> <table class=3D"but=
ton_content-row" style=3D"width: inherit; border-radius: 3px; border-spacin=
g: 0; background-color: #0096D6; border: none;" border=3D"0" cellpadding=3D=
"0" cellspacing=3D"0" bgcolor=3D"#0096D6"> <tr> <td class=3D"button_content=
-cell" style=3D"padding: 10px 40px;" align=3D"center"> <a class=3D"button_l=
ink" href=3D"https://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEG=
O0v-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCias4f7-QkeggQvxIvbs-6TTaZHHhXLKf88NID=
dci4Ge7aYN-QihEgqblie1-DQ2Fa1BKLbT3AM8rtrgeYQgVxJ6cG8POsvFzv7JstrGkCkg3a3AE=
633LfQpAddyVLFkTv6oyS4T2j_YjYIPKDOZktqK_5rOR-Fh8cWGtUD8YPpPNnZ037z6_t9Nkemu=
hxG&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DH=
u9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D" data-trackable=
=3D"true" style=3D"font-size: 16px; font-weight: bold; color: #FFFFFF; font=
-family: Helvetica,Arial,sans-serif; word-wrap: break-word; text-decoration=
: none;">Get Pre-Approved</a> </td> </tr> </table> </td> </tr> </table> =
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" =
cellpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <t=
d class=3D"text_content-cell content-padding-horizontal" style=3D"line-heig=
ht: 1; text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif;=
color: #000000; font-size: 14px; display: block; word-wrap: break-word; pa=
dding: 10px 20px;" align=3D"center" valign=3D"top">
<p style=3D"text-align: left; margin: 0;" align=3D"left"><br></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 19px;">When you're buying=
a home, Pre-Approval gives you confidence you're in the right price range =
and shows sellers you mean business. </span></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 19px;">&#xfeff;Get Pre-Ap=
proved today!</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=
t-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=
ing: 10px 20px;" align=3D"left" valign=3D"top">
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 23px; color: rgb(0, 82, 126); font-weight: bold; font-family: A=
rial, Verdana, Helvetica, sans-serif;">Click or Call to Get Pre-Approved </=
span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 28px; color: rgb(0, 150, 214); font-weight: bold;">844-590-2275=
</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top"> <table class=3D"b=
utton button--padding-vertical" width=3D"100%" border=3D"0" cellpadding=3D"=
0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td class=3D"butt=
on_container content-padding-horizontal" align=3D"center" style=3D"padding:=
10px 20px;"> <table class=3D"button_content-row" style=3D"background-co=
lor: #0096D6; width: inherit; border-radius: 3px; border-spacing: 0; border=
: none;" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" bgcolor=3D"#0096D=
6"> <tr> <td class=3D"button_content-cell" style=3D"padding: 10px 40px;" al=
ign=3D"center"> <a class=3D"button_link" href=3D"https://r20.rs6.net/tn.jsp=
?f=3D001thisisfakethisisfakethisisfakev-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCi=
oVIo_si10jiydw=3D=3D" data-trackable=3D"true" style=3D"font-size: 16px; fon=
t-weight: bold; color: #FFFFFF; font-family: Helvetica,Arial,sans-serif; wo=
rd-wrap: break-word; text-decoration: none;">Get Pre-Approved</a> </td> </t=
r> </table> </td> </tr> </table> </td> </tr> </table> <table class=3D"=
layout layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" bord=
er=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colu=
mn--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"image image--padding-vertical image--mobile-scale image--mo=
bile-center" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0=
"> <tr> <td class=3D"image_container" align=3D"center" valign=3D"top" style=
=3D"padding-top: 10px; padding-bottom: 10px;"> <img data-image-content clas=
s=3D"image_content" width=3D"87" src=3D"https://files.constantcontact.com/d=
f66e42d701/beefbeef-beef-beef-9a13-2779ab497b8d.png" alt=3D"" style=3D"disp=
lay: block; height: auto; max-width: 100%;"> </td> </tr> </table> </td> </t=
r> </table> <table class=3D"layout layout--1-column" style=3D"table-layout:=
fixed;" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <=
tr> <td class=3D"column column--1 scale stack" style=3D"width: 100%;" align=
=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=
t-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=
ing: 10px 20px;" align=3D"left" valign=3D"top">
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><a href=3D"htt=
ps://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvT=
EiCsv-QEMgYju54LKeEV1_a2OCyOAfG7VhZpxtOW89WM-s6S5iiXcmnbK-Z6XDc9LL569h6DE4L=
IRMWiBWHOlFB9TZWQVuX6Ycz3505y1keCrca4QArp&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q=
8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTg=
RJoVIo_si10jiydw=3D=3D" target=3D"_blank" style=3D"font-size: 11px; color: =
rgb(153, 153, 153); text-decoration: underline; font-weight: normal; font-s=
tyle: normal;">nmlsconsumeraccess.org/</a></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">*The 24 hour timeframe is for=
most approvals, however if additional information is needed or a request i=
s on a holiday, the time for preapproval may be greater than 24 hours.</spa=
n></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=
, 255);">This email is for informational purposes only and is not an offer,=
loan approval or loan commitment. Mortgage rates are subject to change wit=
hout notice. Some terms and restrictions may apply to certain loan programs=
. Refinancing existing loans may result in total finance charges being high=
er over the life of the loan, reduction in payments may partially reflect a=
longer loan term. This information is provided as guidance and illustrativ=
e purposes only and does not constitute legal or financial advice. We are n=
ot liable or bound legally for any answers provided to any user for our pro=
cess 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.</span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=
, 255);">spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org.=
You are receiving this information as a current loan customer with spamspa=
m 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 Cal=
ifornia Residential Mortgage .Lending Act #4131216.</span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">This email was sent to <span =
data-id=3D"emailAddress">somebody@gmail.com</span></span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">Version 103023PCHPrAp9 </span=
></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(162, 162, 162);">&#xfeff;</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"divider" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0"=
border=3D"0"> <tr> <td class=3D"divider_container" style=3D"padding-top: 1=
0px; padding-bottom: 0px;" width=3D"100%" align=3D"center" valign=3D"top"> =
<table class=3D"divider_content-row" style=3D"width: 100%; height: 1px;" ce=
llpadding=3D"0" cellspacing=3D"0" border=3D"0"> <tr> <td class=3D"divider_c=
ontent-cell" style=3D"padding-bottom: 2px; height: 1px; line-height: 1px; b=
ackground-color: #0096D6; border-bottom-width: 0px;" height=3D"1" align=3D"=
center" bgcolor=3D"#0096D6"> <img alt=3D"" width=3D"5" height=3D"1" border=
=3D"0" hspace=3D"0" vspace=3D"0" src=3D"https://imgssl.constantcontact.com/=
letters/images/1111111111111/S.gif" style=3D"display: block; height: 1px; w=
idth: 5px;"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=
</td> </tr> </table> </td> </tr> </table> </td> </tr> <tr> <td class=3D"s=
hell_panel-cell shell_panel-cell--systemFooter" style=3D"" align=3D"center"=
valign=3D"top"> <table class=3D"shell_width-row scale" style=3D"width: 100=
%;" align=3D"center" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr>=
<td class=3D"shell_width-cell" style=3D"padding: 0px;" align=3D"center" va=
lign=3D"top"> <table class=3D"shell_content-row" width=3D"100%" align=3D"ce=
nter" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"s=
hell_content-cell" style=3D"background-color: #FFFFFF; padding: 0; border: =
0 solid #0096d6;" align=3D"center" valign=3D"top" bgcolor=3D"#FFFFFF"> <tab=
le class=3D"layout layout--1-column" style=3D"table-layout: fixed;" width=
=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=
=3D"column column--1 scale stack" style=3D"width: 100%;" align=3D"center" v=
align=3D"top"> <table class=3D"footer" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0" style=3D"font-family: Verdana,Geneva,sans-serif=
; color: #5d5d5d; font-size: 12px;"> <tr> <td class=3D"footer_container" al=
ign=3D"center"> <table class=3D"footer-container" width=3D"100%" cellpaddin=
g=3D"0" cellspacing=3D"0" border=3D"0" style=3D"background-color: #ffffff; =
margin-left: auto; margin-right: auto; table-layout: auto !important;" bgco=
lor=3D"#ffffff">
<tr>
<td width=3D"100%" align=3D"center" valign=3D"top" style=3D"width: 100%;">
<div class=3D"footer-max-main-width" align=3D"center" style=3D"margin-left:=
auto; margin-right: auto; max-width: 100%;">
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td class=3D"footer-layout" align=3D"center" valign=3D"top" style=3D"paddin=
g: 16px 0px;">
<table class=3D"footer-main-width" style=3D"width: 580px;" border=3D"0" cel=
lpadding=3D"0" cellspacing=3D"0">
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<span class=3D"footer-column">spamspam Loan Servicing<span class=3D"footer-=
mobile-hidden"> | </span></span><span class=3D"footer-column">4425 Ponce de=
Leon Blvd 5-251<span class=3D"footer-mobile-hidden">, </span></span><span =
class=3D"footer-column"></span><span class=3D"footer-column"></span><span c=
lass=3D"footer-column">Coral Gables, FL 33146-1837</span><span class=3D"foo=
ter-column"></span>
</td>
</tr>
<tr>
<td class=3D"footer-row" align=3D"center" valign=3D"top" style=3D"padding: =
10px 0px;">
<table cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<a href=3D"https://visitor.constantcontact.com/do?p=3Dun&m=3D001g3dtlqhzM3v=
-44b1-be0e-f5a444cb0650" data-track=3D"false" style=3D"color: #5d5d5d;">Uns=
ubscribe somebody@gmail.com<span class=3D"partnerOptOut"></span></a>
<span class=3D"partnerOptOut"></span>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<a href=3D"https://visitor.constantcontact.com/do?p=3Doo&m=3D001g3dtlqhzM3v=
-44b1-be0e-f5a444cb0650" data-track=3D"false" style=3D"color: #5d5d5d;">Upd=
ate Profile</a> |
<a href=3D"https://spamspam.com/privacy-notice/" data-track=3D"false" style=
=3D"color: #5d5d5d;">Our Privacy Policy</a><span class=3D"footer-mobile-hid=
den"> |</span>
<a class=3D"footer-about-provider footer-mobile-stack footer-mobile-stack-p=
adding" href=3D"http://www.constantcontact.com/legal/about-constant-contact=
" data-track=3D"false" style=3D"color: #5d5d5d;">Constant Contact Data Noti=
ce</a>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
Sent by
<a href=3D"mailto:marklake@spamspam.com" style=3D"color: #5d5d5d; text-deco=
ration: none;">marklake@spamspam.com</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table> =
</td> </tr> </table> </td> </tr> </table> </div> </body> </html>
------=_Part_75055660_144854819.1698672187348--
.
`
func TestSmtpBackend_Spam_Text(t *testing.T) {
email := spamEmail
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Buying a home? You deserve the confidence of Pre-Approval", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := "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"
require.Equal(t, expected, actual)
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Spam_HTML(t *testing.T) {
email := strings.ReplaceAll(spamEmail, "text/plain", "text/not-plain-anymore") // We artificially force HTML parsing here
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Buying a home? You deserve the confidence of Pre-Approval", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `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.
` + "\u200a" + `
SELLING or BUYING?
Call: 844-590-2275
Get Your Homebuying
PRE-APPROVAL IN 24-HOURS *
Get Pre-Approved
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.
` + "\ufeff" + `Get 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
` + "\ufeff" + `
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`
require.Equal(t, expected, actual)
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_HTMLOnly_FromDiskStation(t *testing.T) {
email := `EHLO example.com
MAIL FROM: synology@mydomain.me
RCPT TO: synology@mydomain.me
DATA
From: "=?UTF-8?B?Um9iYmll?=" <synology@mydomain.me>
To: <synology@mydomain.me>
Message-Id: <640e6f562895d.6c9584bcfa491ac9c546b480b32ffc1d@mydomain.me>
MIME-Version: 1.0
Subject: =?UTF-8?B?W1N5bm9sb2d5IE5BU10gVGVzdCBNZXNzYWdlIGZyb20gTGl0dHNfTkFT?=
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 8bit
Congratulations! 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>
.
`
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/synology", r.URL.Path)
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS`
require.Equal(t, expected, actual)
})
conf.SMTPServerDomain = "mydomain.me"
conf.SMTPServerAddrPrefix = ""
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) { func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com email := `EHLO example.com
MAIL FROM: phil@example.com MAIL FROM: phil@example.com
@ -639,7 +1436,6 @@ func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expected
return return
} }
output += text + "\n" output += text + "\n"
//fmt.Println(text)
} }
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output) t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
} }

View file

@ -5,8 +5,8 @@ import (
"sync" "sync"
"time" "time"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
const ( const (

View file

@ -69,7 +69,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
t.Parallel() t.Parallel()
to := newTopic("mytopic") to := newTopic("mytopic")
// Fix random seed to force same number generation //lint:ignore SA1019 Fix random seed to force same number generation
rand.Seed(1) rand.Seed(1)
a := rand.Int() a := rand.Int()
to.subscribers[a] = &topicSubscriber{ to.subscribers[a] = &topicSubscriber{
@ -82,7 +82,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
return nil return nil
} }
// Force rand.Int to generate the same id once more //lint:ignore SA1019 Force rand.Int to generate the same id once more
rand.Seed(1) rand.Seed(1)
id := to.Subscribe(subFn, "b", func() {}) id := to.Subscribe(subFn, "b", func() {})
res := to.subscribers[id] res := to.subscribers[id]

View file

@ -5,10 +5,10 @@ import (
"net/netip" "net/netip"
"time" "time"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
// List of possible events // List of possible events

View file

@ -3,15 +3,19 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"io" "io"
"mime" "mime"
"net/http" "net/http"
"net/netip" "net/netip"
"regexp"
"strings" "strings"
) )
var mimeDecoder mime.WordDecoder var (
mimeDecoder mime.WordDecoder
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
value := strings.ToLower(readParam(r, names...)) value := strings.ToLower(readParam(r, names...))
@ -50,9 +54,9 @@ func readParam(r *http.Request, names ...string) string {
func readHeaderParam(r *http.Request, names ...string) string { func readHeaderParam(r *http.Request, names ...string) string {
for _, name := range names { for _, name := range names {
value := maybeDecodeHeader(r.Header.Get(name)) value := strings.TrimSpace(maybeDecodeHeader(name, r.Header.Get(name)))
if value != "" { if value != "" {
return strings.TrimSpace(value) return value
} }
} }
return "" return ""
@ -126,10 +130,26 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
return t, nil return t, nil
} }
func maybeDecodeHeader(header string) string { // maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
decoded, err := mimeDecoder.DecodeHeader(header) // or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
// to ignore new HTTP "Priority" header.
func maybeDecodeHeader(name, value string) string {
decoded, err := mimeDecoder.DecodeHeader(value)
if err != nil { if err != nil {
return header return maybeIgnoreSpecialHeader(name, value)
} }
return decoded return maybeIgnoreSpecialHeader(name, decoded)
}
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
//
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
// Returning an empty string will allow the rest of the logic to continue searching for another header (x-priority, prio, p),
// or in the Query parameters.
func maybeIgnoreSpecialHeader(name, value string) string {
if strings.ToLower(name) == "priority" && priorityHeaderIgnoreRegex.MatchString(strings.TrimSpace(value)) {
return ""
}
return value
} }

View file

@ -2,9 +2,9 @@ package server
import ( import (
"bytes" "bytes"
"crypto/rand"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"math/rand"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@ -75,3 +75,16 @@ Accept: */*
(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)` (peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)`
require.Equal(t, expected, renderHTTPRequest(r)) require.Equal(t, expected, renderHTTPRequest(r))
} }
func TestMaybeIgnoreSpecialHeader(t *testing.T) {
require.Empty(t, maybeIgnoreSpecialHeader("priority", "u=1"))
require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1"))
require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1, i"))
}
func TestMaybeDecodeHeaders(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.Header.Set("Priority", "u=1") // Cloudflare priority header
r.Header.Set("X-Priority", "5") // ntfy priority header
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
}

View file

@ -2,14 +2,14 @@ package server
import ( import (
"fmt" "fmt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/v2/user"
"net/netip" "net/netip"
"sync" "sync"
"time" "time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
) )
const ( const (

View file

@ -3,7 +3,7 @@ package server
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
"time" "time"

View file

@ -2,18 +2,13 @@ package test
import ( import (
"fmt" "fmt"
"heckel.io/ntfy/server" "heckel.io/ntfy/v2/server"
"math/rand" "math/rand"
"net/http" "net/http"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func init() {
rand.Seed(time.Now().UnixMilli())
}
// StartServer starts a server.Server with a random port and waits for the server to be up // StartServer starts a server.Server with a random port and waits for the server to be up
func StartServer(t *testing.T) (*server.Server, int) { func StartServer(t *testing.T) (*server.Server, int) {
return StartServerWithConfig(t, server.NewConfig()) return StartServerWithConfig(t, server.NewConfig())

View file

@ -9,8 +9,8 @@ import (
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
"strings" "strings"
"sync" "sync"
@ -160,8 +160,8 @@ const (
SELECT read, write SELECT read, write
FROM user_access a FROM user_access a
JOIN user u ON u.id = a.user_id JOIN user u ON u.id = a.user_id
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
ORDER BY u.user DESC ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
` `
insertUserQuery = ` insertUserQuery = `
@ -197,13 +197,13 @@ const (
selectUserAllAccessQuery = ` selectUserAllAccessQuery = `
SELECT user_id, topic, read, write SELECT user_id, topic, read, write
FROM user_access FROM user_access
ORDER BY write DESC, read DESC, topic ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
` `
selectUserAccessQuery = ` selectUserAccessQuery = `
SELECT topic, read, write SELECT topic, read, write
FROM user_access FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?) WHERE user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY write DESC, read DESC, topic ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
` `
selectUserReservationsQuery = ` selectUserReservationsQuery = `
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
@ -235,7 +235,7 @@ const (
selectOtherAccessCountQuery = ` selectOtherAccessCountQuery = `
SELECT COUNT(*) SELECT COUNT(*)
FROM user_access FROM user_access
WHERE (topic = ? OR ? LIKE topic) WHERE (topic = ? OR ? LIKE topic ESCAPE '\')
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
` `
deleteAllAccessQuery = `DELETE FROM user_access` deleteAllAccessQuery = `DELETE FROM user_access`
@ -262,7 +262,8 @@ const (
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
deleteExcessTokensQuery = ` deleteExcessTokensQuery = `
DELETE FROM user_token DELETE FROM user_token
WHERE (user_id, token) NOT IN ( WHERE user_id = ?
AND (user_id, token) NOT IN (
SELECT user_id, token SELECT user_id, token
FROM user_token FROM user_token
WHERE user_id = ? WHERE user_id = ?
@ -311,7 +312,7 @@ const (
// Schema management queries // Schema management queries
const ( const (
currentSchemaVersion = 4 currentSchemaVersion = 5
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@ -421,6 +422,11 @@ const (
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
` `
// 4 -> 5
migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
) )
var ( var (
@ -428,6 +434,7 @@ var (
1: migrateFrom1, 1: migrateFrom1,
2: migrateFrom2, 2: migrateFrom2,
3: migrateFrom3, 3: migrateFrom3,
4: migrateFrom4,
} }
) )
@ -534,7 +541,7 @@ func (a *Manager) CreateToken(userID, label string, expires time.Time, origin ne
if tokenCount >= tokenMaxCount { if tokenCount >= tokenMaxCount {
// This pruning logic is done in two queries for efficiency. The SELECT above is a lookup // This pruning logic is done in two queries for efficiency. The SELECT above is a lookup
// on two indices, whereas the query below is a full table scan. // on two indices, whereas the query below is a full table scan.
if _, err := tx.Exec(deleteExcessTokensQuery, userID, tokenMaxCount); err != nil { if _, err := tx.Exec(deleteExcessTokensQuery, userID, userID, tokenMaxCount); err != nil {
return nil, err return nil, err
} }
} }
@ -826,8 +833,10 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
if user != nil { if user != nil {
username = user.Name username = user.Name
} }
// Select the read/write permissions for this user/topic combo. The query may return two // Select the read/write permissions for this user/topic combo.
// rows (one for everyone, and one for the user), but prioritizes the user. // - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions
rows, err := a.db.Query(selectTopicPermsQuery, Everyone, username, topic) rows, err := a.db.Query(selectTopicPermsQuery, Everyone, username, topic)
if err != nil { if err != nil {
return err return err
@ -1122,7 +1131,7 @@ func (a *Manager) Reservations(username string) ([]Reservation, error) {
return nil, err return nil, err
} }
reservations = append(reservations, Reservation{ reservations = append(reservations, Reservation{
Topic: topic, Topic: unescapeUnderscore(topic),
Owner: NewPermission(ownerRead, ownerWrite), Owner: NewPermission(ownerRead, ownerWrite),
Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), // false if null Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), // false if null
}) })
@ -1132,7 +1141,7 @@ func (a *Manager) Reservations(username string) ([]Reservation, error) {
// HasReservation returns true if the given topic access is owned by the user // HasReservation returns true if the given topic access is owned by the user
func (a *Manager) HasReservation(username, topic string) (bool, error) { func (a *Manager) HasReservation(username, topic string) (bool, error) {
rows, err := a.db.Query(selectUserHasReservationQuery, username, topic) rows, err := a.db.Query(selectUserHasReservationQuery, username, escapeUnderscore(topic))
if err != nil { if err != nil {
return false, err return false, err
} }
@ -1167,7 +1176,7 @@ func (a *Manager) ReservationsCount(username string) (int64, error) {
// ReservationOwner returns user ID of the user that owns this topic, or an // ReservationOwner returns user ID of the user that owns this topic, or an
// empty string if it's not owned by anyone // empty string if it's not owned by anyone
func (a *Manager) ReservationOwner(topic string) (string, error) { func (a *Manager) ReservationOwner(topic string) (string, error) {
rows, err := a.db.Query(selectUserReservationsOwnerQuery, topic) rows, err := a.db.Query(selectUserReservationsOwnerQuery, escapeUnderscore(topic))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -1262,7 +1271,7 @@ func (a *Manager) AllowReservation(username string, topic string) error {
if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) {
return ErrInvalidArgument return ErrInvalidArgument
} }
rows, err := a.db.Query(selectOtherAccessCountQuery, topic, topic, username) rows, err := a.db.Query(selectOtherAccessCountQuery, escapeUnderscore(topic), escapeUnderscore(topic), username)
if err != nil { if err != nil {
return err return err
} }
@ -1327,10 +1336,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.Exec(upsertUserAccessQuery, username, topic, true, true, username, username); err != nil { if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil {
return err return err
} }
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, username); err != nil { if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@ -1353,10 +1362,10 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
} }
defer tx.Rollback() defer tx.Rollback()
for _, topic := range topics { for _, topic := range topics {
if _, err := tx.Exec(deleteTopicAccessQuery, username, username, topic); err != nil { if _, err := tx.Exec(deleteTopicAccessQuery, username, username, escapeUnderscore(topic)); err != nil {
return err return err
} }
if _, err := tx.Exec(deleteTopicAccessQuery, Everyone, Everyone, topic); err != nil { if _, err := tx.Exec(deleteTopicAccessQuery, Everyone, Everyone, escapeUnderscore(topic)); err != nil {
return err return err
} }
} }
@ -1483,12 +1492,24 @@ func (a *Manager) Close() error {
return a.db.Close() return a.db.Close()
} }
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string { func toSQLWildcard(s string) string {
return strings.ReplaceAll(s, "*", "%") return escapeUnderscore(strings.ReplaceAll(s, "*", "%"))
} }
// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*',
// and removes the '\_' escape character.
func fromSQLWildcard(s string) string { func fromSQLWildcard(s string) string {
return strings.ReplaceAll(s, "%", "*") return strings.ReplaceAll(unescapeUnderscore(s), "%", "*")
}
func escapeUnderscore(s string) string {
return strings.ReplaceAll(s, "_", "\\_")
}
func unescapeUnderscore(s string) string {
return strings.ReplaceAll(s, "\\_", "_")
} }
func runStartupQueries(db *sql.DB, startupQueries string) error { func runStartupQueries(db *sql.DB, startupQueries string) error {
@ -1626,6 +1647,22 @@ func migrateFrom3(db *sql.DB) error {
return tx.Commit() return tx.Commit()
} }
func migrateFrom4(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString { func nullString(s string) sql.NullString {
if s == "" { if s == "" {
return sql.NullString{} return sql.NullString{}

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"strings" "strings"
@ -20,10 +20,15 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", RoleUser)) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("john", "john", RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair!
require.Nil(t, a.AllowAccess("john", "*", PermissionRead))
require.Nil(t, a.AllowAccess("john", "mytopic*", PermissionReadWrite))
require.Nil(t, a.AllowAccess("john", "mytopic_ro*", PermissionRead))
require.Nil(t, a.AllowAccess("john", "mytopic_deny*", PermissionDenyAll))
require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead))
require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite))
require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // Everyone can write to /up* require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // Everyone can write to /up*
@ -47,12 +52,27 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite}, {"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite}, {"writeme", PermissionWrite},
{"readme", PermissionRead}, {"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll},
}, benGrants) }, benGrants)
john, err := a.Authenticate("john", "john")
require.Nil(t, err)
require.Equal(t, "john", john.Name)
require.True(t, strings.HasPrefix(john.Hash, "$2a$10$"))
require.Equal(t, RoleUser, john.Role)
johnGrants, err := a.Grants("john")
require.Nil(t, err)
require.Equal(t, []Grant{
{"mytopic_deny*", PermissionDenyAll},
{"mytopic_ro*", PermissionRead},
{"mytopic*", PermissionReadWrite},
{"*", PermissionRead},
}, johnGrants)
notben, err := a.Authenticate("ben", "this is wrong") notben, err := a.Authenticate("ben", "this is wrong")
require.Nil(t, notben) require.Nil(t, notben)
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
@ -78,6 +98,20 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) require.Nil(t, a.Authorize(ben, "announcements", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite))
// User john should have
// "deny" to mytopic_deny*,
// "ro" to mytopic_ro*,
// "rw" to mytopic*,
// "ro" to the rest
require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionWrite))
require.Nil(t, a.Authorize(john, "mytopic_ro_test_case", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_ro_test_case", PermissionWrite))
require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionRead))
require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionWrite))
require.Nil(t, a.Authorize(john, "readme", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(john, "writeme", PermissionWrite))
// Everyone else can do barely anything // Everyone else can do barely anything
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead)) require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite)) require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite))
@ -95,6 +129,22 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite)) require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite))
} }
func TestManager_Access_Order_LengthWriteRead(t *testing.T) {
// This test validates issue #914 / #917, i.e. that write permissions are prioritized over read permissions,
// and longer ACL rules are prioritized as well.
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "*", PermissionRead))
ben, err := a.Authenticate("ben", "ben")
require.Nil(t, err)
require.Nil(t, a.Authorize(ben, "any-topic-can-be-read", PermissionRead))
require.Nil(t, a.Authorize(ben, "this-too", PermissionRead))
require.Nil(t, a.Authorize(ben, "test123", PermissionWrite))
}
func TestManager_AddUser_Invalid(t *testing.T) { func TestManager_AddUser_Invalid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin)) require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
@ -227,10 +277,10 @@ func TestManager_UserManagement(t *testing.T) {
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite}, {"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite}, {"writeme", PermissionWrite},
{"readme", PermissionRead}, {"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll},
}, benGrants) }, benGrants)
everyone, err := a.User(Everyone) everyone, err := a.User(Everyone)
@ -330,7 +380,7 @@ func TestManager_Reservations(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser)) require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser)) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddReservation("ben", "ztopic", PermissionDenyAll)) require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead)) require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
@ -343,7 +393,7 @@ func TestManager_Reservations(t *testing.T) {
Everyone: PermissionRead, Everyone: PermissionRead,
}, reservations[0]) }, reservations[0])
require.Equal(t, Reservation{ require.Equal(t, Reservation{
Topic: "ztopic", Topic: "ztopic_",
Owner: PermissionReadWrite, Owner: PermissionReadWrite,
Everyone: PermissionDenyAll, Everyone: PermissionDenyAll,
}, reservations[1]) }, reservations[1])
@ -352,6 +402,14 @@ func TestManager_Reservations(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.True(t, b) require.True(t, b)
b, err = a.HasReservation("ben", "ztopic_")
require.Nil(t, err)
require.True(t, b)
b, err = a.HasReservation("ben", "ztopicX") // _ != X (used to be a SQL wildcard issue)
require.Nil(t, err)
require.False(t, b)
b, err = a.HasReservation("notben", "readme") b, err = a.HasReservation("notben", "readme")
require.Nil(t, err) require.Nil(t, err)
require.False(t, b) require.False(t, b)
@ -371,11 +429,17 @@ func TestManager_Reservations(t *testing.T) {
err = a.AllowReservation("phil", "readme") err = a.AllowReservation("phil", "readme")
require.Equal(t, errTopicOwnedByOthers, err) require.Equal(t, errTopicOwnedByOthers, err)
err = a.AllowReservation("phil", "ztopic_")
require.Equal(t, errTopicOwnedByOthers, err)
err = a.AllowReservation("phil", "ztopicX")
require.Nil(t, err)
err = a.AllowReservation("phil", "not-reserved") err = a.AllowReservation("phil", "not-reserved")
require.Nil(t, err) require.Nil(t, err)
// Now remove them again // Now remove them again
require.Nil(t, a.RemoveReservations("ben", "ztopic", "readme")) require.Nil(t, a.RemoveReservations("ben", "ztopic_", "readme"))
count, err = a.ReservationsCount("ben") count, err = a.ReservationsCount("ben")
require.Nil(t, err) require.Nil(t, err)
@ -580,46 +644,80 @@ func TestManager_Token_Extend(t *testing.T) {
} }
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
// Tests that tokens are automatically deleted when the maximum number of tokens is reached
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser)) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
// Try to extend token for user without token ben, err := a.User("ben")
u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
// Tokens phil, err := a.User("phil")
require.Nil(t, err)
// Create 2 tokens for phil
philTokens := make([]string, 0)
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err)
require.NotEmpty(t, token.Value)
philTokens = append(philTokens, token.Value)
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
require.Nil(t, err)
require.NotEmpty(t, token.Value)
philTokens = append(philTokens, token.Value)
// Create 22 tokens for ben (only 20 allowed!)
baseTime := time.Now().Add(24 * time.Hour) baseTime := time.Now().Add(24 * time.Hour)
tokens := make([]string, 0) benTokens := make([]string, 0)
for i := 0; i < 22; i++ { for i := 0; i < 22; i++ { //
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
tokens = append(tokens, token.Value) benTokens = append(benTokens, token.Value)
// Manually modify expiry date to avoid sorting issues (this is a hack) // Manually modify expiry date to avoid sorting issues (this is a hack)
_, err = a.db.Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value) _, err = a.db.Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value)
require.Nil(t, err) require.Nil(t, err)
} }
_, err = a.AuthenticateToken(tokens[0]) // Ben: The first 2 tokens should have been wiped and should not work anymore!
_, err = a.AuthenticateToken(benTokens[0])
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
_, err = a.AuthenticateToken(tokens[1]) _, err = a.AuthenticateToken(benTokens[1])
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
// Ben: The other tokens should still work
for i := 2; i < 22; i++ { for i := 2; i < 22; i++ {
userWithToken, err := a.AuthenticateToken(tokens[i]) userWithToken, err := a.AuthenticateToken(benTokens[i])
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i]) require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i])
require.Equal(t, "ben", userWithToken.Name) require.Equal(t, "ben", userWithToken.Name)
require.Equal(t, tokens[i], userWithToken.Token) require.Equal(t, benTokens[i], userWithToken.Token)
} }
var count int // Phil: All tokens should still work
rows, err := a.db.Query(`SELECT COUNT(*) FROM user_token`) for i := 0; i < 2; i++ {
userWithToken, err := a.AuthenticateToken(philTokens[i])
require.Nil(t, err, "token[%d]=%s failed", i, philTokens[i])
require.Equal(t, "phil", userWithToken.Name)
require.Equal(t, philTokens[i], userWithToken.Token)
}
var benCount int
rows, err := a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, ben.ID)
require.Nil(t, err) require.Nil(t, err)
require.True(t, rows.Next()) require.True(t, rows.Next())
require.Nil(t, rows.Scan(&count)) require.Nil(t, rows.Scan(&benCount))
require.Equal(t, 20, count) require.Equal(t, 20, benCount)
var philCount int
rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID)
require.Nil(t, err)
require.True(t, rows.Next())
require.Nil(t, rows.Scan(&philCount))
require.Equal(t, 2, philCount)
} }
func TestManager_EnqueueStats_ResetStats(t *testing.T) { func TestManager_EnqueueStats_ResetStats(t *testing.T) {
@ -944,7 +1042,44 @@ func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890"))
} }
func TestSqliteCache_Migration_From1(t *testing.T) { func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AllowAccess(Everyone, "*_", PermissionRead))
require.Nil(t, a.AllowAccess(Everyone, "__*_", PermissionRead))
require.Nil(t, a.Authorize(nil, "allowed_", PermissionRead))
require.Nil(t, a.Authorize(nil, "__allowed_", PermissionRead))
require.Nil(t, a.Authorize(nil, "_allowed_", PermissionRead)) // The "%" in "%\_" matches the first "_"
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "notallowed", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "_notallowed", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "__notallowed", PermissionRead))
}
func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AllowAccess(Everyone, "mytopic_", PermissionReadWrite))
require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead))
require.Nil(t, a.Authorize(nil, "mytopic_", PermissionWrite))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
}
func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "up%", toSQLWildcard("up*"))
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
require.Equal(t, "foo", toSQLWildcard("foo"))
require.Equal(t, "up*", fromSQLWildcard("up%"))
require.Equal(t, "up_*", fromSQLWildcard("up\\_%"))
require.Equal(t, "foo", fromSQLWildcard("foo"))
require.Equal(t, "up*", fromSQLWildcard(toSQLWildcard("up*")))
require.Equal(t, "up_*", fromSQLWildcard(toSQLWildcard("up_*")))
require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo")))
}
func TestMigrationFrom1(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db") filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
require.Nil(t, err) require.Nil(t, err)
@ -1016,10 +1151,10 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Equal(t, syncTopicLength, len(ben.SyncTopic)) require.Equal(t, syncTopicLength, len(ben.SyncTopic))
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic) require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
require.Equal(t, 2, len(benGrants)) require.Equal(t, 2, len(benGrants))
require.Equal(t, "stats", benGrants[0].TopicPattern) require.Equal(t, "secret", benGrants[0].TopicPattern)
require.Equal(t, PermissionReadWrite, benGrants[0].Allow) require.Equal(t, PermissionRead, benGrants[0].Allow)
require.Equal(t, "secret", benGrants[1].TopicPattern) require.Equal(t, "stats", benGrants[1].TopicPattern)
require.Equal(t, PermissionRead, benGrants[1].Allow) require.Equal(t, PermissionReadWrite, benGrants[1].Allow)
require.Equal(t, "u_everyone", everyone.ID) require.Equal(t, "u_everyone", everyone.ID)
require.Equal(t, Everyone, everyone.Name) require.Equal(t, Everyone, everyone.Name)
@ -1029,6 +1164,152 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Equal(t, PermissionRead, everyoneGrants[0].Allow) require.Equal(t, PermissionRead, everyoneGrants[0].Allow)
} }
func TestMigrationFrom4(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 4" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
calls_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
INSERT INTO schemaVersion (id, version) VALUES (1, 4);
COMMIT;
`)
require.Nil(t, err)
// Insert a few ACL entries
_, err = db.Exec(`
BEGIN;
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);
COMMIT;
`)
require.Nil(t, err)
// Create manager to trigger migration
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
checkSchemaVersion(t, a.db)
// Add another
require.Nil(t, a.AllowAccess(Everyone, "left_*", PermissionReadWrite))
// Check "external view" of grants
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 4, len(everyoneGrants))
require.Equal(t, "mytopic_", everyoneGrants[0].TopicPattern)
require.Equal(t, "down_*", everyoneGrants[1].TopicPattern)
require.Equal(t, "left_*", everyoneGrants[2].TopicPattern)
require.Equal(t, "up*", everyoneGrants[3].TopicPattern)
// Check they are stored correctly in the database
rows, err := db.Query(`SELECT topic FROM user_access WHERE user_id = 'u_everyone' ORDER BY topic`)
require.Nil(t, err)
topicPatterns := make([]string, 0)
for rows.Next() {
var topicPattern string
require.Nil(t, rows.Scan(&topicPattern))
topicPatterns = append(topicPatterns, topicPattern)
}
require.Nil(t, rows.Close())
require.Equal(t, 4, len(topicPatterns))
require.Equal(t, "down\\_%", topicPatterns[0])
require.Equal(t, "left\\_%", topicPatterns[1])
require.Equal(t, "mytopic\\_", topicPatterns[2])
require.Equal(t, "up%", topicPatterns[3])
// Check that ACL works as excepted
require.Nil(t, a.Authorize(nil, "down_123", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "downX123", PermissionRead))
require.Nil(t, a.Authorize(nil, "left_abc", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "leftX123", PermissionRead))
require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead))
require.Nil(t, a.Authorize(nil, "up123", PermissionRead))
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
}
func checkSchemaVersion(t *testing.T, db *sql.DB) { func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`) rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err) require.Nil(t, err)

View file

@ -3,7 +3,7 @@ package user
import ( import (
"errors" "errors"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"heckel.io/ntfy/log" "heckel.io/ntfy/v2/log"
"net/netip" "net/netip"
"regexp" "regexp"
"strings" "strings"

View file

@ -2,7 +2,7 @@ package util_test
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/util" "heckel.io/ntfy/v2/util"
"math/rand" "math/rand"
"sync" "sync"
"testing" "testing"

View file

@ -161,11 +161,6 @@ func ParsePriority(priority string) (int, error) {
case "5", "max", "urgent": case "5", "max", "urgent":
return 5, nil return 5, nil
default: default:
// Ignore new HTTP Priority header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
// Cloudflare adds this to requests when forwarding to the backend (ntfy), so we just ignore it.
if strings.HasPrefix(p, "u=") {
return 3, nil
}
return 0, errInvalidPriority return 0, errInvalidPriority
} }
} }
@ -258,6 +253,8 @@ func ReadPassword(in io.Reader) ([]byte, error) {
password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0 password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0
if err != nil { if err != nil {
return nil, err return nil, err
} else if len(password) == 0 {
return nil, errors.New("password cannot be empty")
} }
return password, nil return password, nil
} }
@ -277,7 +274,9 @@ func ReadPassword(in io.Reader) ([]byte, error) {
} }
password = append(password, buf[0]) password = append(password, buf[0])
} }
if len(password) == 0 {
return nil, errors.New("password cannot be empty")
}
return password, nil return password, nil
} }

View file

@ -87,15 +87,6 @@ func TestParsePriority_Invalid(t *testing.T) {
} }
} }
func TestParsePriority_HTTPSpecPriority(t *testing.T) {
priorities := []string{"u=1", "u=3", "u=7, i"} // see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority
for _, priority := range priorities {
actual, err := ParsePriority(priority)
require.Nil(t, err)
require.Equal(t, 3, actual) // Always expect 3!
}
}
func TestPriorityString(t *testing.T) { func TestPriorityString(t *testing.T) {
priorities := []int{0, 1, 2, 3, 4, 5} priorities := []int{0, 1, 2, 3, 4, 5}
expected := []string{"default", "min", "low", "default", "high", "max"} expected := []string{"default", "min", "low", "default", "high", "max"}

Some files were not shown because too many files have changed in this diff Show more