Compare commits
No commits in common. "mine" and "user-account-log" have entirely different histories.
mine
...
user-accou
|
@ -1,3 +0,0 @@
|
||||||
dist
|
|
||||||
*/node_modules
|
|
||||||
Dockerfile*
|
|
|
@ -1,11 +0,0 @@
|
||||||
# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view
|
|
||||||
|
|
||||||
# Run prettier (https://github.com/binwiederhier/ntfy/pull/746)
|
|
||||||
6f6a2d1f693070bf72e89d86748080e4825c9164
|
|
||||||
c87549e71a10bc789eac8036078228f06e515a8e
|
|
||||||
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
|
|
||||||
2e27f58963feb9e4d1c573d4745d07770777fa7d
|
|
||||||
|
|
||||||
# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)
|
|
||||||
f558b4dbe9bb5b9e0e87fada1215de2558353173
|
|
||||||
8319f1cf26113167fb29fe12edaff5db74caf35f
|
|
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
name: 🐛 Bug Report
|
|
||||||
about: Report any errors and problems
|
|
||||||
title: ''
|
|
||||||
labels: '🪲 bug'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
:lady_beetle: **Describe the bug**
|
|
||||||
<!-- A clear and concise description of the problem. -->
|
|
||||||
|
|
||||||
:computer: **Components impacted**
|
|
||||||
<!-- ntfy server, Android app, iOS app, web app -->
|
|
||||||
|
|
||||||
:bulb: **Screenshots and/or logs**
|
|
||||||
<!--
|
|
||||||
If applicable, add screenshots or share logs help explain your problem.
|
|
||||||
To get logs from the ...
|
|
||||||
- ntfy server: Enable "log-level: trace" in your server.yml file
|
|
||||||
- Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs"
|
|
||||||
- web app: Press "F12" and find the "Console" window
|
|
||||||
-->
|
|
||||||
|
|
||||||
:crystal_ball: **Additional context**
|
|
||||||
<!-- Add any other context about the problem here. -->
|
|
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
name: 💡 Feature/Enhancement Request
|
|
||||||
about: Got a great idea? Let us know!
|
|
||||||
title: ''
|
|
||||||
labels: 'enhancement'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
|
||||||
sooner, and there are more people there to help!
|
|
||||||
|
|
||||||
- Discord: https://discord.gg/cT7ECsZj9w
|
|
||||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
|
||||||
|
|
||||||
-->
|
|
||||||
|
|
||||||
:bulb: **Idea**
|
|
||||||
<!-- Share your thoughts; try to be detailed if you can -->
|
|
||||||
|
|
||||||
:computer: **Target components**
|
|
||||||
<!-- Where should this feature/enhancement be added? -->
|
|
||||||
<!-- e.g. ntfy server, Android app, iOS app, web app -->
|
|
||||||
|
|
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
name: 🆘 I need help with ...
|
|
||||||
about: Installing ntfy, configuring the app, etc.
|
|
||||||
title: ''
|
|
||||||
labels: 'tech-support'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!--
|
|
||||||
|
|
||||||
STOP!
|
|
||||||
|
|
||||||
This is not the right place to ask for help. Consider asking on Discord/Matrix instead.
|
|
||||||
You'll usually get an answer sooner, and there are more people there to help!
|
|
||||||
|
|
||||||
- Discord: https://discord.gg/cT7ECsZj9w
|
|
||||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
|
||||||
|
|
||||||
-->
|
|
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
name: ❓ Question
|
|
||||||
about: Ask a question about ntfy
|
|
||||||
title: ''
|
|
||||||
labels: 'question'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
|
||||||
sooner, and there are more people there to help!
|
|
||||||
|
|
||||||
- Discord: https://discord.gg/cT7ECsZj9w
|
|
||||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
|
||||||
|
|
||||||
-->
|
|
||||||
|
|
||||||
:question: **Question**
|
|
||||||
<!-- Go ahead and ask your question here :) -->
|
|
BIN
.github/images/logo.png
vendored
Before Width: | Height: | Size: 81 KiB |
27
.github/workflows/build.yaml
vendored
|
@ -4,21 +4,30 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '17'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
|
|
2
.github/workflows/docs.yaml
vendored
|
@ -30,7 +30,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cd build/ntfy-docs.github.io
|
cd build/ntfy-docs.github.io
|
||||||
git config user.name "GitHub Actions Bot"
|
git config user.name "GitHub Actions Bot"
|
||||||
git config user.email "<actions@github.com>"
|
git config user.email "<>"
|
||||||
git add docs/
|
git add docs/
|
||||||
git commit -m "Updated docs"
|
git commit -m "Updated docs"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
27
.github/workflows/release.yaml
vendored
|
@ -7,21 +7,30 @@ jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '17'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Docker login
|
name: Docker login
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
|
27
.github/workflows/test.yaml
vendored
|
@ -4,21 +4,30 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '17'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
|
|
2
.gitignore
vendored
|
@ -1,5 +1,4 @@
|
||||||
dist/
|
dist/
|
||||||
dev-dist/
|
|
||||||
build/
|
build/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
@ -12,4 +11,3 @@ secrets/
|
||||||
*.iml
|
*.iml
|
||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
__pycache__
|
|
||||||
|
|
|
@ -97,7 +97,7 @@ nfpms:
|
||||||
- dst: /var/lib/ntfy
|
- dst: /var/lib/ntfy
|
||||||
type: dir
|
type: dir
|
||||||
- dst: /usr/share/ntfy/logo.png
|
- dst: /usr/share/ntfy/logo.png
|
||||||
src: web/public/static/images/ntfy.png
|
src: web/public/static/img/ntfy.png
|
||||||
scripts:
|
scripts:
|
||||||
preinstall: "scripts/preinst.sh"
|
preinstall: "scripts/preinst.sh"
|
||||||
postinstall: "scripts/postinst.sh"
|
postinstall: "scripts/postinst.sh"
|
||||||
|
|
14
Dockerfile
|
@ -1,15 +1,9 @@
|
||||||
FROM r.batts.cloud/debian:testing
|
FROM alpine
|
||||||
|
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
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"
|
|
||||||
|
|
||||||
COPY ntfy /usr/bin
|
COPY ntfy /usr/bin
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
EXPOSE 80/tcp
|
||||||
ENTRYPOINT ["ntfy"]
|
ENTRYPOINT ["ntfy"]
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
FROM r.batts.cloud/golang:1.19 as builder
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG COMMIT=unknown
|
|
||||||
|
|
||||||
RUN apt-get update
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash
|
|
||||||
RUN apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
nodejs \
|
|
||||||
python3-pip \
|
|
||||||
python3-venv
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ADD Makefile .
|
|
||||||
|
|
||||||
# docs
|
|
||||||
ADD ./requirements.txt .
|
|
||||||
RUN make docs-deps
|
|
||||||
ADD ./mkdocs.yml .
|
|
||||||
ADD ./docs ./docs
|
|
||||||
RUN make docs-build
|
|
||||||
|
|
||||||
# web
|
|
||||||
ADD ./web/package.json ./web/package-lock.json ./web/
|
|
||||||
RUN make web-deps
|
|
||||||
ADD ./web ./web
|
|
||||||
RUN make web-build
|
|
||||||
|
|
||||||
# cli & server
|
|
||||||
ADD go.mod go.sum main.go ./
|
|
||||||
ADD ./client ./client
|
|
||||||
ADD ./cmd ./cmd
|
|
||||||
ADD ./log ./log
|
|
||||||
ADD ./server ./server
|
|
||||||
ADD ./user ./user
|
|
||||||
ADD ./util ./util
|
|
||||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
|
||||||
|
|
||||||
FROM r.batts.cloud/debian:testing
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
|
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
ENTRYPOINT ["ntfy"]
|
|
73
Makefile
|
@ -31,16 +31,10 @@ help:
|
||||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build dev Docker:"
|
|
||||||
@echo " make docker-dev - Build client & server for current architecture using Docker only"
|
|
||||||
@echo
|
|
||||||
@echo "Build web app:"
|
@echo "Build web app:"
|
||||||
@echo " make web - Build the web app"
|
@echo " make web - Build the web app"
|
||||||
@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-format - Run prettier on the web app"
|
|
||||||
@echo " make web-format-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"
|
||||||
|
@ -86,33 +80,23 @@ build: web docs cli
|
||||||
update: web-deps-update cli-deps-update docs-deps-update
|
update: web-deps-update cli-deps-update docs-deps-update
|
||||||
docker pull alpine
|
docker pull alpine
|
||||||
|
|
||||||
docker-dev:
|
|
||||||
docker build \
|
|
||||||
--file ./Dockerfile-build \
|
|
||||||
--tag binwiederhier/ntfy:$(VERSION) \
|
|
||||||
--tag binwiederhier/ntfy:dev \
|
|
||||||
--build-arg VERSION=$(VERSION) \
|
|
||||||
--build-arg COMMIT=$(COMMIT) \
|
|
||||||
./
|
|
||||||
|
|
||||||
# Ubuntu-specific
|
# Ubuntu-specific
|
||||||
|
|
||||||
build-deps-ubuntu:
|
build-deps-ubuntu:
|
||||||
sudo apt-get update
|
sudo apt update
|
||||||
sudo apt-get install -y \
|
sudo apt install -y \
|
||||||
curl \
|
curl \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
jq
|
jq
|
||||||
which pip3 || sudo apt-get install -y python3-pip
|
which pip3 || sudo apt install -y python3-pip
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
docs: docs-deps docs-build
|
docs: docs-deps docs-build
|
||||||
|
|
||||||
docs-build: venv .PHONY
|
docs-build: .PHONY
|
||||||
@. venv/bin/activate && \
|
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
||||||
if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
|
||||||
if which python3.8; then \
|
if which python3.8; then \
|
||||||
echo "python3.8 $(shell which mkdocs) build"; \
|
echo "python3.8 $(shell which mkdocs) build"; \
|
||||||
python3.8 $(shell which mkdocs) build; \
|
python3.8 $(shell which mkdocs) build; \
|
||||||
|
@ -125,15 +109,10 @@ docs-build: venv .PHONY
|
||||||
mkdocs build; \
|
mkdocs build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
venv:
|
docs-deps: .PHONY
|
||||||
python3 -m venv ./venv
|
|
||||||
|
|
||||||
docs-deps: venv .PHONY
|
|
||||||
. venv/bin/activate && \
|
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
docs-deps-update: venv .PHONY
|
docs-deps-update: .PHONY
|
||||||
. venv/bin/activate && \
|
|
||||||
pip3 install -r requirements.txt --upgrade
|
pip3 install -r requirements.txt --upgrade
|
||||||
|
|
||||||
|
|
||||||
|
@ -148,7 +127,8 @@ web-build:
|
||||||
&& rm -rf ../server/site \
|
&& rm -rf ../server/site \
|
||||||
&& mv build ../server/site \
|
&& mv build ../server/site \
|
||||||
&& rm \
|
&& rm \
|
||||||
../server/site/config.js
|
../server/site/config.js \
|
||||||
|
../server/site/asset-manifest.json
|
||||||
|
|
||||||
web-deps:
|
web-deps:
|
||||||
cd web && npm install
|
cd web && npm install
|
||||||
|
@ -157,37 +137,29 @@ web-deps:
|
||||||
web-deps-update:
|
web-deps-update:
|
||||||
cd web && npm update
|
cd web && npm update
|
||||||
|
|
||||||
web-format:
|
|
||||||
cd web && npm run format
|
|
||||||
|
|
||||||
web-format-check:
|
|
||||||
cd web && npm run format:check
|
|
||||||
|
|
||||||
web-lint:
|
|
||||||
cd web && npm run lint
|
|
||||||
|
|
||||||
# Main server/client build
|
# Main server/client build
|
||||||
|
|
||||||
cli: cli-deps
|
cli: cli-deps
|
||||||
goreleaser build --snapshot --clean
|
goreleaser build --snapshot --rm-dist
|
||||||
|
|
||||||
cli-linux-amd64: cli-deps-static-sites
|
cli-linux-amd64: cli-deps-static-sites
|
||||||
goreleaser build --snapshot --clean --id ntfy_linux_amd64
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
|
||||||
|
|
||||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||||
goreleaser build --snapshot --clean --id ntfy_linux_armv6
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
|
||||||
|
|
||||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||||
goreleaser build --snapshot --clean --id ntfy_linux_armv7
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
|
||||||
|
|
||||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||||
goreleaser build --snapshot --clean --id ntfy_linux_arm64
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
|
||||||
|
|
||||||
cli-windows-amd64: cli-deps-static-sites
|
cli-windows-amd64: cli-deps-static-sites
|
||||||
goreleaser build --snapshot --clean --id ntfy_windows_amd64
|
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
|
||||||
|
|
||||||
cli-darwin-all: cli-deps-static-sites
|
cli-darwin-all: cli-deps-static-sites
|
||||||
goreleaser build --snapshot --clean --id ntfy_darwin_all
|
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
|
||||||
|
|
||||||
cli-linux-server: cli-deps-static-sites
|
cli-linux-server: cli-deps-static-sites
|
||||||
# This is a target to build the CLI (including the server) manually.
|
# This is a target to build the CLI (including the server) manually.
|
||||||
|
@ -254,12 +226,9 @@ cli-build-results:
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
check: test web-format-check fmt-check vet web-lint lint staticcheck
|
check: test fmt-check vet lint staticcheck
|
||||||
|
|
||||||
test: .PHONY
|
test: .PHONY
|
||||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
|
||||||
|
|
||||||
testv: .PHONY
|
|
||||||
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||||
|
|
||||||
race: .PHONY
|
race: .PHONY
|
||||||
|
@ -305,11 +274,11 @@ staticcheck: .PHONY
|
||||||
|
|
||||||
# Releasing targets
|
# Releasing targets
|
||||||
|
|
||||||
release: clean cli-deps release-checks docs web check
|
release: clean update cli-deps release-checks docs web check
|
||||||
goreleaser release --clean
|
goreleaser release --rm-dist
|
||||||
|
|
||||||
release-snapshot: clean cli-deps docs web check
|
release-snapshot: clean update cli-deps docs web check
|
||||||
goreleaser release --snapshot --skip-publish --clean
|
goreleaser release --snapshot --skip-publish --rm-dist
|
||||||
|
|
||||||
release-checks:
|
release-checks:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
|
|
47
README.md
|
@ -1,4 +1,4 @@
|
||||||

|

|
||||||
|
|
||||||
# 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
|
||||||
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||||
|
@ -13,26 +13,20 @@
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)
|
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||||
notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,
|
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
||||||
**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
|
It's also open source (as you can plainly see) if you want to run your own.
|
||||||
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)
|
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [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/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
|
||||||
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).
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src=".github/images/screenshot-curl.png" height="180">
|
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||||
<img src=".github/images/screenshot-web-detail.png" height="180">
|
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
|
||||||
<img src=".github/images/screenshot-phone-main.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
|
||||||
<img src=".github/images/screenshot-phone-detail.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
|
||||||
<img src=".github/images/screenshot-phone-notification.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## [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. ❤️
|
|
||||||
|
|
||||||
## **[Documentation](https://ntfy.sh/docs/)**
|
## **[Documentation](https://ntfy.sh/docs/)**
|
||||||
|
|
||||||
[Getting started](https://ntfy.sh/docs/) |
|
[Getting started](https://ntfy.sh/docs/) |
|
||||||
|
@ -120,27 +114,6 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||||
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
|
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
|
||||||
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
|
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
|
||||||
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
|
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
|
||||||
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/nichu42"><img src="https://github.com/nichu42.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/samliebow"><img src="https://github.com/samliebow.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/johman10"><img src="https://github.com/johman10.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/R-Gld"><img src="https://github.com/R-Gld.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/FingerlessGlov3s"><img src="https://github.com/FingerlessGlov3s.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/Twisterado"><img src="https://github.com/Twisterado.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/ScrumpyJack"><img src="https://github.com/ScrumpyJack.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/andrejarrell"><img src="https://github.com/andrejarrell.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/oaustegard"><img src="https://github.com/oaustegard.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/darkdragon-001"><img src="https://github.com/darkdragon-001.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/jonathan-kosgei"><img src="https://github.com/jonathan-kosgei.png" width="40px" /></a>
|
|
||||||
|
|
||||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||||
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:
|
||||||
|
|
10
SECURITY.md
|
@ -1,10 +0,0 @@
|
||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
|
||||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
|
|
@ -11,25 +11,23 @@ import (
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event type constants
|
||||||
const (
|
const (
|
||||||
// MessageEvent identifies a message event
|
MessageEvent = "message"
|
||||||
MessageEvent = "message"
|
KeepaliveEvent = "keepalive"
|
||||||
|
OpenEvent = "open"
|
||||||
|
PollRequestEvent = "poll_request"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxResponseBytes = 4096
|
maxResponseBytes = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
|
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Messages chan *Message
|
Messages chan *Message
|
||||||
|
@ -98,14 +96,8 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
|
||||||
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
||||||
// WithNoFirebase, and the generic WithHeader.
|
// WithNoFirebase, and the generic WithHeader.
|
||||||
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
topicURL := c.expandTopicURL(topic)
|
||||||
if err != nil {
|
req, _ := http.NewRequest("POST", topicURL, body)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("POST", topicURL, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
if err := option(req); err != nil {
|
if err := option(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -141,14 +133,11 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
||||||
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
|
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
|
||||||
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
|
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
|
||||||
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
|
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := make([]*Message, 0)
|
messages := make([]*Message, 0)
|
||||||
msgChan := make(chan *Message)
|
msgChan := make(chan *Message)
|
||||||
errChan := make(chan error)
|
errChan := make(chan error)
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
||||||
options = append(options, WithPoll())
|
options = append(options, WithPoll())
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -177,18 +166,15 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// c := client.New(client.NewConfig())
|
// c := client.New(client.NewConfig())
|
||||||
// subscriptionID, _ := c.Subscribe("mytopic")
|
// subscriptionID := c.Subscribe("mytopic")
|
||||||
// for m := range c.Messages {
|
// for m := range c.Messages {
|
||||||
// fmt.Printf("New message: %s", m.Message)
|
// fmt.Printf("New message: %s", m.Message)
|
||||||
// }
|
// }
|
||||||
func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {
|
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
subscriptionID := util.RandomString(10)
|
subscriptionID := util.RandomString(10)
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
c.subscriptions[subscriptionID] = &subscription{
|
c.subscriptions[subscriptionID] = &subscription{
|
||||||
|
@ -197,7 +183,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, er
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
|
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
|
||||||
return subscriptionID, nil
|
return subscriptionID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
|
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
|
||||||
|
@ -213,16 +199,31 @@ func (c *Client) Unsubscribe(subscriptionID string) {
|
||||||
sub.cancel()
|
sub.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) expandTopicURL(topic string) (string, error) {
|
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
|
||||||
|
// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
|
||||||
|
//
|
||||||
|
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||||
|
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||||
|
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
|
||||||
|
func (c *Client) UnsubscribeAll(topic string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
|
for _, sub := range c.subscriptions {
|
||||||
|
if sub.topicURL == topicURL {
|
||||||
|
delete(c.subscriptions, sub.ID)
|
||||||
|
sub.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) expandTopicURL(topic string) string {
|
||||||
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
|
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
|
||||||
return topic, nil
|
return topic
|
||||||
} else if strings.Contains(topic, "/") {
|
} else if strings.Contains(topic, "/") {
|
||||||
return fmt.Sprintf("https://%s", topic), nil
|
return fmt.Sprintf("https://%s", topic)
|
||||||
}
|
}
|
||||||
if !topicRegex.MatchString(topic) {
|
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
|
||||||
return "", fmt.Errorf("invalid topic name: %s", topic)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
|
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
|
||||||
|
|
|
@ -5,12 +5,10 @@
|
||||||
#
|
#
|
||||||
# default-host: https://ntfy.sh
|
# default-host: https://ntfy.sh
|
||||||
|
|
||||||
# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided.
|
# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
|
||||||
# You can set a default token to use or a default user:password combination, but not both. For an empty password,
|
# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
|
||||||
# use empty double-quotes ("")
|
# For an empty password, use empty double-quotes ("")
|
||||||
|
#
|
||||||
# default-token:
|
|
||||||
|
|
||||||
# default-user:
|
# default-user:
|
||||||
# default-password:
|
# default-password:
|
||||||
|
|
||||||
|
@ -32,8 +30,6 @@
|
||||||
# command: 'notify-send "$m"'
|
# command: 'notify-send "$m"'
|
||||||
# user: phill
|
# user: phill
|
||||||
# password: mypass
|
# password: mypass
|
||||||
# - topic: token_topic
|
|
||||||
# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
#
|
#
|
||||||
# Variables:
|
# Variables:
|
||||||
# Variable Aliases Description
|
# Variable Aliases Description
|
||||||
|
|
|
@ -4,24 +4,17 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
log.SetLevel(log.ErrorLevel)
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_Publish_Subscribe(t *testing.T) {
|
func TestClient_Publish_Subscribe(t *testing.T) {
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
c := client.New(newTestConfig(port))
|
c := client.New(newTestConfig(port))
|
||||||
|
|
||||||
subscriptionID, _ := c.Subscribe("mytopic")
|
subscriptionID := c.Subscribe("mytopic")
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
msg, err := c.Publish("mytopic", "some message")
|
msg, err := c.Publish("mytopic", "some message")
|
||||||
|
|
|
@ -12,22 +12,17 @@ const (
|
||||||
|
|
||||||
// Config is the config struct for a Client
|
// Config is the config struct for a Client
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DefaultHost string `yaml:"default-host"`
|
DefaultHost string `yaml:"default-host"`
|
||||||
DefaultUser string `yaml:"default-user"`
|
DefaultUser string `yaml:"default-user"`
|
||||||
DefaultPassword *string `yaml:"default-password"`
|
DefaultPassword *string `yaml:"default-password"`
|
||||||
DefaultToken string `yaml:"default-token"`
|
DefaultCommand string `yaml:"default-command"`
|
||||||
DefaultCommand string `yaml:"default-command"`
|
Subscribe []struct {
|
||||||
Subscribe []Subscribe `yaml:"subscribe"`
|
Topic string `yaml:"topic"`
|
||||||
}
|
User string `yaml:"user"`
|
||||||
|
Password *string `yaml:"password"`
|
||||||
// Subscribe is the struct for a Subscription within Config
|
Command string `yaml:"command"`
|
||||||
type Subscribe struct {
|
If map[string]string `yaml:"if"`
|
||||||
Topic string `yaml:"topic"`
|
} `yaml:"subscribe"`
|
||||||
User string `yaml:"user"`
|
|
||||||
Password *string `yaml:"password"`
|
|
||||||
Token string `yaml:"token"`
|
|
||||||
Command string `yaml:"command"`
|
|
||||||
If map[string]string `yaml:"if"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig creates a new Config struct for a Client
|
// NewConfig creates a new Config struct for a Client
|
||||||
|
@ -36,7 +31,6 @@ func NewConfig() *Config {
|
||||||
DefaultHost: DefaultBaseURL,
|
DefaultHost: DefaultBaseURL,
|
||||||
DefaultUser: "",
|
DefaultUser: "",
|
||||||
DefaultPassword: nil,
|
DefaultPassword: nil,
|
||||||
DefaultToken: "",
|
|
||||||
DefaultCommand: "",
|
DefaultCommand: "",
|
||||||
Subscribe: nil,
|
Subscribe: nil,
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,25 +116,3 @@ subscribe:
|
||||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
require.Nil(t, conf.Subscribe[0].Password)
|
require.Nil(t, conf.Subscribe[0].Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_DefaultToken(t *testing.T) {
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
|
||||||
default-host: http://localhost
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
`), 0600))
|
|
||||||
|
|
||||||
conf, err := client.LoadConfig(filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
|
||||||
require.Equal(t, "", conf.DefaultUser)
|
|
||||||
require.Nil(t, conf.DefaultPassword)
|
|
||||||
require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken)
|
|
||||||
require.Equal(t, 1, len(conf.Subscribe))
|
|
||||||
require.Equal(t, "mytopic", conf.Subscribe[0].Topic)
|
|
||||||
require.Equal(t, "", conf.Subscribe[0].User)
|
|
||||||
require.Nil(t, conf.Subscribe[0].Password)
|
|
||||||
require.Equal(t, "", conf.Subscribe[0].Token)
|
|
||||||
}
|
|
||||||
|
|
|
@ -87,11 +87,6 @@ func WithBasicAuth(user, pass string) PublishOption {
|
||||||
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithBearerAuth adds the Authorization header for Bearer auth to the request
|
|
||||||
func WithBearerAuth(token string) PublishOption {
|
|
||||||
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithNoCache instructs the server not to cache the message server-side
|
// WithNoCache instructs the server not to cache the message server-side
|
||||||
func WithNoCache() PublishOption {
|
func WithNoCache() PublishOption {
|
||||||
return WithHeader("X-Cache", "no")
|
return WithHeader("X-Cache", "no")
|
||||||
|
|
|
@ -19,7 +19,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsAccess = append(
|
var flagsAccess = append(
|
||||||
append([]cli.Flag{}, flagsUser...),
|
flagsUser,
|
||||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -189,11 +189,7 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tier := "none"
|
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", u.Name, u.Role)
|
||||||
if u.Tier != nil {
|
|
||||||
tier = u.Tier.Name
|
|
||||||
}
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
|
|
||||||
if u.Role == user.RoleAdmin {
|
if u.Role == user.RoleAdmin {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||||
} else if len(grants) > 0 {
|
} else if len(grants) > 0 {
|
||||||
|
|
|
@ -15,7 +15,7 @@ func TestCLI_Access_Show(t *testing.T) {
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
|
@ -32,12 +32,12 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
expected := `user phil (role: admin, tier: none)
|
expected := `user phil (admin)
|
||||||
- read-write access to all topics (admin role)
|
- read-write access to all topics (admin role)
|
||||||
user ben (role: user, tier: none)
|
user ben (user)
|
||||||
- read-write access to topic announcements
|
- read-write access to topic announcements
|
||||||
- read-only access to topic sometopic
|
- read-only access to topic sometopic
|
||||||
user * (role: anonymous, tier: none)
|
user * (anonymous)
|
||||||
- read-only access to topic announcements
|
- read-only access to topic announcements
|
||||||
- no access to any (other) topics (server config)
|
- no access to any (other) topics (server config)
|
||||||
`
|
`
|
||||||
|
@ -79,9 +79,7 @@ user * (role: anonymous, tier: none)
|
||||||
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
userArgs := []string{
|
userArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
"--log-level=ERROR",
|
|
||||||
"access",
|
"access",
|
||||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
|
|
23
cmd/app.go
|
@ -23,12 +23,11 @@ var flagsDefault = []cli.Flag{
|
||||||
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
||||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log level"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)
|
logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=]+)\s*=\s*(\S+)\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// New creates a new CLI application
|
// New creates a new CLI application
|
||||||
|
@ -62,29 +61,17 @@ func initLogFunc(c *cli.Context) error {
|
||||||
if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil {
|
if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logFile := c.String("log-file")
|
|
||||||
if logFile != "" {
|
|
||||||
w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.SetOutput(w)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyLogLevelOverrides(rawOverrides []string) error {
|
func applyLogLevelOverrides(rawOverrides []string) error {
|
||||||
for _, override := range rawOverrides {
|
for _, override := range rawOverrides {
|
||||||
m := logLevelOverrideRegex.FindStringSubmatch(override)
|
m := logLevelOverrideRegex.FindStringSubmatch(override)
|
||||||
if len(m) == 4 {
|
if len(m) != 4 {
|
||||||
field, value, level := m[1], m[2], m[3]
|
|
||||||
log.SetLevelOverride(field, value, log.ToLevel(level))
|
|
||||||
} else if len(m) == 3 {
|
|
||||||
field, level := m[1], m[2]
|
|
||||||
log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override)
|
return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override)
|
||||||
}
|
}
|
||||||
|
field, value, level := m[1], m[2], m[3]
|
||||||
|
log.SetLevelOverride(field, value, log.ToLevel(level))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import (
|
||||||
// This only contains helpers so far
|
// This only contains helpers so far
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
log.SetLevel(log.ErrorLevel)
|
log.SetLevel(log.WarnLevel)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var flagsPublish = append(
|
var flagsPublish = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
flagsDefault,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||||
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
||||||
|
@ -35,11 +35,11 @@ var flagsPublish = append(
|
||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
|
|
||||||
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
||||||
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||||
|
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -99,18 +99,10 @@ func execPublish(c *cli.Context) error {
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
user := c.String("user")
|
user := c.String("user")
|
||||||
token := c.String("token")
|
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
pid := c.Int("wait-pid")
|
pid := c.Int("wait-pid")
|
||||||
|
|
||||||
// Checks
|
|
||||||
if user != "" && token != "" {
|
|
||||||
return errors.New("cannot set both --user and --token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do the things
|
|
||||||
topic, message, command, err := parseTopicMessageCommand(c)
|
topic, message, command, err := parseTopicMessageCommand(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -152,9 +144,7 @@ func execPublish(c *cli.Context) error {
|
||||||
if noFirebase {
|
if noFirebase {
|
||||||
options = append(options, client.WithNoFirebase())
|
options = append(options, client.WithNoFirebase())
|
||||||
}
|
}
|
||||||
if token != "" {
|
if user != "" {
|
||||||
options = append(options, client.WithBearerAuth(token))
|
|
||||||
} else if user != "" {
|
|
||||||
var pass string
|
var pass string
|
||||||
parts := strings.SplitN(user, ":", 2)
|
parts := strings.SplitN(user, ":", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
|
@ -170,8 +160,6 @@ func execPublish(c *cli.Context) error {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
}
|
}
|
||||||
options = append(options, client.WithBasicAuth(user, pass))
|
options = append(options, client.WithBasicAuth(user, pass))
|
||||||
} else if conf.DefaultToken != "" {
|
|
||||||
options = append(options, client.WithBearerAuth(conf.DefaultToken))
|
|
||||||
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||||
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,33 +5,23 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||||
testMessage := util.RandomString(10)
|
testMessage := util.RandomString(10)
|
||||||
|
|
||||||
app, _, _, _ := newTestApp()
|
app, _, _, _ := newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||||
|
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
||||||
|
|
||||||
_, err := util.Retry(func() (*int, error) {
|
app2, _, stdout, _ := newTestApp()
|
||||||
app2, _, stdout, _ := newTestApp()
|
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||||
if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
|
require.Contains(t, stdout.String(), testMessage)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !strings.Contains(stdout.String(), testMessage) {
|
|
||||||
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
|
|
||||||
}
|
|
||||||
return util.Int(1), nil
|
|
||||||
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||||
|
@ -133,11 +123,11 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||||
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
|
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
|
||||||
|
|
||||||
// Tests with NTFY_TOPIC set ////
|
// Tests with NTFY_TOPIC set ////
|
||||||
t.Setenv("NTFY_TOPIC", topic)
|
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
|
||||||
|
|
||||||
// Test: Successful command with NTFY_TOPIC
|
// Test: Successful command with NTFY_TOPIC
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||||
m = toMessage(t, stdout.String())
|
m = toMessage(t, stdout.String())
|
||||||
require.Equal(t, "mytopic", m.Topic)
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
|
||||||
|
@ -146,155 +136,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||||
require.Nil(t, sleep.Start())
|
require.Nil(t, sleep.Start())
|
||||||
go sleep.Wait() // Must be called to release resources
|
go sleep.Wait() // Must be called to release resources
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||||
m = toMessage(t, stdout.String())
|
m = toMessage(t, stdout.String())
|
||||||
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Publish_Default_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Default_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: fakepass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
|
||||||
m := toMessage(t, stdout.String())
|
|
||||||
require.Equal(t, "triggered", m.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Publish_Token_And_UserPass(t *testing.T) {
|
|
||||||
app, _, _, _ := newTestApp()
|
|
||||||
err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
|
||||||
}
|
|
||||||
|
|
71
cmd/serve.go
|
@ -34,11 +34,11 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsServe = append(
|
var flagsServe = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
flagsDefault,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||||
|
@ -58,13 +58,11 @@ var flagsServe = append(
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
|
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
|
@ -72,10 +70,6 @@ var flagsServe = append(
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||||
|
@ -86,14 +80,9 @@ var flagsServe = append(
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdServe = &cli.Command{
|
var cmdServe = &cli.Command{
|
||||||
|
@ -143,13 +132,11 @@ func execServe(c *cli.Context) error {
|
||||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
enableSignup := c.Bool("enable-signup")
|
enableSignup := c.Bool("enable-signup")
|
||||||
enableLogin := c.Bool("enable-login")
|
enableLogin := c.Bool("enable-login")
|
||||||
enableReservations := c.Bool("enable-reservations")
|
enableReservations := c.Bool("enable-reservations")
|
||||||
upstreamBaseURL := c.String("upstream-base-url")
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
upstreamAccessToken := c.String("upstream-access-token")
|
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpSenderPass := c.String("smtp-sender-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
|
@ -157,13 +144,8 @@ func execServe(c *cli.Context) error {
|
||||||
smtpServerListen := c.String("smtp-server-listen")
|
smtpServerListen := c.String("smtp-server-listen")
|
||||||
smtpServerDomain := c.String("smtp-server-domain")
|
smtpServerDomain := c.String("smtp-server-domain")
|
||||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
twilioAccount := c.String("twilio-account")
|
|
||||||
twilioAuthToken := c.String("twilio-auth-token")
|
|
||||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
|
||||||
twilioVerifyService := c.String("twilio-verify-service")
|
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
|
||||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
|
@ -175,10 +157,6 @@ func execServe(c *cli.Context) error {
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
stripeSecretKey := c.String("stripe-secret-key")
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
billingContact := c.String("billing-contact")
|
|
||||||
metricsListenHTTP := c.String("metrics-listen-http")
|
|
||||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
|
||||||
profileListenHTTP := c.String("profile-listen-http")
|
|
||||||
|
|
||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
|
@ -195,8 +173,8 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("if set, certificate file must exist")
|
return errors.New("if set, certificate file must exist")
|
||||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
|
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||||
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
|
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
|
||||||
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||||
|
@ -205,6 +183,8 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("if set, base-url must start with http:// or https://")
|
return errors.New("if set, base-url must start with http:// or https://")
|
||||||
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
||||||
return errors.New("if set, base-url must not end with a slash (/)")
|
return errors.New("if set, base-url must not end with a slash (/)")
|
||||||
|
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
|
||||||
|
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||||
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
||||||
|
@ -219,20 +199,10 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
|
||||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
webRootIsApp := webRoot == "app"
|
||||||
if webRoot == "app" {
|
enableWeb := webRoot != "disable"
|
||||||
webRoot = "/"
|
|
||||||
} else if webRoot == "home" {
|
|
||||||
webRoot = "/app"
|
|
||||||
} else if webRoot == "disable" {
|
|
||||||
webRoot = ""
|
|
||||||
} else if !strings.HasPrefix(webRoot, "/") {
|
|
||||||
webRoot = "/" + webRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default auth permissions
|
// Default auth permissions
|
||||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||||
|
@ -278,16 +248,11 @@ func execServe(c *cli.Context) error {
|
||||||
|
|
||||||
// Stripe things
|
// Stripe things
|
||||||
if stripeSecretKey != "" {
|
if stripeSecretKey != "" {
|
||||||
stripe.EnableTelemetry = false // Whoa!
|
|
||||||
stripe.Key = stripeSecretKey
|
stripe.Key = stripeSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default forbidden topics
|
|
||||||
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
conf.File = config
|
|
||||||
conf.BaseURL = baseURL
|
conf.BaseURL = baseURL
|
||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
|
@ -310,10 +275,8 @@ func execServe(c *cli.Context) error {
|
||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.DisallowedTopics = disallowedTopics
|
conf.WebRootIsApp = webRootIsApp
|
||||||
conf.WebRoot = webRoot
|
|
||||||
conf.UpstreamBaseURL = upstreamBaseURL
|
conf.UpstreamBaseURL = upstreamBaseURL
|
||||||
conf.UpstreamAccessToken = upstreamAccessToken
|
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPSenderUser = smtpSenderUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPSenderPass = smtpSenderPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
|
@ -321,10 +284,6 @@ func execServe(c *cli.Context) error {
|
||||||
conf.SMTPServerListen = smtpServerListen
|
conf.SMTPServerListen = smtpServerListen
|
||||||
conf.SMTPServerDomain = smtpServerDomain
|
conf.SMTPServerDomain = smtpServerDomain
|
||||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||||
conf.TwilioAccount = twilioAccount
|
|
||||||
conf.TwilioAuthToken = twilioAuthToken
|
|
||||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
|
||||||
conf.TwilioVerifyService = twilioVerifyService
|
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
|
@ -335,17 +294,13 @@ func execServe(c *cli.Context) error {
|
||||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.BillingContact = billingContact
|
conf.EnableWeb = enableWeb
|
||||||
conf.EnableSignup = enableSignup
|
conf.EnableSignup = enableSignup
|
||||||
conf.EnableLogin = enableLogin
|
conf.EnableLogin = enableLogin
|
||||||
conf.EnableReservations = enableReservations
|
conf.EnableReservations = enableReservations
|
||||||
conf.EnableMetrics = enableMetrics
|
|
||||||
conf.MetricsListenHTTP = metricsListenHTTP
|
|
||||||
conf.ProfileListenHTTP = profileListenHTTP
|
|
||||||
conf.Version = c.App.Version
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
|
@ -423,7 +378,7 @@ func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||||
}
|
}
|
||||||
log.ResetLevelOverrides()
|
log.ResetLevelOverride()
|
||||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,11 +26,10 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsSubscribe = append(
|
var flagsSubscribe = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
flagsDefault,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
|
|
||||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
|
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||||
|
@ -72,7 +71,7 @@ ntfy subscribe TOPIC COMMAND
|
||||||
$NTFY_TITLE $title, $t Message title
|
$NTFY_TITLE $title, $t Message title
|
||||||
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
|
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
|
||||||
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
|
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
|
||||||
$NTFY_RAW $raw Raw JSON message
|
$NTFY_RAW $raw Raw JSON message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||||
|
@ -98,18 +97,11 @@ func execSubscribe(c *cli.Context) error {
|
||||||
cl := client.New(conf)
|
cl := client.New(conf)
|
||||||
since := c.String("since")
|
since := c.String("since")
|
||||||
user := c.String("user")
|
user := c.String("user")
|
||||||
token := c.String("token")
|
|
||||||
poll := c.Bool("poll")
|
poll := c.Bool("poll")
|
||||||
scheduled := c.Bool("scheduled")
|
scheduled := c.Bool("scheduled")
|
||||||
fromConfig := c.Bool("from-config")
|
fromConfig := c.Bool("from-config")
|
||||||
topic := c.Args().Get(0)
|
topic := c.Args().Get(0)
|
||||||
command := c.Args().Get(1)
|
command := c.Args().Get(1)
|
||||||
|
|
||||||
// Checks
|
|
||||||
if user != "" && token != "" {
|
|
||||||
return errors.New("cannot set both --user and --token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fromConfig {
|
if !fromConfig {
|
||||||
conf.Subscribe = nil // wipe if --from-config not passed
|
conf.Subscribe = nil // wipe if --from-config not passed
|
||||||
}
|
}
|
||||||
|
@ -117,9 +109,7 @@ func execSubscribe(c *cli.Context) error {
|
||||||
if since != "" {
|
if since != "" {
|
||||||
options = append(options, client.WithSince(since))
|
options = append(options, client.WithSince(since))
|
||||||
}
|
}
|
||||||
if token != "" {
|
if user != "" {
|
||||||
options = append(options, client.WithBearerAuth(token))
|
|
||||||
} else if user != "" {
|
|
||||||
var pass string
|
var pass string
|
||||||
parts := strings.SplitN(user, ":", 2)
|
parts := strings.SplitN(user, ":", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
|
@ -135,10 +125,9 @@ func execSubscribe(c *cli.Context) error {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
}
|
}
|
||||||
options = append(options, client.WithBasicAuth(user, pass))
|
options = append(options, client.WithBasicAuth(user, pass))
|
||||||
} else if conf.DefaultToken != "" {
|
}
|
||||||
options = append(options, client.WithBearerAuth(conf.DefaultToken))
|
if poll {
|
||||||
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
options = append(options, client.WithPoll())
|
||||||
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
|
||||||
}
|
}
|
||||||
if scheduled {
|
if scheduled {
|
||||||
options = append(options, client.WithScheduled())
|
options = append(options, client.WithScheduled())
|
||||||
|
@ -156,9 +145,6 @@ func execSubscribe(c *cli.Context) error {
|
||||||
|
|
||||||
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||||
for _, s := range conf.Subscribe { // may be nil
|
for _, s := range conf.Subscribe { // may be nil
|
||||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
|
||||||
options = append(options, auth)
|
|
||||||
}
|
|
||||||
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
|
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -189,15 +175,22 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||||
for filter, value := range s.If {
|
for filter, value := range s.If {
|
||||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||||
}
|
}
|
||||||
|
var user string
|
||||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
var password *string
|
||||||
topicOptions = append(topicOptions, auth)
|
if s.User != "" {
|
||||||
|
user = s.User
|
||||||
|
} else if conf.DefaultUser != "" {
|
||||||
|
user = conf.DefaultUser
|
||||||
}
|
}
|
||||||
|
if s.Password != nil {
|
||||||
subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
|
password = s.Password
|
||||||
if err != nil {
|
} else if conf.DefaultPassword != nil {
|
||||||
return err
|
password = conf.DefaultPassword
|
||||||
}
|
}
|
||||||
|
if user != "" && password != nil {
|
||||||
|
topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
|
||||||
|
}
|
||||||
|
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||||
if s.Command != "" {
|
if s.Command != "" {
|
||||||
cmds[subscriptionID] = s.Command
|
cmds[subscriptionID] = s.Command
|
||||||
} else if conf.DefaultCommand != "" {
|
} else if conf.DefaultCommand != "" {
|
||||||
|
@ -207,10 +200,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
subscriptionID, err := cl.Subscribe(topic, options...)
|
subscriptionID := cl.Subscribe(topic, options...)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cmds[subscriptionID] = command
|
cmds[subscriptionID] = command
|
||||||
}
|
}
|
||||||
for m := range cl.Messages {
|
for m := range cl.Messages {
|
||||||
|
@ -224,25 +214,6 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
|
|
||||||
// check for subscription token then subscription user:pass
|
|
||||||
if s.Token != "" {
|
|
||||||
return client.WithBearerAuth(s.Token)
|
|
||||||
}
|
|
||||||
if s.User != "" && s.Password != nil {
|
|
||||||
return client.WithBasicAuth(s.User, *s.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no subscription token nor subscription user:pass, check for default token then default user:pass
|
|
||||||
if conf.DefaultToken != "" {
|
|
||||||
return client.WithBearerAuth(conf.DefaultToken)
|
|
||||||
}
|
|
||||||
if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
|
||||||
return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
|
func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
|
||||||
if command != "" {
|
if command != "" {
|
||||||
runCommand(c, command, m)
|
runCommand(c, command, m)
|
||||||
|
|
|
@ -1,361 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: mypass
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
user: philipp
|
|
||||||
password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: fake
|
|
||||||
default-password: password
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
user: philipp
|
|
||||||
password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: mypass
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
user: philipp
|
|
||||||
password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_FAKETOKEN0123456789FAKETOKEN
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
|
||||||
subscribe:
|
|
||||||
- topic: mytopic
|
|
||||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) {
|
|
||||||
app, _, _, _ := newTestApp()
|
|
||||||
err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_Token(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--config=" + filename, "mytopic"}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Subscribe_Default_UserPass(t *testing.T) {
|
|
||||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(message))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
|
||||||
default-host: %s
|
|
||||||
default-user: philipp
|
|
||||||
default-password: mypass
|
|
||||||
`, server.URL)), 0600))
|
|
||||||
|
|
||||||
app, _, stdout, _ := newTestApp()
|
|
||||||
|
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--config=" + filename, "mytopic"}))
|
|
||||||
|
|
||||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
|
||||||
}
|
|
374
cmd/tier.go
|
@ -1,374 +0,0 @@
|
||||||
//go:build !noserver
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
commands = append(commands, cmdTier)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultMessageLimit = 5000
|
|
||||||
defaultMessageExpiryDuration = "12h"
|
|
||||||
defaultEmailLimit = 20
|
|
||||||
defaultCallLimit = 0
|
|
||||||
defaultReservationLimit = 3
|
|
||||||
defaultAttachmentFileSizeLimit = "15M"
|
|
||||||
defaultAttachmentTotalSizeLimit = "100M"
|
|
||||||
defaultAttachmentExpiryDuration = "6h"
|
|
||||||
defaultAttachmentBandwidthLimit = "1G"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
flagsTier = append([]cli.Flag{}, flagsUser...)
|
|
||||||
)
|
|
||||||
|
|
||||||
var cmdTier = &cli.Command{
|
|
||||||
Name: "tier",
|
|
||||||
Usage: "Manage/show tiers",
|
|
||||||
UsageText: "ntfy tier [list|add|change|remove] ...",
|
|
||||||
Flags: flagsTier,
|
|
||||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
|
||||||
Category: categoryServer,
|
|
||||||
Subcommands: []*cli.Command{
|
|
||||||
{
|
|
||||||
Name: "add",
|
|
||||||
Aliases: []string{"a"},
|
|
||||||
Usage: "Adds a new tier",
|
|
||||||
UsageText: "ntfy tier add [OPTIONS] CODE",
|
|
||||||
Action: execTierAdd,
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
|
||||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
|
||||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
|
||||||
&cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
|
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
|
||||||
&cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
|
||||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
|
||||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
|
||||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
|
||||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"},
|
|
||||||
},
|
|
||||||
Description: `Add a new tier to the ntfy user database.
|
|
||||||
|
|
||||||
Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or
|
|
||||||
make it possible for users to reserve topics.
|
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
|
||||||
ntfy tier add \ # Add a tier with custom limits
|
|
||||||
--name="Pro" \
|
|
||||||
--message-limit=10000 \
|
|
||||||
--message-expiry-duration=24h \
|
|
||||||
--email-limit=50 \
|
|
||||||
--reservation-limit=10 \
|
|
||||||
--attachment-file-size-limit=100M \
|
|
||||||
--attachment-total-size-limit=1G \
|
|
||||||
--attachment-expiry-duration=12h \
|
|
||||||
--attachment-bandwidth-limit=5G \
|
|
||||||
pro
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "change",
|
|
||||||
Aliases: []string{"ch"},
|
|
||||||
Usage: "Change a tier",
|
|
||||||
UsageText: "ntfy tier change [OPTIONS] CODE",
|
|
||||||
Action: execTierChange,
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
|
||||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
|
||||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
|
||||||
&cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"},
|
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
|
||||||
&cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
|
||||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
|
||||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
|
||||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
|
||||||
},
|
|
||||||
Description: `Updates a tier to change the limits.
|
|
||||||
|
|
||||||
After updating a tier, you may have to restart the ntfy server to apply them
|
|
||||||
to all visitors.
|
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
|
||||||
ntfy tier change \ # Update multiple limits and fields
|
|
||||||
--message-expiry-duration=24h \
|
|
||||||
--stripe-monthly-price-id=price_1234 \
|
|
||||||
--stripe-monthly-price-id=price_5678 \
|
|
||||||
pro
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "remove",
|
|
||||||
Aliases: []string{"del", "rm"},
|
|
||||||
Usage: "Removes a tier",
|
|
||||||
UsageText: "ntfy tier remove CODE",
|
|
||||||
Action: execTierDel,
|
|
||||||
Description: `Remove a tier from the ntfy user database.
|
|
||||||
|
|
||||||
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
|
|
||||||
to remove or switch their tier first.
|
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
ntfy tier del pro
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "list",
|
|
||||||
Aliases: []string{"l"},
|
|
||||||
Usage: "Shows a list of tiers",
|
|
||||||
Action: execTierList,
|
|
||||||
Description: `Shows a list of all configured tiers.
|
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Description: `Manage tiers of the ntfy server.
|
|
||||||
|
|
||||||
The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used
|
|
||||||
to grant users higher limits, such as daily message limits, attachment size, or make it
|
|
||||||
possible for users to reserve topics.
|
|
||||||
|
|
||||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
|
||||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
|
||||||
ntfy tier del pro # Delete an existing tier
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
|
|
||||||
func execTierAdd(c *cli.Context) error {
|
|
||||||
code := c.Args().Get(0)
|
|
||||||
if code == "" {
|
|
||||||
return errors.New("tier code expected, type 'ntfy tier add --help' for help")
|
|
||||||
} else if !user.AllowedTier(code) {
|
|
||||||
return errors.New("tier code must consist only of numbers and letters")
|
|
||||||
} else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" {
|
|
||||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
|
||||||
} else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" {
|
|
||||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
|
||||||
}
|
|
||||||
manager, err := createUserManager(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tier, _ := manager.Tier(code); tier != nil {
|
|
||||||
if c.Bool("ignore-exists") {
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("tier %s already exists", code)
|
|
||||||
}
|
|
||||||
name := c.String("name")
|
|
||||||
if name == "" {
|
|
||||||
name = code
|
|
||||||
}
|
|
||||||
messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tier := &user.Tier{
|
|
||||||
ID: "", // Generated
|
|
||||||
Code: code,
|
|
||||||
Name: name,
|
|
||||||
MessageLimit: c.Int64("message-limit"),
|
|
||||||
MessageExpiryDuration: messageExpiryDuration,
|
|
||||||
EmailLimit: c.Int64("email-limit"),
|
|
||||||
CallLimit: c.Int64("call-limit"),
|
|
||||||
ReservationLimit: c.Int64("reservation-limit"),
|
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
|
||||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
|
||||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
|
||||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
|
||||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
|
||||||
}
|
|
||||||
if err := manager.AddTier(tier); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tier, err = manager.Tier(code)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
|
||||||
printTier(c, tier)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execTierChange(c *cli.Context) error {
|
|
||||||
code := c.Args().Get(0)
|
|
||||||
if code == "" {
|
|
||||||
return errors.New("tier code expected, type 'ntfy tier change --help' for help")
|
|
||||||
} else if !user.AllowedTier(code) {
|
|
||||||
return errors.New("tier code must consist only of numbers and letters")
|
|
||||||
}
|
|
||||||
manager, err := createUserManager(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tier, err := manager.Tier(code)
|
|
||||||
if err == user.ErrTierNotFound {
|
|
||||||
return fmt.Errorf("tier %s does not exist", code)
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if c.IsSet("name") {
|
|
||||||
tier.Name = c.String("name")
|
|
||||||
}
|
|
||||||
if c.IsSet("message-limit") {
|
|
||||||
tier.MessageLimit = c.Int64("message-limit")
|
|
||||||
}
|
|
||||||
if c.IsSet("message-expiry-duration") {
|
|
||||||
tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.IsSet("email-limit") {
|
|
||||||
tier.EmailLimit = c.Int64("email-limit")
|
|
||||||
}
|
|
||||||
if c.IsSet("call-limit") {
|
|
||||||
tier.CallLimit = c.Int64("call-limit")
|
|
||||||
}
|
|
||||||
if c.IsSet("reservation-limit") {
|
|
||||||
tier.ReservationLimit = c.Int64("reservation-limit")
|
|
||||||
}
|
|
||||||
if c.IsSet("attachment-file-size-limit") {
|
|
||||||
tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.IsSet("attachment-total-size-limit") {
|
|
||||||
tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.IsSet("attachment-expiry-duration") {
|
|
||||||
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.IsSet("attachment-bandwidth-limit") {
|
|
||||||
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.IsSet("stripe-monthly-price-id") {
|
|
||||||
tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id")
|
|
||||||
}
|
|
||||||
if c.IsSet("stripe-yearly-price-id") {
|
|
||||||
tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id")
|
|
||||||
}
|
|
||||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" {
|
|
||||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
|
||||||
} else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" {
|
|
||||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
|
||||||
}
|
|
||||||
if err := manager.UpdateTier(tier); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
|
||||||
printTier(c, tier)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execTierDel(c *cli.Context) error {
|
|
||||||
code := c.Args().Get(0)
|
|
||||||
if code == "" {
|
|
||||||
return errors.New("tier code expected, type 'ntfy tier del --help' for help")
|
|
||||||
}
|
|
||||||
manager, err := createUserManager(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := manager.Tier(code); err == user.ErrTierNotFound {
|
|
||||||
return fmt.Errorf("tier %s does not exist", code)
|
|
||||||
}
|
|
||||||
if err := manager.RemoveTier(code); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execTierList(c *cli.Context) error {
|
|
||||||
manager, err := createUserManager(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tiers, err := manager.Tiers()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, tier := range tiers {
|
|
||||||
printTier(c, tier)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printTier(c *cli.Context, tier *user.Tier) {
|
|
||||||
prices := "(none)"
|
|
||||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
|
||||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/server"
|
|
||||||
"heckel.io/ntfy/test"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
|
||||||
defer test.StopServer(t, s, port)
|
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
|
||||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
|
||||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
|
||||||
|
|
||||||
err := runTierCommand(app, conf, "add", "pro")
|
|
||||||
require.NotNil(t, err)
|
|
||||||
require.Equal(t, "tier pro already exists", err.Error())
|
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
|
||||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
|
||||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
|
||||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
|
||||||
require.Nil(t, runTierCommand(app, conf, "change",
|
|
||||||
"--message-limit=999",
|
|
||||||
"--message-expiry-duration=2d",
|
|
||||||
"--email-limit=91",
|
|
||||||
"--reservation-limit=98",
|
|
||||||
"--attachment-file-size-limit=100m",
|
|
||||||
"--attachment-expiry-duration=1d",
|
|
||||||
"--attachment-total-size-limit=10G",
|
|
||||||
"--attachment-bandwidth-limit=100G",
|
|
||||||
"--stripe-monthly-price-id=price_991",
|
|
||||||
"--stripe-yearly-price-id=price_992",
|
|
||||||
"pro",
|
|
||||||
))
|
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
|
||||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
|
||||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
|
||||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
|
||||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
|
||||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
|
||||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
|
||||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
|
||||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
|
||||||
require.Contains(t, stderr.String(), "tier pro removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
|
||||||
userArgs := []string{
|
|
||||||
"ntfy",
|
|
||||||
"--log-level=ERROR",
|
|
||||||
"tier",
|
|
||||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
|
||||||
"--auth-file=" + conf.AuthFile,
|
|
||||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
|
||||||
}
|
|
||||||
return app.Run(append(userArgs, args...))
|
|
||||||
}
|
|
|
@ -16,7 +16,7 @@ func init() {
|
||||||
commands = append(commands, cmdToken)
|
commands = append(commands, cmdToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
var flagsToken = append([]cli.Flag{}, flagsUser...)
|
var flagsToken = flagsUser
|
||||||
|
|
||||||
var cmdToken = &cli.Command{
|
var cmdToken = &cli.Command{
|
||||||
Name: "token",
|
Name: "token",
|
||||||
|
@ -42,9 +42,6 @@ User access tokens can be used to publish, subscribe, or perform any other user-
|
||||||
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
||||||
avoid spreading the password to various places.
|
avoid spreading the password to various places.
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy token add phil # Create token for user phil which never expires
|
ntfy token add phil # Create token for user phil which never expires
|
||||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||||
|
@ -69,7 +66,7 @@ Example:
|
||||||
Action: execTokenList,
|
Action: execTokenList,
|
||||||
Description: `Shows a list of all tokens.
|
Description: `Shows a list of all tokens.
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
This is a server-only command. It directly reads from the user.db as defined in the server config
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -41,9 +41,7 @@ func TestCLI_Token_AddListRemove(t *testing.T) {
|
||||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
userArgs := []string{
|
userArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
"--log-level=ERROR",
|
|
||||||
"token",
|
"token",
|
||||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
}
|
}
|
||||||
return app.Run(append(userArgs, args...))
|
return app.Run(append(userArgs, args...))
|
||||||
|
|
19
cmd/user.go
|
@ -24,7 +24,7 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var flagsUser = append(
|
var flagsUser = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
flagsDefault,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
|
@ -46,7 +46,6 @@ var cmdUser = &cli.Command{
|
||||||
Action: execUserAdd,
|
Action: execUserAdd,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
|
|
||||||
},
|
},
|
||||||
Description: `Add a new user to the ntfy user database.
|
Description: `Add a new user to the ntfy user database.
|
||||||
|
|
||||||
|
@ -140,22 +139,22 @@ Example:
|
||||||
Action: execUserList,
|
Action: execUserList,
|
||||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||||
|
|
||||||
This command is an alias to calling 'ntfy access' (display access control list).
|
This is a server-only command. It directly reads from the user.db as defined in the server config
|
||||||
|
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||||
|
|
||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
This command is an alias to calling 'ntfy access' (display access control list).
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Description: `Manage users of the ntfy server.
|
Description: `Manage users of the ntfy server.
|
||||||
|
|
||||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
|
||||||
passwords or roles.
|
|
||||||
|
|
||||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||||
to the related command 'ntfy access'.
|
to the related command 'ntfy access'.
|
||||||
|
|
||||||
|
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||||
|
passwords or roles.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||||
ntfy user add phil # Add regular user phil
|
ntfy user add phil # Add regular user phil
|
||||||
|
@ -187,10 +186,6 @@ func execUserAdd(c *cli.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if user, _ := manager.User(username); user != nil {
|
if user, _ := manager.User(username); user != nil {
|
||||||
if c.Bool("ignore-exists") {
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("user %s already exists", username)
|
return fmt.Errorf("user %s already exists", username)
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
@ -114,10 +113,7 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
|
||||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
|
||||||
conf = server.NewConfig()
|
conf = server.NewConfig()
|
||||||
conf.File = configFile
|
|
||||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
conf.AuthDefault = user.PermissionDenyAll
|
conf.AuthDefault = user.PermissionDenyAll
|
||||||
s, port = test.StartServerWithConfig(t, conf)
|
s, port = test.StartServerWithConfig(t, conf)
|
||||||
|
@ -127,9 +123,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
|
||||||
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
userArgs := []string{
|
userArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
"--log-level=ERROR",
|
|
||||||
"user",
|
"user",
|
||||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block announce %}
|
|
||||||
<style>
|
|
||||||
div[data-md-component="announce"] {
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-md-component="announce"] a {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-md-component="announce"] a:hover, div[data-md-component="announce"] a:focus {
|
|
||||||
transition: ease-in 150ms;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-md-component="announce"] .md-banner__button {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-md-component="announce"] .md-banner.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-md-component="announce"] .twemoji {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<button id="announce-bar-close" class="md-banner__button md-icon" aria-label="Don't show this again">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
If you like ntfy, please consider sponsoring me via <a target="_blank" href="https://github.com/sponsors/binwiederhier"><strong>GitHub Sponsors</strong></a>
|
|
||||||
or <a target="_blank" href="https://en.liberapay.com/ntfy/"><strong>Liberapay</strong></a>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 36 36" class="twemoji md-footer-custom-text">
|
|
||||||
<path fill="#DD2E44" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
|
|
||||||
</svg>, or subscribing to <a target="_blank" href="https://ntfy.sh/app"><strong>ntfy Pro</strong></a>.
|
|
||||||
<script>
|
|
||||||
announceBarKey = 'announce-bar-closed-sponsor';
|
|
||||||
document.getElementById('announce-bar-close').addEventListener('click', (e) => {
|
|
||||||
localStorage.setItem(announceBarKey, 'true');
|
|
||||||
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
|
|
||||||
});
|
|
||||||
if (localStorage.getItem(announceBarKey) === 'true') {
|
|
||||||
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
378
docs/config.md
|
@ -161,7 +161,6 @@ ntfy user add --role=admin phil # Add admin user phil
|
||||||
ntfy user del phil # Delete user phil
|
ntfy user del phil # Delete user phil
|
||||||
ntfy user change-pass phil # Change password for user phil
|
ntfy user change-pass phil # Change password for user phil
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Access control list (ACL)
|
### Access control list (ACL)
|
||||||
|
@ -223,39 +222,6 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
|
||||||
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||||
|
|
||||||
### Access tokens
|
|
||||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
|
||||||
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
|
||||||
want to use a dedicated token to publish from your backup host, and one from your home automation system.
|
|
||||||
|
|
||||||
!!! info
|
|
||||||
As of today, access tokens grant users **full access to the user account**. Aside from changing the password,
|
|
||||||
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
|
||||||
but not yet implemented.
|
|
||||||
|
|
||||||
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
|
||||||
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
|
|
||||||
|
|
||||||
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
|
||||||
```
|
|
||||||
ntfy token list # Shows list of tokens for all users
|
|
||||||
ntfy token list phil # Shows list of tokens for user phil
|
|
||||||
ntfy token add phil # Create token for user phil which never expires
|
|
||||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
|
||||||
ntfy token remove phil tk_th2sxr... # Delete token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Creating an access token:**
|
|
||||||
```
|
|
||||||
$ ntfy token add --expires=30d --label="backups" phil
|
|
||||||
$ ntfy token list
|
|
||||||
user phil
|
|
||||||
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
|
||||||
```
|
|
||||||
|
|
||||||
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
|
||||||
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
|
||||||
|
|
||||||
### Example: Private instance
|
### Example: Private instance
|
||||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||||
|
|
||||||
|
@ -759,7 +725,6 @@ To configure it, simply set `upstream-base-url` like so:
|
||||||
|
|
||||||
``` yaml
|
``` yaml
|
||||||
upstream-base-url: "https://ntfy.sh"
|
upstream-base-url: "https://ntfy.sh"
|
||||||
upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
||||||
|
@ -789,89 +754,6 @@ Note that the self-hosted server literally sends the message `New message` for e
|
||||||
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
||||||
it'll show `New message` as a popup.
|
it'll show `New message` as a popup.
|
||||||
|
|
||||||
## Tiers
|
|
||||||
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
|
|
||||||
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
|
|
||||||
tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way
|
|
||||||
to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).
|
|
||||||
|
|
||||||
By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.
|
|
||||||
Once a user is associated with a tier, some limits are overridden based on the tier.
|
|
||||||
|
|
||||||
The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.
|
|
||||||
|
|
||||||
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
|
||||||
```
|
|
||||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
|
||||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
|
||||||
ntfy tier del starter # Delete an existing tier
|
|
||||||
ntfy user change-tier phil pro # Switch user "phil" to tier "pro"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Creating a tier (full example):**
|
|
||||||
```
|
|
||||||
ntfy tier add \
|
|
||||||
--name="Pro" \
|
|
||||||
--message-limit=10000 \
|
|
||||||
--message-expiry-duration=24h \
|
|
||||||
--email-limit=50 \
|
|
||||||
--call-limit=10 \
|
|
||||||
--reservation-limit=10 \
|
|
||||||
--attachment-file-size-limit=100M \
|
|
||||||
--attachment-total-size-limit=1G \
|
|
||||||
--attachment-expiry-duration=12h \
|
|
||||||
--attachment-bandwidth-limit=5G \
|
|
||||||
--stripe-price-id=price_123456 \
|
|
||||||
pro
|
|
||||||
```
|
|
||||||
|
|
||||||
## Payments
|
|
||||||
ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled,
|
|
||||||
users can register, login and switch plans in the web app. The web app will behave slightly differently if payments
|
|
||||||
are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags).
|
|
||||||
|
|
||||||
!!! info
|
|
||||||
The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use
|
|
||||||
cases.
|
|
||||||
|
|
||||||
To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key`
|
|
||||||
config options:
|
|
||||||
|
|
||||||
* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values
|
|
||||||
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
|
|
||||||
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
|
|
||||||
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
|
|
||||||
* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
|
||||||
out with billing questions. If unset, nothing will be displayed.
|
|
||||||
|
|
||||||
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
|
|
||||||
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
|
|
||||||
to `https://ntfy.example.com/v1/account/billing/webhook`.
|
|
||||||
|
|
||||||
Here's an example:
|
|
||||||
|
|
||||||
``` yaml
|
|
||||||
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
|
|
||||||
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
|
||||||
billing-contact: "phil@example.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phone calls
|
|
||||||
ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled,
|
|
||||||
users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header.
|
|
||||||
See [publishing page](publish.md#phone-calls) for more details.
|
|
||||||
|
|
||||||
To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers
|
|
||||||
are the easiest), and then configure the following options:
|
|
||||||
|
|
||||||
* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
|
||||||
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
|
||||||
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
|
|
||||||
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
|
||||||
|
|
||||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
|
||||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
|
@ -906,15 +788,7 @@ request every 5s (defined by `visitor-request-limit-replenish`)
|
||||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||||
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||||
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||||
|
|
||||||
### Message limits
|
|
||||||
By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits).
|
|
||||||
For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests
|
|
||||||
to publish messages, then that is the daily message limit.
|
|
||||||
|
|
||||||
To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number
|
|
||||||
of messages a visitor can send in a day. This counter is reset every day at midnight (UTC).
|
|
||||||
|
|
||||||
### Attachment limits
|
### Attachment limits
|
||||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||||
per-visitor limits:
|
per-visitor limits:
|
||||||
|
@ -950,25 +824,6 @@ If this ever happens, there will be a log message that looks something like this
|
||||||
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
||||||
```
|
```
|
||||||
|
|
||||||
### Subscriber-based rate limiting
|
|
||||||
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
|
|
||||||
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.**
|
|
||||||
|
|
||||||
If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
|
|
||||||
to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
|
|
||||||
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
|
|
||||||
HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
|
|
||||||
to use when publishing on this topic. Note that setting the rate visitor 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`
|
|
||||||
response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's
|
|
||||||
`visitor-message-daily-limit`.
|
|
||||||
|
|
||||||
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
|
|
||||||
|
|
||||||
## 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**.
|
||||||
|
@ -1107,111 +962,18 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||||
maxretry = 10
|
maxretry = 10
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health checks
|
## Debugging/tracing
|
||||||
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.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"health":true}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment.
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
|
|
||||||
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
|
|
||||||
|
|
||||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated
|
|
||||||
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
|
||||||
doing, and/or secure access to the endpoint in your reverse proxy.
|
|
||||||
|
|
||||||
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
|
||||||
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly
|
|
||||||
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
|
||||||
|
|
||||||
=== "server.yml (Using default port)"
|
|
||||||
```yaml
|
|
||||||
enable-metrics: true
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "server.yml (Using dedicated IP/port)"
|
|
||||||
```yaml
|
|
||||||
metrics-listen-http: "10.0.1.1:9090"
|
|
||||||
```
|
|
||||||
|
|
||||||
In Prometheus, an example scrape config would look like this:
|
|
||||||
|
|
||||||
=== "prometheus.yml"
|
|
||||||
```yaml
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: "ntfy"
|
|
||||||
static_configs:
|
|
||||||
- targets: ["10.0.1.1:9090"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Here's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)):
|
|
||||||
|
|
||||||
<figure markdown style="padding-left: 50px; padding-right: 50px">
|
|
||||||
<a href="../../static/img/grafana-dashboard.png" target="_blank"><img src="../../static/img/grafana-dashboard.png"/></a>
|
|
||||||
<figcaption>ntfy Grafana dashboard</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
## Profiling
|
|
||||||
ntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server.
|
|
||||||
If enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://<ip>:<port>/debug/pprof/`.
|
|
||||||
This can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option.
|
|
||||||
|
|
||||||
## Logging & debugging
|
|
||||||
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
|
|
||||||
|
|
||||||
ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
|
|
||||||
log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded
|
|
||||||
by calling `kill -HUP $pid` or `systemctl reload ntfy`.
|
|
||||||
|
|
||||||
The following config options define the logging behavior:
|
|
||||||
|
|
||||||
* `log-format` defines the output format, can be `text` (default) or `json`
|
|
||||||
* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.
|
|
||||||
* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.
|
|
||||||
Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.
|
|
||||||
* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful
|
|
||||||
for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
|
|
||||||
This is an array of strings in the format:
|
|
||||||
- `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`
|
|
||||||
- `field -> level` to match any value, e.g. `time_taken_ms -> debug`
|
|
||||||
|
|
||||||
**Logging config (good for production use):**
|
|
||||||
``` yaml
|
|
||||||
log-level: info
|
|
||||||
log-format: json
|
|
||||||
log-file: /var/log/ntfy.log
|
|
||||||
```
|
|
||||||
|
|
||||||
**Temporary debugging:**
|
|
||||||
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
||||||
to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message
|
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
|
||||||
contents. The `trace` setting will also print the message contents.
|
contents. The `TRACE` setting will also print the message contents.
|
||||||
|
|
||||||
Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`),
|
|
||||||
a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what
|
|
||||||
they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).
|
|
||||||
|
|
||||||
Here's an example that will output only `info` log events, except when they match either of the defined overrides:
|
|
||||||
``` yaml
|
|
||||||
log-level: info
|
|
||||||
log-level-overrides:
|
|
||||||
- "tag=manager -> trace"
|
|
||||||
- "visitor_ip=1.2.3.4 -> debug"
|
|
||||||
- "time_taken_ms -> debug"
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a
|
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
|
||||||
performance penalty. Only use it for temporary debugging.
|
you're going to run out of disk space pretty quickly.
|
||||||
|
|
||||||
You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after
|
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
|
||||||
editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd),
|
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
|
||||||
or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:
|
If successful, you'll see something like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ ntfy serve
|
$ ntfy serve
|
||||||
|
@ -1259,32 +1021,24 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||||
| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 |
|
|
||||||
| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 |
|
|
||||||
| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 |
|
|
||||||
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||||
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
|
||||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||||
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
|
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||||
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
|
| `enable-signup` | `NTFY_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
| `enable-reservations` | `NTFY_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
|
||||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
@ -1303,72 +1057,58 @@ CATEGORY:
|
||||||
|
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
Run the ntfy server and listen for incoming requests
|
Run the ntfy server and listen for incoming requests
|
||||||
|
|
||||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||||
be overridden using the command line options.
|
be overridden using the command line options.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy serve # Starts server in the foreground (on port 80)
|
ntfy serve # Starts server in the foreground (on port 80)
|
||||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||||
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||||
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||||
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||||
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||||
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
||||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||||
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
|
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||||
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||||
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||||
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
|
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||||
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
|
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
||||||
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||||
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
|
||||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
|
||||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
|
||||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
|
||||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
|
||||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
|
||||||
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
|
||||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
|
||||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
|
||||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
|
||||||
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
|
|
||||||
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
|
|
||||||
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
|
|
||||||
--help, -h show help (default: false)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -163,15 +163,6 @@ $ make release-snapshot
|
||||||
|
|
||||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
### Build a Docker image only for Linux
|
|
||||||
|
|
||||||
This is useful to test the final build with web app, docs, and server without any dependencies locally
|
|
||||||
|
|
||||||
``` shell
|
|
||||||
$ make docker-dev
|
|
||||||
$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build the ntfy binary
|
### Build the ntfy binary
|
||||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||||
|
|
||||||
|
@ -336,76 +327,7 @@ To build your own version with Firebase, you must:
|
||||||
```
|
```
|
||||||
|
|
||||||
## iOS app
|
## iOS app
|
||||||
Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
|
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||||
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
1. macOS Monterey or later
|
|
||||||
1. XCode 13.2+
|
|
||||||
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
|
|
||||||
1. Firebase account
|
|
||||||
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
|
|
||||||
|
|
||||||
### Apple setup
|
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
|
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||||
for these changes to take effect in the iOS app.
|
|
||||||
|
|
||||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
|
||||||
1. Select "Apple Push Notifications service (APNs)"
|
|
||||||
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
|
|
||||||
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
|
|
||||||
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
|
|
||||||
|
|
||||||
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
|
|
||||||
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
|
|
||||||
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
|
|
||||||
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
|
|
||||||
recommended.
|
|
||||||
|
|
||||||
### Firebase setup
|
|
||||||
|
|
||||||
1. If you haven't already, create a Google / Firebase account
|
|
||||||
1. Visit the [Firebase console](https://console.firebase.google.com)
|
|
||||||
1. Create a new Firebase project:
|
|
||||||
1. Enter a project name
|
|
||||||
1. Disable Google Analytics (currently iOS app does not support analytics)
|
|
||||||
1. On the "Project settings" page, add an iOS app
|
|
||||||
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
|
|
||||||
1. Register the app
|
|
||||||
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
|
|
||||||
1. Generate a new service account private key for the ntfy server
|
|
||||||
1. Go to "Project settings" > "Service accounts"
|
|
||||||
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
|
|
||||||
|
|
||||||
### ntfy server
|
|
||||||
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
|
|
||||||
steps:
|
|
||||||
|
|
||||||
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
|
|
||||||
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
|
|
||||||
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
|
|
||||||
1. Install go: `brew install go`
|
|
||||||
1. In the ntfy repository, run `make cli-darwin-server`.
|
|
||||||
|
|
||||||
### XCode setup
|
|
||||||
|
|
||||||
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) 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
|
|
||||||
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
|
|
||||||
|
|
||||||
### PLIST config
|
|
||||||
To have instant notifications/better notification delivery when using firebase, you will need to add the
|
|
||||||
`GoogleService-Info.plist` file to your project. Here's how to do that:
|
|
||||||
|
|
||||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
|
||||||
1. Find the Asset/ folder in the project navigator
|
|
||||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
|
||||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
|
||||||
|
|
||||||
After that, you should be all set!
|
|
||||||
|
|
3636
docs/emojis.md
|
@ -16,7 +16,7 @@ I started adding notifications pretty much all of my scripts. Typically, I just
|
||||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||||
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
||||||
|
|
||||||
``` bash
|
```
|
||||||
rsync -a root@laptop /backups/laptop \
|
rsync -a root@laptop /backups/laptop \
|
||||||
&& zfs snapshot ... \
|
&& zfs snapshot ... \
|
||||||
&& curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \
|
&& curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \
|
||||||
|
@ -26,7 +26,7 @@ rsync -a root@laptop /backups/laptop \
|
||||||
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||||
|
|
||||||
```
|
``` cron
|
||||||
# Check github/ntfy user
|
# Check github/ntfy user
|
||||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||||
```
|
```
|
||||||
|
@ -136,33 +136,27 @@ You can send a message during a workflow run with curl. Here is an example sendi
|
||||||
```
|
```
|
||||||
|
|
||||||
## Watchtower (shoutrrr)
|
## Watchtower (shoutrrr)
|
||||||
You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send
|
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||||
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||||
|
|
||||||
Example docker-compose.yml:
|
Example docker-compose.yml:
|
||||||
|
|
||||||
``` yaml
|
``` yaml
|
||||||
services:
|
services:
|
||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
environment:
|
environment:
|
||||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||||
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||||
```
|
```
|
||||||
|
|
||||||
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 "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||||
|
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||||
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
|
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||||
|
|
||||||
Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect.
|
|
||||||
|
|
||||||
Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc.
|
|
||||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts).
|
|
||||||
|
|
||||||
## Node-RED
|
## Node-RED
|
||||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
@ -419,8 +413,7 @@ alerting:
|
||||||
|
|
||||||
## Jellyseerr/Overseerr webhook
|
## Jellyseerr/Overseerr webhook
|
||||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||||
JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click.
|
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||||
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
|
|
||||||
|
|
||||||
``` json
|
``` json
|
||||||
{
|
{
|
||||||
|
@ -578,27 +571,4 @@ Example `template.html`:
|
||||||
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
||||||

|

|
||||||
|
|
||||||
## Traccar
|
|
||||||
This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes
|
|
||||||
|
|
||||||
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
|
||||||
|
|
||||||
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
|
||||||
```xml
|
|
||||||
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
|
||||||
<entry key='sms.http.template'>
|
|
||||||
{
|
|
||||||
"topic": "{phone}",
|
|
||||||
"message": "{message}"
|
|
||||||
}
|
|
||||||
</entry>
|
|
||||||
```
|
|
||||||
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token
|
|
||||||
```xml
|
|
||||||
<entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>
|
|
||||||
```
|
|
||||||
or by simply providing traccar with a valid username/password combination.
|
|
||||||
```xml
|
|
||||||
<entry key='sms.http.user'>phil</entry>
|
|
||||||
<entry key='sms.http.password'>mypass</entry>
|
|
||||||
```
|
|
||||||
|
|
11
docs/faq.md
|
@ -43,14 +43,9 @@ of the app and [self-host your own ntfy server](install.md).
|
||||||
## How much battery does the Android app use?
|
## How much battery does the Android app use?
|
||||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||||
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||||
or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)),
|
or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes
|
||||||
the app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone).
|
about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||||
There has been a ton of testing and improvement around this. I think it's pretty decent now.
|
decent now.
|
||||||
|
|
||||||
## Paid plans? I thought it was open source?
|
|
||||||
All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you
|
|
||||||
can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher
|
|
||||||
limits.
|
|
||||||
|
|
||||||
## What is instant delivery?
|
## What is instant delivery?
|
||||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
def copy_fonts(config, **kwargs):
|
|
||||||
site_dir = config['site_dir']
|
|
||||||
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
|
|
|
@ -20,46 +20,43 @@ To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` wh
|
||||||
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).
|
|
||||||
It's short and to the point. _I am not affiliated with Kris, I just liked the video._
|
|
||||||
|
|
||||||
## 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
|
||||||
deb/rpm packages.
|
deb/rpm packages.
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_armv6.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_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.5.0/ntfy_2.5.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_armv7.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_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.5.0/ntfy_2.5.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_arm64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -109,7 +106,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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 +114,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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 +122,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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 +130,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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 +140,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.5.0/ntfy_2.5.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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.5.0/ntfy_2.5.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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.5.0/ntfy_2.5.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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.5.0/ntfy_2.5.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
@ -192,36 +189,30 @@ 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.5.0/ntfy_2.5.0_macOS_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_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.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_macOS_all.tar.gz
|
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for
|
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
|
||||||
development as well. Check out the [build instructions](develop.md) for details.
|
[Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it.
|
||||||
|
Also, you can build and run the ntfy server on macOS as well, though I don't officially support that.
|
||||||
## Homebrew
|
Check out the [build instructions](develop.md) for details.
|
||||||
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
|
|
||||||
simply run:
|
|
||||||
```
|
|
||||||
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.5.0/ntfy_2.5.0_windows_x86_64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_windows_x86_64.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).
|
||||||
|
@ -275,7 +266,7 @@ docker run \
|
||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Using docker-compose with non-root user and healthchecks enabled:
|
Using docker-compose with non-root user:
|
||||||
```yaml
|
```yaml
|
||||||
version: "2.1"
|
version: "2.1"
|
||||||
|
|
||||||
|
@ -293,12 +284,6 @@ services:
|
||||||
- /etc/ntfy:/etc/ntfy
|
- /etc/ntfy:/etc/ntfy
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
healthcheck: # optional: remember to adapt the host:port to your environment
|
|
||||||
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
|
|
||||||
interval: 60s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,22 @@ There are quite a few projects that work with ntfy, integrate ntfy, or have been
|
||||||
|
|
||||||
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
|
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
|
||||||
|
|
||||||
|
## Public ntfy servers
|
||||||
|
|
||||||
|
Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the
|
||||||
|
ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
||||||
|
|
||||||
|
| URL | Country |
|
||||||
|
|---------------------------------------------------|--------------------|
|
||||||
|
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
|
||||||
|
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France |
|
||||||
|
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
|
||||||
|
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
||||||
|
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
## Official integrations
|
## Official integrations
|
||||||
|
|
||||||
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
|
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
|
||||||
|
@ -17,20 +33,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
||||||
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
||||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||||
- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring
|
|
||||||
- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool
|
|
||||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||||
- [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
|
|
||||||
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
|
|
||||||
|
|
||||||
## Integration via HTTP/SMTP/etc.
|
|
||||||
|
|
||||||
- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr))
|
|
||||||
- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#))
|
|
||||||
- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))
|
|
||||||
- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook))
|
|
||||||
- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy))
|
|
||||||
|
|
||||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||||
|
|
||||||
|
@ -54,7 +58,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R)
|
- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R)
|
||||||
- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)
|
- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)
|
||||||
- [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)
|
|
||||||
|
|
||||||
## CLIs + GUIs
|
## CLIs + GUIs
|
||||||
|
|
||||||
|
@ -69,8 +72,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
||||||
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
|
|
||||||
- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
|
|
||||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||||
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
||||||
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
||||||
|
@ -80,7 +81,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [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)
|
- [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)
|
|
||||||
- [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)
|
||||||
- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python)
|
- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python)
|
||||||
- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript)
|
- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript)
|
||||||
|
@ -104,52 +104,24 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
|
- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
|
||||||
- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
|
- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
|
||||||
- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost
|
- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost
|
||||||
- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go)
|
|
||||||
- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)
|
- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)
|
||||||
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||||
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
||||||
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
||||||
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
||||||
- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)
|
|
||||||
- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy
|
|
||||||
- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy
|
|
||||||
- [easy2ntfy](https://github.com/chromoxdor/easy2ntfy) - Gateway for ESPeasy to receive commands through ntfy and using easyfetch (HTML/JS)
|
|
||||||
- [ntfy_lite](https://github.com/MPI-IS/ntfy_lite) - Minimalist python API for pushing ntfy notifications (Python)
|
|
||||||
- [notify](https://github.com/guanguans/notify) - 推送通知 (PHP)
|
|
||||||
- [zpool-events](https://github.com/maglar0/zpool-events) - Notify on ZFS pool events (Python)
|
|
||||||
- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)
|
|
||||||
- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)
|
|
||||||
- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)
|
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023
|
|
||||||
- [桌面通知:ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023
|
|
||||||
- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023
|
|
||||||
- [Install ntfy Inside Docker Container in Linux](https://lindevs.com/install-ntfy-inside-docker-container-in-linux) - lindevs.com - 4/2023
|
|
||||||
- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023
|
|
||||||
- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023
|
|
||||||
- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023
|
|
||||||
- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023
|
|
||||||
- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023
|
|
||||||
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
|
|
||||||
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
|
|
||||||
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
|
|
||||||
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
|
|
||||||
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
|
|
||||||
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
|
|
||||||
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
|
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
|
||||||
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
||||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||||
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||||
- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022
|
- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022
|
||||||
- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022
|
|
||||||
- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022
|
- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022
|
||||||
- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022
|
- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022
|
||||||
- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022
|
- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022
|
||||||
- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022
|
- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022
|
||||||
- [How to make my phone buzz*](https://evbogue.com/howtomakemyphonebuzz) - evbogue.com - 11/2022
|
|
||||||
- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022
|
- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022
|
||||||
- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022
|
- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022
|
||||||
- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022
|
- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022
|
||||||
|
@ -187,22 +159,3 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021
|
- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021
|
||||||
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
|
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
|
||||||
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
||||||
|
|
||||||
|
|
||||||
## Alternative ntfy servers
|
|
||||||
|
|
||||||
Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the
|
|
||||||
ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
|
||||||
|
|
||||||
| URL | Country |
|
|
||||||
|---------------------------------------------------|--------------------|
|
|
||||||
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
|
|
||||||
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France |
|
|
||||||
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
|
|
||||||
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
|
||||||
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
|
||||||
| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany |
|
|
||||||
| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France |
|
|
||||||
|
|
||||||
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**.
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ For some (many?) users, the iOS app is not refreshing the view when new notifica
|
||||||
swipe down, you do not see the newly arrived messages, even though the popup appeared before.
|
swipe down, you do not see the newly arrived messages, even though the popup appeared before.
|
||||||
|
|
||||||
This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely
|
This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely
|
||||||
clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it.
|
clueless on how to fix it, sadly, as it is ephemeral and now clear to me what is causing it.
|
||||||
|
|
||||||
Please send experienced iOS developers my way to help me figure this out.
|
Please send experienced iOS developers my way to help me figure this out.
|
||||||
|
|
||||||
|
|
876
docs/publish.md
326
docs/releases.md
|
@ -2,290 +2,7 @@
|
||||||
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 server v2.5.0
|
## ntfy server v1.31.0 (UNRELEASED)
|
||||||
Released May 18, 2023
|
|
||||||
|
|
||||||
This release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls),
|
|
||||||
an admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to
|
|
||||||
upstream servers via the `upstream-access-token` config option.
|
|
||||||
|
|
||||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
|
||||||
and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off
|
|
||||||
if you use promo code `MYTOPIC`). ntfy will always remain open source.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)
|
|
||||||
* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
|
|
||||||
* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Removed old ntfy website from ntfy entirely (no ticket)
|
|
||||||
* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike))
|
|
||||||
* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing)
|
|
||||||
* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion))
|
|
||||||
* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
|
|
||||||
* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting)
|
|
||||||
|
|
||||||
## ntfy server v2.4.0
|
|
||||||
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`,
|
|
||||||
`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)
|
|
||||||
and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
|
|
||||||
will always remain open source.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) can now be installed via Homebrew (thanks to [@Moulick](https://github.com/Moulick))
|
|
||||||
* Added `v1/stats` endpoint to expose messages stats (no ticket)
|
|
||||||
* Support [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2) encoded headers (no ticket, honorable mention to [mqttwarn](https://github.com/jpmens/mqttwarn/pull/638) and [@amotl](https://github.com/amotl))
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Hide country flags on Windows ([#606](https://github.com/binwiederhier/ntfy/issues/606), thanks to [@cmeis](https://github.com/cmeis) for reporting, and to [@pokej6](https://github.com/pokej6) for fixing it)
|
|
||||||
* `ntfy sub` now uses default auth credentials as defined in `client.yml` ([#698](https://github.com/binwiederhier/ntfy/issues/698), thanks to [@CrimsonFez](https://github.com/CrimsonFez) for reporting, and to [@wunter8](https://github.com/wunter8) for fixing it)
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
|
|
||||||
* Updated PowerShell examples ([#697](https://github.com/binwiederhier/ntfy/pull/697), thanks to [@Natfan](https://github.com/Natfan))
|
|
||||||
|
|
||||||
**Additional languages:**
|
|
||||||
|
|
||||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/))
|
|
||||||
|
|
||||||
## ntfy server v2.3.1
|
|
||||||
Released March 30, 2023
|
|
||||||
|
|
||||||
This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem
|
|
||||||
on ntfy.sh that we observe every 20 minutes. The polling was never strictly necessary, and has actually caused duplicate
|
|
||||||
delivery issues as well, so disabling it should not have any negative effects. iOS users, please reach out via Discord
|
|
||||||
or Matrix if there are issues.
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Disable iOS polling entirely ([#677](https://github.com/binwiederhier/ntfy/issues/677)/[#509](https://github.com/binwiederhier/ntfy/issues/509))
|
|
||||||
|
|
||||||
## ntfy server v2.3.0
|
|
||||||
Released March 29, 2023
|
|
||||||
|
|
||||||
This release primarily fixes an issue with delayed messages, and it adds support for Go's profiler (if enabled), which
|
|
||||||
will allow investigating usage spikes in more detail. There will likely be a follow-up release this week to fix the
|
|
||||||
actual spikes [caused by iOS devices](https://github.com/binwiederhier/ntfy/issues/677).
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* ntfy now supports Go's `pprof` profiler, if enabled (relates to [#677](https://github.com/binwiederhier/ntfy/issues/677))
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Fix delayed message sending from authenticated users ([#679](https://github.com/binwiederhier/ntfy/issues/679))
|
|
||||||
* Fixed plural for Polish and other translations ([#678](https://github.com/binwiederhier/ntfy/pull/678), thanks to [@bmoczulski](https://github.com/bmoczulski))
|
|
||||||
|
|
||||||
## ntfy server v2.2.0
|
|
||||||
Released March 17, 2023
|
|
||||||
|
|
||||||
With this release, ntfy is now able to expose metrics via a `/metrics` endpoint for [Prometheus](https://prometheus.io/), if enabled.
|
|
||||||
The endpoint exposes about 20 different counters and gauges, from the number of published messages and emails, to active subscribers,
|
|
||||||
visitors and topics. If you'd like more metrics, pop in the Discord/Matrix or file an issue on GitHub.
|
|
||||||
|
|
||||||
On top of this, you can now use access tokens in the ntfy CLI (defined in the `client.yml` file), fixed a bug in `ntfy subscribe`,
|
|
||||||
removed the dependency on Google Fonts, and more.
|
|
||||||
|
|
||||||
🔥 Reminder: Purchase one of three **ntfy Pro plans** for **50% off** for a limited time (if you use promo code `MYTOPIC`).
|
|
||||||
ntfy Pro gives you higher rate limits and lets you reserve topic names. [Buy through web app](https://ntfy.sh/app).
|
|
||||||
|
|
||||||
❤️ If you don't need ntfy Pro, please consider sponsoring ntfy via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
|
||||||
and [Liberapay](https://en.liberapay.com/ntfy/). ntfy will stay open source forever.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Monitoring: ntfy now exposes a `/metrics` endpoint for [Prometheus](https://prometheus.io/) if [configured](config.md#monitoring) ([#210](https://github.com/binwiederhier/ntfy/issues/210), thanks to [@rogeliodh](https://github.com/rogeliodh) for reporting)
|
|
||||||
* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8))
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8))
|
|
||||||
* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing)
|
|
||||||
* Increase allowed auth failure attempts per IP address to 30 (no ticket)
|
|
||||||
* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket)
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
|
|
||||||
* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix)
|
|
||||||
|
|
||||||
## ntfy server v2.1.2
|
|
||||||
Released March 4, 2023
|
|
||||||
|
|
||||||
This is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the
|
|
||||||
corresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses per day 🤦. This
|
|
||||||
release solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic for 12 hours.
|
|
||||||
|
|
||||||
The release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces
|
|
||||||
a flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder))
|
|
||||||
* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
|
|
||||||
* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
|
|
||||||
|
|
||||||
**Additional languages:**
|
|
||||||
|
|
||||||
* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/))
|
|
||||||
|
|
||||||
## ntfy server v2.1.1
|
|
||||||
Released March 1, 2023
|
|
||||||
|
|
||||||
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
|
||||||
**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉.
|
|
||||||
|
|
||||||
You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...),
|
|
||||||
as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the
|
|
||||||
ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use
|
|
||||||
promo code `MYTOPIC` for a **50% discount**, limited time only).
|
|
||||||
|
|
||||||
And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There
|
|
||||||
are no closed-source features. So if you'd like to run your own server, you can!
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)
|
|
||||||
* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha))
|
|
||||||
* Upgrade dialog: Disable submit button for free tier (no ticket)
|
|
||||||
* Allow multiple `log-level-overrides` on the same field (no ticket)
|
|
||||||
* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)
|
|
||||||
* Added `billing-contact` config option (no ticket)
|
|
||||||
|
|
||||||
## ntfy server v2.1.0
|
|
||||||
Released February 25, 2023
|
|
||||||
|
|
||||||
This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based
|
|
||||||
rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits.
|
|
||||||
However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do
|
|
||||||
no, clients will receive an HTTP 507 response from the server.
|
|
||||||
|
|
||||||
We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers,
|
|
||||||
which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
|
|
||||||
|
|
||||||
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
|
|
||||||
cycles (not live yet).
|
|
||||||
|
|
||||||
As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate
|
|
||||||
limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits
|
|
||||||
a bit more. For 90% of users, you should not feel the difference.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
|
||||||
* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore))
|
|
||||||
* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts))
|
|
||||||
* Payments: Add support for annual billing intervals (no ticket)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting)
|
|
||||||
* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting)
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
|
|
||||||
* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore))
|
|
||||||
|
|
||||||
**Additional languages:**
|
|
||||||
|
|
||||||
* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/))
|
|
||||||
|
|
||||||
## ntfy server v2.0.1
|
|
||||||
Released February 17, 2023
|
|
||||||
|
|
||||||
This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set.
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
|
|
||||||
* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)
|
|
||||||
|
|
||||||
## ntfy server v2.0.0
|
|
||||||
Released February 16, 2023
|
|
||||||
|
|
||||||
This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features.
|
|
||||||
|
|
||||||
**Brand-new features:**
|
|
||||||
|
|
||||||
* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to
|
|
||||||
the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as
|
|
||||||
opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS
|
|
||||||
app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and
|
|
||||||
`enable-login`.
|
|
||||||
<div id="account-screenshots" class="screenshots">
|
|
||||||
<a href="../../static/img/web-signup.png"><img src="../../static/img/web-signup.png"/></a>
|
|
||||||
<a href="../../static/img/web-account.png"><img src="../../static/img/web-account.png"/></a>
|
|
||||||
</div>
|
|
||||||
* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.
|
|
||||||
Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe
|
|
||||||
to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as
|
|
||||||
`deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you
|
|
||||||
can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).
|
|
||||||
Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and
|
|
||||||
only if the user has a [tier](config.md#tiers) that supports reservations.
|
|
||||||
<div id="reserve-screenshots" class="screenshots">
|
|
||||||
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
|
|
||||||
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
|
|
||||||
</div>
|
|
||||||
* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful
|
|
||||||
to avoid having to paste your password to various applications or scripts. For instance, you may want to use a
|
|
||||||
dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured
|
|
||||||
in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),
|
|
||||||
and [publishing using tokens](publish.md#access-tokens).
|
|
||||||
<div id="token-screenshots" class="screenshots">
|
|
||||||
<a href="../../static/img/web-token-create.png"><img src="../../static/img/web-token-create.png"/></a>
|
|
||||||
<a href="../../static/img/web-token-list.png"><img src="../../static/img/web-token-list.png"/></a>
|
|
||||||
</div>
|
|
||||||
* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and
|
|
||||||
troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields
|
|
||||||
that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields
|
|
||||||
match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.
|
|
||||||
See [logging & debugging](config.md#logging-debugging).
|
|
||||||
* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as
|
|
||||||
daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have
|
|
||||||
a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another
|
|
||||||
tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).
|
|
||||||
Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).
|
|
||||||
* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be
|
|
||||||
able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.
|
|
||||||
Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)
|
|
||||||
for details.
|
|
||||||
|
|
||||||
**ntfy is forever open source!**
|
|
||||||
Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything
|
|
||||||
will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
|
||||||
and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep
|
|
||||||
going. It'll only make ntfy better.
|
|
||||||
|
|
||||||
**Other tickets:**
|
|
||||||
|
|
||||||
* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))
|
|
||||||
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
|
|
||||||
|
|
||||||
**Special thanks:**
|
|
||||||
|
|
||||||
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
|
|
||||||
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot.
|
|
||||||
|
|
||||||
## ntfy server v1.31.0
|
|
||||||
Released February 14, 2023
|
|
||||||
|
|
||||||
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
|
|
||||||
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
|
|
||||||
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
|
|
||||||
breaking-change upgrade, which required some work to get working again.
|
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
|
@ -296,20 +13,22 @@ breaking-change upgrade, which required some work to get working again.
|
||||||
|
|
||||||
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
||||||
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
||||||
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
|
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
||||||
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
||||||
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
||||||
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
|
|
||||||
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
|
|
||||||
|
|
||||||
**Additional languages:**
|
**Additional languages:**
|
||||||
|
|
||||||
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
|
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
|
||||||
|
|
||||||
|
**Special thanks:**
|
||||||
|
|
||||||
|
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
|
||||||
|
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, and @xenrox.
|
||||||
|
|
||||||
## ntfy server v1.30.1
|
## ntfy server v1.30.1
|
||||||
Released December 23, 2022 🎅
|
Released December 23, 2022 🎅
|
||||||
|
|
||||||
|
@ -1201,36 +920,3 @@ Released Dec 28, 2021
|
||||||
## Older releases
|
## Older releases
|
||||||
For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
For older releases, check out 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).
|
||||||
|
|
||||||
## Not released yet
|
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
|
||||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
|
||||||
* Bumped all dependencies to the latest versions (no ticket)
|
|
||||||
|
|
||||||
**Additional languages:**
|
|
||||||
|
|
||||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
|
||||||
|
|
||||||
### ntfy server v2.6.0 (UNRELEASED)
|
|
||||||
|
|
||||||
**Bug fixes:**
|
|
||||||
|
|
||||||
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
|
|
||||||
* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)
|
|
||||||
* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
|
|
||||||
|
|
||||||
**Maintenance:**
|
|
||||||
|
|
||||||
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
|
|
BIN
docs/static/audio/ntfy-phone-call.mp3
vendored
BIN
docs/static/audio/ntfy-phone-call.ogg
vendored
98
docs/static/css/extra.css
vendored
|
@ -2,15 +2,16 @@
|
||||||
--md-primary-fg-color: #338574;
|
--md-primary-fg-color: #338574;
|
||||||
--md-primary-fg-color--light: #338574;
|
--md-primary-fg-color--light: #338574;
|
||||||
--md-primary-fg-color--dark: #338574;
|
--md-primary-fg-color--dark: #338574;
|
||||||
--md-footer-bg-color: #353744;
|
|
||||||
--md-text-font: "Roboto";
|
|
||||||
--md-code-font: "Roboto Mono";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-header__button.md-logo :is(img, svg) {
|
.md-header__button.md-logo :is(img, svg) {
|
||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
.md-header__topic:first-child {
|
.md-header__topic:first-child {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
@ -33,30 +34,12 @@ figure img, figure video {
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
|
||||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-md-color-scheme="default"] header {
|
|
||||||
filter: drop-shadow(0 5px 10px #ccc);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-md-color-scheme="slate"] header {
|
|
||||||
filter: drop-shadow(0 5px 10px #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-md-color-scheme="default"] figure img,
|
|
||||||
body[data-md-color-scheme="default"] figure video,
|
|
||||||
body[data-md-color-scheme="default"] .screenshots img,
|
|
||||||
body[data-md-color-scheme="default"] .screenshots video {
|
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-md-color-scheme="slate"] figure img,
|
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
||||||
body[data-md-color-scheme="slate"] figure video,
|
filter: drop-shadow(3px 3px 3px #1a1313);
|
||||||
body[data-md-color-scheme="slate"] .screenshots img,
|
|
||||||
body[data-md-color-scheme="slate"] .screenshots video {
|
|
||||||
filter: drop-shadow(3px 3px 3px #353744);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
figure video {
|
figure video {
|
||||||
|
@ -71,18 +54,7 @@ figure video {
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-md-box td {
|
.remove-md-box td {
|
||||||
padding: 0 10px;
|
padding: 0 10px
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-table .c {
|
|
||||||
vertical-align: middle !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-table .e {
|
|
||||||
font-size: 2.5em;
|
|
||||||
padding: 0 2px !important;
|
|
||||||
text-align: center !important;
|
|
||||||
vertical-align: middle !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
||||||
|
@ -160,57 +132,3 @@ figure video {
|
||||||
.lightbox .close-lightbox:hover::before {
|
.lightbox .close-lightbox:hover::before {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-300 - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-regular - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-italic - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-500 - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-700 - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-mono - latin */
|
|
||||||
@font-face {
|
|
||||||
font-display: swap;
|
|
||||||
font-family: 'Roboto Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Before Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/favicon.ico
vendored
Before Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/favicon.png
vendored
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
docs/static/img/grafana-dashboard.png
vendored
Before Width: | Height: | Size: 334 KiB |
BIN
docs/static/img/web-account.png
vendored
Before Width: | Height: | Size: 98 KiB |
BIN
docs/static/img/web-logs.png
vendored
Before Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/web-phone-verify.png
vendored
Before Width: | Height: | Size: 22 KiB |
BIN
docs/static/img/web-reserve-topic-dialog.png
vendored
Before Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/web-reserve-topic.png
vendored
Before Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/web-signup.png
vendored
Before Width: | Height: | Size: 27 KiB |
BIN
docs/static/img/web-token-create.png
vendored
Before Width: | Height: | Size: 83 KiB |
BIN
docs/static/img/web-token-list.png
vendored
Before Width: | Height: | Size: 93 KiB |
|
@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|
||||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
|
||||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
|
|
|
@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
|
||||||
<figcaption>Execute all the things</figcaption>
|
<figcaption>Execute all the things</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both).
|
If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
|
||||||
You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults
|
`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will
|
||||||
will be used, otherwise, the subscription settings will override the defaults.
|
override the defaults.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not
|
Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication),
|
||||||
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||||
|
|
||||||
### Using the systemd service
|
### Using the systemd service
|
||||||
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
||||||
|
|
|
@ -18,10 +18,3 @@ is to pin the tab so that it's always open, but sort of out of the way:
|
||||||
{ width=500 }
|
{ width=500 }
|
||||||
<figcaption>Pin web app to move it out of the way</figcaption>
|
<figcaption>Pin web app to move it out of the way</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
If topic reservations are enabled, you can claim ownership over topics and define access to it:
|
|
||||||
|
|
||||||
<div id="reserve-screenshots" class="screenshots">
|
|
||||||
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
|
|
||||||
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -1,131 +0,0 @@
|
||||||
# Troubleshooting
|
|
||||||
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
|
|
||||||
If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
|
||||||
and ask there. We're happy to help.
|
|
||||||
|
|
||||||
## ntfy server
|
|
||||||
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing
|
|
||||||
in the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately
|
|
||||||
boils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file:
|
|
||||||
|
|
||||||
=== "server.yml (debug)"
|
|
||||||
``` yaml
|
|
||||||
log-level: debug
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "server.yml (trace)"
|
|
||||||
``` yaml
|
|
||||||
log-level: trace
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace`
|
|
||||||
to the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at
|
|
||||||
the logs using `journalctl -u ntfy -f`. The logs will look something like this:
|
|
||||||
|
|
||||||
=== "Example logs (debug)"
|
|
||||||
```
|
|
||||||
$ ntfy serve --debug
|
|
||||||
2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup)
|
|
||||||
2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)
|
|
||||||
2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
|
||||||
2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
|
||||||
2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
|
||||||
2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
|
||||||
2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
|
||||||
2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Example logs (trace)"
|
|
||||||
```
|
|
||||||
$ ntfy serve --trace
|
|
||||||
2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup)
|
|
||||||
2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)
|
|
||||||
2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1
|
|
||||||
User-Agent: curl/7.81.0
|
|
||||||
Accept: */*
|
|
||||||
Content-Length: 2
|
|
||||||
Content-Type: application/x-www-form-urlencoded
|
|
||||||
|
|
||||||
hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={
|
|
||||||
"id": "Khaup1RVclU3",
|
|
||||||
"time": 1679337659,
|
|
||||||
"expires": 1679380859,
|
|
||||||
"event": "message",
|
|
||||||
"topic": "mytopic",
|
|
||||||
"message": "hi"
|
|
||||||
}, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
|
||||||
2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android app
|
|
||||||
On Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log
|
|
||||||
entries, which you can then copy or upload.
|
|
||||||
|
|
||||||
<figure markdown>
|
|
||||||
{ width=400 }
|
|
||||||
<figcaption>Recording logs on Android</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
When you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all
|
|
||||||
topics and hostnames with fruits. Here's an example:
|
|
||||||
|
|
||||||
```
|
|
||||||
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
|
|
||||||
Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.
|
|
||||||
|
|
||||||
Device info:
|
|
||||||
--
|
|
||||||
ntfy: 1.16.0 (play)
|
|
||||||
OS: 4.19.157-perf+
|
|
||||||
Android: 13 (SDK 33)
|
|
||||||
...
|
|
||||||
|
|
||||||
Logs
|
|
||||||
--
|
|
||||||
|
|
||||||
1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false
|
|
||||||
1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service
|
|
||||||
1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b)
|
|
||||||
1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262
|
|
||||||
1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START
|
|
||||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions
|
|
||||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})]
|
|
||||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})]
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
To get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb).
|
|
||||||
After you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can
|
|
||||||
get detailed logs like so:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Connect to phone (enable Wireless debugging first)
|
|
||||||
adb connect 192.168.1.137:39539
|
|
||||||
|
|
||||||
# Print all logs; you may have to pass the -s option
|
|
||||||
adb logcat
|
|
||||||
adb -s 192.168.1.137:39539 logcat
|
|
||||||
|
|
||||||
# Only list ntfy logs
|
|
||||||
adb logcat --pid=$(adb shell pidof -s io.heckel.ntfy)
|
|
||||||
adb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web app
|
|
||||||
The web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your
|
|
||||||
keyboard.
|
|
||||||
|
|
||||||
<figure markdown>
|
|
||||||

|
|
||||||
<figcaption>Web app logs in the developer console</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
## iOS app
|
|
||||||
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
|
|
61
go.mod
|
@ -4,71 +4,62 @@ go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||||
cloud.google.com/go/storage v1.30.1 // indirect
|
cloud.google.com/go/storage v1.28.1 // indirect
|
||||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/emersion/go-smtp v0.16.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2
|
github.com/gabriel-vasile/mimetype v1.4.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.16
|
github.com/mattn/go-sqlite3 v1.14.16
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/urfave/cli/v2 v2.25.3
|
github.com/urfave/cli/v2 v2.23.7
|
||||||
golang.org/x/crypto v0.9.0
|
golang.org/x/crypto v0.4.0
|
||||||
golang.org/x/oauth2 v0.8.0 // indirect
|
golang.org/x/oauth2 v0.3.0 // indirect
|
||||||
golang.org/x/sync v0.2.0
|
golang.org/x/sync v0.1.0
|
||||||
golang.org/x/term v0.8.0
|
golang.org/x/term v0.3.0
|
||||||
golang.org/x/time v0.3.0
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.122.0
|
google.golang.org/api v0.105.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
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.11.0
|
firebase.google.com/go/v4 v4.10.0
|
||||||
github.com/prometheus/client_golang v1.15.1
|
github.com/stripe/stripe-go/v74 v74.5.0
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.110.2 // indirect
|
cloud.google.com/go v0.107.0 // indirect
|
||||||
cloud.google.com/go/compute v1.19.3 // indirect
|
cloud.google.com/go/compute v1.14.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.0.1 // indirect
|
cloud.google.com/go/iam v0.9.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.4.2 // indirect
|
cloud.google.com/go/longrunning v0.3.0 // 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/beorn7/perks v1.0.1 // 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-20220912192320-0145f2c60ead // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
github.com/golang-jwt/jwt/v4 v4.4.3 // 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.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/google/s2a-go v0.1.3 // indirect
|
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.7.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/common v0.43.0 // indirect
|
|
||||||
github.com/prometheus/procfs v0.9.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-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.4.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.3.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.5.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.3 // indirect
|
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
|
||||||
google.golang.org/grpc v1.55.0 // indirect
|
google.golang.org/grpc v1.51.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
178
go.sum
|
@ -1,21 +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.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww=
|
||||||
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
|
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
|
||||||
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
|
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
||||||
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
|
|
||||||
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.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
|
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
|
||||||
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
|
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||||
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
|
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
|
||||||
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
|
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
||||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
|
||||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
||||||
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||||
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
|
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||||
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=
|
||||||
|
@ -23,43 +22,28 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak
|
||||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.2.1/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/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
|
||||||
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/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/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/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
|
||||||
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 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
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/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-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.15.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/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
|
||||||
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=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
@ -68,19 +52,16 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
|
||||||
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.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 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
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.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
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=
|
||||||
|
@ -90,127 +71,90 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||||
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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
||||||
github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE=
|
|
||||||
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
|
|
||||||
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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||||
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
|
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||||
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
|
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/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.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
|
|
||||||
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
|
|
||||||
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.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
|
|
||||||
github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us=
|
|
||||||
github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc=
|
|
||||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
|
||||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
|
||||||
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/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 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.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
|
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||||
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
||||||
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
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/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
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=
|
|
||||||
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.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||||
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
|
||||||
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/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-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-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
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-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
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.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
|
||||||
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
|
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
|
||||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
|
||||||
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.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
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-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-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.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
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.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
|
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
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.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.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.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
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/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
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=
|
||||||
|
@ -218,37 +162,29 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
||||||
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=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
|
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
|
||||||
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
|
||||||
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.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/appengine/v2 v2.0.3 h1:AyY/mipuqiyCIAqOevfmu5fMDc5/9P/QggWfCQYdkSA=
|
google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk=
|
||||||
google.golang.org/appengine/v2 v2.0.3/go.mod h1:2Z0TTdcXxnHdXzmp8drrmOExUDM2WQgyT33c6JDUlJM=
|
google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E=
|
||||||
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-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
|
||||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||||
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.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
|
||||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
||||||
google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
|
|
||||||
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
|
|
||||||
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=
|
||||||
|
@ -260,12 +196,10 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
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 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
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=
|
||||||
|
|
179
log/event.go
|
@ -3,7 +3,6 @@ package log
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -12,161 +11,88 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fieldTag = "tag"
|
tagField = "tag"
|
||||||
fieldError = "error"
|
errorField = "error"
|
||||||
fieldTimeTaken = "time_taken_ms"
|
|
||||||
fieldExitCode = "exit_code"
|
|
||||||
tagStdLog = "stdlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a single log event
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Timestamp string `json:"time"`
|
Time int64 `json:"time"`
|
||||||
Level Level `json:"level"`
|
Level Level `json:"level"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
time time.Time
|
fields map[string]any
|
||||||
contexters []Contexter
|
|
||||||
fields Context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newEvent creates a new log event
|
|
||||||
//
|
|
||||||
// We delay allocations and processing for efficiency, because most log events
|
|
||||||
// are never actually rendered, so we don't format the time, or allocate a fields map.
|
|
||||||
func newEvent() *Event {
|
func newEvent() *Event {
|
||||||
return &Event{
|
return &Event{
|
||||||
time: time.Now(),
|
Time: time.Now().UnixMilli(),
|
||||||
|
fields: make(map[string]any),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
|
||||||
func (e *Event) Fatal(message string, v ...any) {
|
func (e *Event) Fatal(message string, v ...any) {
|
||||||
e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
|
e.Log(FatalLevel, message, v...)
|
||||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs the event with log level error
|
func (e *Event) Error(message string, v ...any) {
|
||||||
func (e *Event) Error(message string, v ...any) *Event {
|
e.Log(ErrorLevel, message, v...)
|
||||||
return e.Log(ErrorLevel, message, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs the event with log level warn
|
func (e *Event) Warn(message string, v ...any) {
|
||||||
func (e *Event) Warn(message string, v ...any) *Event {
|
e.Log(WarnLevel, message, v...)
|
||||||
return e.Log(WarnLevel, message, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs the event with log level info
|
func (e *Event) Info(message string, v ...any) {
|
||||||
func (e *Event) Info(message string, v ...any) *Event {
|
e.Log(InfoLevel, message, v...)
|
||||||
return e.Log(InfoLevel, message, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs the event with log level debug
|
func (e *Event) Debug(message string, v ...any) {
|
||||||
func (e *Event) Debug(message string, v ...any) *Event {
|
e.Log(DebugLevel, message, v...)
|
||||||
return e.Log(DebugLevel, message, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace logs the event with log level trace
|
func (e *Event) Trace(message string, v ...any) {
|
||||||
func (e *Event) Trace(message string, v ...any) *Event {
|
e.Log(TraceLevel, message, v...)
|
||||||
return e.Log(TraceLevel, message, v...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag adds a "tag" field to the log event
|
|
||||||
func (e *Event) Tag(tag string) *Event {
|
func (e *Event) Tag(tag string) *Event {
|
||||||
return e.Field(fieldTag, tag)
|
e.fields[tagField] = tag
|
||||||
}
|
|
||||||
|
|
||||||
// Time sets the time field
|
|
||||||
func (e *Event) Time(t time.Time) *Event {
|
|
||||||
e.time = t
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timing runs f and records the time if took to execute it in "time_taken_ms"
|
|
||||||
func (e *Event) Timing(f func()) *Event {
|
|
||||||
start := time.Now()
|
|
||||||
f()
|
|
||||||
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Err adds an "error" field to the log event
|
|
||||||
func (e *Event) Err(err error) *Event {
|
func (e *Event) Err(err error) *Event {
|
||||||
if err == nil {
|
e.fields[errorField] = err
|
||||||
return e
|
return e
|
||||||
} else if c, ok := err.(Contexter); ok {
|
|
||||||
return e.With(c)
|
|
||||||
}
|
|
||||||
return e.Field(fieldError, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field adds a custom field and value to the log event
|
|
||||||
func (e *Event) Field(key string, value any) *Event {
|
func (e *Event) Field(key string, value any) *Event {
|
||||||
if e.fields == nil {
|
|
||||||
e.fields = make(Context)
|
|
||||||
}
|
|
||||||
e.fields[key] = value
|
e.fields[key] = value
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// FieldIf adds a custom field and value to the log event if the given level is loggable
|
func (e *Event) Fields(fields map[string]any) *Event {
|
||||||
func (e *Event) FieldIf(key string, value any, level Level) *Event {
|
|
||||||
if e.Loggable(level) {
|
|
||||||
return e.Field(key, value)
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fields adds a map of fields to the log event
|
|
||||||
func (e *Event) Fields(fields Context) *Event {
|
|
||||||
if e.fields == nil {
|
|
||||||
e.fields = make(Context)
|
|
||||||
}
|
|
||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
e.fields[k] = v
|
e.fields[k] = v
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// With adds the fields of the given Contexter structs to the log event by calling their Context method
|
func (e *Event) Context(contexts ...Ctx) *Event {
|
||||||
func (e *Event) With(contexters ...Contexter) *Event {
|
for _, c := range contexts {
|
||||||
if e.contexters == nil {
|
e.Fields(c.Context())
|
||||||
e.contexters = contexters
|
|
||||||
} else {
|
|
||||||
e.contexters = append(e.contexters, contexters...)
|
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render returns the rendered log event as a string, or an empty string. The event is only rendered,
|
func (e *Event) Log(l Level, message string, v ...any) {
|
||||||
// if either the global log level is >= l, or if the log level in one of the overrides matches
|
|
||||||
// the level.
|
|
||||||
//
|
|
||||||
// If no overrides are defined (default), the Contexter array is not applied unless the event
|
|
||||||
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
|
|
||||||
// to determine if they match. This is super complicated, but required for efficiency.
|
|
||||||
func (e *Event) Render(l Level, message string, v ...any) string {
|
|
||||||
appliedContexters := e.maybeApplyContexters()
|
|
||||||
if !e.Loggable(l) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
e.Message = fmt.Sprintf(message, v...)
|
e.Message = fmt.Sprintf(message, v...)
|
||||||
e.Level = l
|
e.Level = l
|
||||||
e.Timestamp = util.FormatTime(e.time)
|
if e.shouldPrint() {
|
||||||
if !appliedContexters {
|
if CurrentFormat() == JSONFormat {
|
||||||
e.applyContexters()
|
log.Println(e.JSON())
|
||||||
|
} else {
|
||||||
|
log.Println(e.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if CurrentFormat() == JSONFormat {
|
|
||||||
return e.JSON()
|
|
||||||
}
|
|
||||||
return e.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log logs the event to the defined output, or does nothing if Render returns an empty string
|
|
||||||
func (e *Event) Log(l Level, message string, v ...any) *Event {
|
|
||||||
if m := e.Render(l, message, v...); m != "" {
|
|
||||||
log.Println(m)
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
@ -184,7 +110,6 @@ func (e *Event) IsDebug() bool {
|
||||||
return e.Loggable(DebugLevel)
|
return e.Loggable(DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON returns the event as a JSON representation
|
|
||||||
func (e *Event) JSON() string {
|
func (e *Event) JSON() string {
|
||||||
b, _ := json.Marshal(e)
|
b, _ := json.Marshal(e)
|
||||||
s := string(b)
|
s := string(b)
|
||||||
|
@ -195,7 +120,6 @@ func (e *Event) JSON() string {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the event as a string
|
|
||||||
func (e *Event) String() string {
|
func (e *Event) String() string {
|
||||||
if len(e.fields) == 0 {
|
if len(e.fields) == 0 {
|
||||||
return fmt.Sprintf("%s %s", e.Level.String(), e.Message)
|
return fmt.Sprintf("%s %s", e.Level.String(), e.Message)
|
||||||
|
@ -208,38 +132,19 @@ func (e *Event) String() string {
|
||||||
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Event) shouldPrint() bool {
|
||||||
|
return e.globalLevelWithOverride() <= e.Level
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Event) globalLevelWithOverride() Level {
|
func (e *Event) globalLevelWithOverride() Level {
|
||||||
mu.RLock()
|
mu.Lock()
|
||||||
l, ov := level, overrides
|
l, ov := level, overrides
|
||||||
mu.RUnlock()
|
mu.Unlock()
|
||||||
if e.fields == nil {
|
for field, override := range ov {
|
||||||
return l
|
|
||||||
}
|
|
||||||
for field, fieldOverrides := range ov {
|
|
||||||
value, exists := e.fields[field]
|
value, exists := e.fields[field]
|
||||||
if exists {
|
if exists && value == override.value {
|
||||||
for _, o := range fieldOverrides {
|
return override.level
|
||||||
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
|
|
||||||
return o.level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Event) maybeApplyContexters() bool {
|
|
||||||
mu.RLock()
|
|
||||||
hasOverrides := len(overrides) > 0
|
|
||||||
mu.RUnlock()
|
|
||||||
if hasOverrides {
|
|
||||||
e.applyContexters()
|
|
||||||
}
|
|
||||||
return hasOverrides // = applied
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) applyContexters() {
|
|
||||||
for _, c := range e.contexters {
|
|
||||||
e.Fields(c.Context())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
116
log/log.go
|
@ -1,38 +1,17 @@
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Defaults for package level variables
|
|
||||||
var (
|
|
||||||
DefaultLevel = InfoLevel
|
|
||||||
DefaultFormat = TextFormat
|
|
||||||
DefaultOutput = &peekLogWriter{os.Stderr}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
level = DefaultLevel
|
level = InfoLevel
|
||||||
format = DefaultFormat
|
format = TextFormat
|
||||||
overrides = make(map[string][]*levelOverride)
|
overrides = make(map[string]*levelOverride)
|
||||||
output io.Writer = DefaultOutput
|
mu = &sync.Mutex{}
|
||||||
filename = ""
|
|
||||||
mu = &sync.RWMutex{}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// init sets the default log output (including log.SetOutput)
|
|
||||||
//
|
|
||||||
// This has to be explicitly called, because DefaultOutput is a peekLogWriter,
|
|
||||||
// which wraps os.Stderr.
|
|
||||||
func init() {
|
|
||||||
SetOutput(DefaultOutput)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fatal prints the given message, and exits the program
|
// Fatal prints the given message, and exits the program
|
||||||
func Fatal(message string, v ...any) {
|
func Fatal(message string, v ...any) {
|
||||||
newEvent().Fatal(message, v...)
|
newEvent().Fatal(message, v...)
|
||||||
|
@ -63,40 +42,26 @@ func Trace(message string, v ...any) {
|
||||||
newEvent().Trace(message, v...)
|
newEvent().Trace(message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With creates a new log event and adds the fields of the given Contexter structs
|
func Context(contexts ...Ctx) *Event {
|
||||||
func With(contexts ...Contexter) *Event {
|
return newEvent().Context(contexts...)
|
||||||
return newEvent().With(contexts...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field creates a new log event and adds a custom field and value to it
|
|
||||||
func Field(key string, value any) *Event {
|
func Field(key string, value any) *Event {
|
||||||
return newEvent().Field(key, value)
|
return newEvent().Field(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fields creates a new log event and adds a map of fields to it
|
func Fields(fields map[string]any) *Event {
|
||||||
func Fields(fields Context) *Event {
|
|
||||||
return newEvent().Fields(fields)
|
return newEvent().Fields(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag creates a new log event and adds a "tag" field to it
|
|
||||||
func Tag(tag string) *Event {
|
func Tag(tag string) *Event {
|
||||||
return newEvent().Tag(tag)
|
return newEvent().Tag(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time creates a new log event and sets the time field
|
|
||||||
func Time(time time.Time) *Event {
|
|
||||||
return newEvent().Time(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timing runs f and records the time if took to execute it in "time_taken_ms"
|
|
||||||
func Timing(f func()) *Event {
|
|
||||||
return newEvent().Timing(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentLevel returns the current log level
|
// CurrentLevel returns the current log level
|
||||||
func CurrentLevel() Level {
|
func CurrentLevel() Level {
|
||||||
mu.RLock()
|
mu.Lock()
|
||||||
defer mu.RUnlock()
|
defer mu.Unlock()
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,26 +73,23 @@ func SetLevel(newLevel Level) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLevelOverride adds a log override for the given field
|
// SetLevelOverride adds a log override for the given field
|
||||||
func SetLevelOverride(field string, value string, level Level) {
|
func SetLevelOverride(field string, value any, level Level) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
if _, ok := overrides[field]; !ok {
|
overrides[field] = &levelOverride{value: value, level: level}
|
||||||
overrides[field] = make([]*levelOverride, 0)
|
|
||||||
}
|
|
||||||
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetLevelOverrides removes all log level overrides
|
// ResetLevelOverride removes all log level overrides
|
||||||
func ResetLevelOverrides() {
|
func ResetLevelOverride() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
overrides = make(map[string][]*levelOverride)
|
overrides = make(map[string]*levelOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentFormat returns the current log format
|
// CurrentFormat returns the current log formt
|
||||||
func CurrentFormat() Format {
|
func CurrentFormat() Format {
|
||||||
mu.RLock()
|
mu.Lock()
|
||||||
defer mu.RUnlock()
|
defer mu.Unlock()
|
||||||
return format
|
return format
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,33 +103,6 @@ func SetFormat(newFormat Format) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOutput sets the log output writer
|
|
||||||
func SetOutput(w io.Writer) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
output = &peekLogWriter{w}
|
|
||||||
if f, ok := w.(*os.File); ok {
|
|
||||||
filename = f.Name()
|
|
||||||
} else {
|
|
||||||
filename = ""
|
|
||||||
}
|
|
||||||
log.SetOutput(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// File returns the log file, if any, or an empty string otherwise
|
|
||||||
func File() string {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFile returns true if the output is a non-default file
|
|
||||||
func IsFile() bool {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
return filename != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// DisableDates disables the date/time prefix
|
// DisableDates disables the date/time prefix
|
||||||
func DisableDates() {
|
func DisableDates() {
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
|
@ -187,20 +122,3 @@ func IsTrace() bool {
|
||||||
func IsDebug() bool {
|
func IsDebug() bool {
|
||||||
return Loggable(DebugLevel)
|
return Loggable(DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// peekLogWriter is an io.Writer which will peek at the rendered log event,
|
|
||||||
// and ensure that the rendered output is valid JSON. This is a hack!
|
|
||||||
type peekLogWriter struct {
|
|
||||||
w io.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *peekLogWriter) Write(p []byte) (n int, err error) {
|
|
||||||
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
|
|
||||||
return w.w.Write(p)
|
|
||||||
}
|
|
||||||
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
|
|
||||||
if m == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return w.w.Write([]byte(m + "\n"))
|
|
||||||
}
|
|
||||||
|
|
336
log/log_test.go
|
@ -1,303 +1,57 @@
|
||||||
package log
|
package log_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"heckel.io/ntfy/log"
|
||||||
"encoding/json"
|
"net/http"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
const tagPay = "PAY"
|
||||||
exitCode := m.Run()
|
|
||||||
resetState()
|
type visitor struct {
|
||||||
SetLevel(ErrorLevel) // For other modules!
|
UserID string
|
||||||
os.Exit(exitCode)
|
IP string
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLog_TagContextFieldFields(t *testing.T) {
|
func (v *visitor) Context() map[string]any {
|
||||||
t.Cleanup(resetState)
|
return map[string]any{
|
||||||
v := &fakeVisitor{
|
"user_id": v.UserID,
|
||||||
|
"ip": v.IP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvent_Info(t *testing.T) {
|
||||||
|
/*
|
||||||
|
log-level: INFO, user_id:u_abc=DEBUG
|
||||||
|
log-level-overrides:
|
||||||
|
- user_id=u_abc: DEBUG
|
||||||
|
log-filter =
|
||||||
|
|
||||||
|
*/
|
||||||
|
v := &visitor{
|
||||||
UserID: "u_abc",
|
UserID: "u_abc",
|
||||||
IP: "1.2.3.4",
|
IP: "1.2.3.4",
|
||||||
}
|
}
|
||||||
err := &fakeError{
|
stripeCtx := log.NewCtx(map[string]any{
|
||||||
Code: 123,
|
"tag": "pay",
|
||||||
Message: "some error",
|
})
|
||||||
}
|
log.SetLevel(log.InfoLevel)
|
||||||
var out bytes.Buffer
|
//log.SetFormat(log.JSONFormat)
|
||||||
SetOutput(&out)
|
//log.SetLevelOverride("user_id", "u_abc", log.DebugLevel)
|
||||||
SetFormat(JSONFormat)
|
log.SetLevelOverride("tag", "pay", log.DebugLevel)
|
||||||
SetLevelOverride("tag", "stripe", DebugLevel)
|
mlog := log.Field("tag", "manager")
|
||||||
SetLevelOverride("number", "5", DebugLevel)
|
mlog.Field("one", 1).Info("this is one")
|
||||||
|
mlog.Err(http.ErrHandlerTimeout).Field("two", 2).Info("this is two")
|
||||||
Tag("mytag").
|
log.Info("somebody did something")
|
||||||
Field("field2", 123).
|
log.
|
||||||
Field("field1", "value1").
|
Context(stripeCtx, v).
|
||||||
Time(time.Unix(123, 999000000).UTC()).
|
Fields(map[string]any{
|
||||||
Info("hi there %s", "phil")
|
"tier": "ti_abc",
|
||||||
|
"user_id": "u_abc",
|
||||||
Tag("not-stripe").
|
|
||||||
Debug("this message will not appear")
|
|
||||||
|
|
||||||
With(v).
|
|
||||||
Fields(Context{
|
|
||||||
"stripe_customer_id": "acct_123",
|
|
||||||
"stripe_subscription_id": "sub_123",
|
|
||||||
}).
|
}).
|
||||||
Tag("stripe").
|
Debug("Somebody paid something for $%d", 10)
|
||||||
Err(err).
|
log.
|
||||||
Time(time.Unix(456, 123000000).UTC()).
|
Field("tag", "account").
|
||||||
Debug("Subscription status %s", "active")
|
Field("user_id", "u_abc").
|
||||||
|
Debug("User logged in")
|
||||||
Field("number", 5).
|
|
||||||
Time(time.Unix(777, 001000000).UTC()).
|
|
||||||
Debug("The number 5 is an int, but the level override is a string")
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"}
|
|
||||||
{"time":"1970-01-01T00:07:36.123Z","level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
|
||||||
{"time":"1970-01-01T00:12:57Z","level":"DEBUG","message":"The number 5 is an int, but the level override is a string","number":5}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_NoAllocIfNotPrinted(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
v := &fakeVisitor{
|
|
||||||
UserID: "u_abc",
|
|
||||||
IP: "1.2.3.4",
|
|
||||||
}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
|
|
||||||
// Do not log, do not call contexters (because global level is INFO)
|
|
||||||
v.contextCalled = false
|
|
||||||
ev := With(v)
|
|
||||||
ev.Debug("some message")
|
|
||||||
require.False(t, v.contextCalled)
|
|
||||||
require.Equal(t, "", ev.Timestamp)
|
|
||||||
require.Equal(t, Level(0), ev.Level)
|
|
||||||
require.Equal(t, "", ev.Message)
|
|
||||||
require.Nil(t, ev.fields)
|
|
||||||
|
|
||||||
// Logged because info level, contexters called
|
|
||||||
v.contextCalled = false
|
|
||||||
ev = With(v).Time(time.Unix(1111, 0).UTC())
|
|
||||||
ev.Info("some message")
|
|
||||||
require.True(t, v.contextCalled)
|
|
||||||
require.NotNil(t, ev.fields)
|
|
||||||
require.Equal(t, "1.2.3.4", ev.fields["visitor_ip"])
|
|
||||||
|
|
||||||
// Not logged, but contexters called, because overrides exist
|
|
||||||
SetLevel(DebugLevel)
|
|
||||||
SetLevelOverride("tag", "overridetag", TraceLevel)
|
|
||||||
v.contextCalled = false
|
|
||||||
ev = Tag("sometag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
|
|
||||||
ev.Trace("some debug message")
|
|
||||||
require.True(t, v.contextCalled) // If there are overrides, we must call the context to determine the filter fields
|
|
||||||
require.Equal(t, "", ev.Timestamp)
|
|
||||||
require.Equal(t, Level(0), ev.Level)
|
|
||||||
require.Equal(t, "", ev.Message)
|
|
||||||
require.Equal(t, 4, len(ev.fields))
|
|
||||||
require.Equal(t, "value", ev.fields["field"])
|
|
||||||
require.Equal(t, "sometag", ev.fields["tag"])
|
|
||||||
|
|
||||||
// Logged because of override tag, and contexters called
|
|
||||||
v.contextCalled = false
|
|
||||||
ev = Tag("overridetag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
|
|
||||||
ev.Trace("some trace message")
|
|
||||||
require.True(t, v.contextCalled)
|
|
||||||
require.Equal(t, "1970-01-01T00:02:03Z", ev.Timestamp)
|
|
||||||
require.Equal(t, TraceLevel, ev.Level)
|
|
||||||
require.Equal(t, "some trace message", ev.Message)
|
|
||||||
|
|
||||||
// Logged because of field override, and contexters called
|
|
||||||
ResetLevelOverrides()
|
|
||||||
SetLevelOverride("visitor_ip", "1.2.3.4", TraceLevel)
|
|
||||||
v.contextCalled = false
|
|
||||||
ev = With(v).Time(time.Unix(124, 0).UTC())
|
|
||||||
ev.Trace("some trace message with override")
|
|
||||||
require.True(t, v.contextCalled)
|
|
||||||
require.Equal(t, "1970-01-01T00:02:04Z", ev.Timestamp)
|
|
||||||
require.Equal(t, TraceLevel, ev.Level)
|
|
||||||
require.Equal(t, "some trace message with override", ev.Message)
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:18:31Z","level":"INFO","message":"some message","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
|
||||||
{"time":"1970-01-01T00:02:03Z","level":"TRACE","message":"some trace message","field":"value","tag":"overridetag","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
|
||||||
{"time":"1970-01-01T00:02:04Z","level":"TRACE","message":"some trace message with override","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_Timing(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
|
|
||||||
Timing(func() { time.Sleep(300 * time.Millisecond) }).
|
|
||||||
Time(time.Unix(12, 0).UTC()).
|
|
||||||
Info("A thing that takes a while")
|
|
||||||
|
|
||||||
var ev struct {
|
|
||||||
TimeTakenMs int64 `json:"time_taken_ms"`
|
|
||||||
}
|
|
||||||
require.Nil(t, json.Unmarshal(out.Bytes(), &ev))
|
|
||||||
require.True(t, ev.TimeTakenMs >= 300)
|
|
||||||
require.Contains(t, out.String(), `{"time":"1970-01-01T00:00:12Z","level":"INFO","message":"A thing that takes a while","time_taken_ms":`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_LevelOverrideAny(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
SetLevelOverride("this_one", "", DebugLevel)
|
|
||||||
SetLevelOverride("time_taken_ms", "", TraceLevel)
|
|
||||||
|
|
||||||
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Debug("this is logged")
|
|
||||||
Time(time.Unix(12, 0).UTC()).Field("not_this", "11").Debug("this is not logged")
|
|
||||||
Time(time.Unix(13, 0).UTC()).Field("this_too", "11").Info("this is also logged")
|
|
||||||
Time(time.Unix(14, 0).UTC()).Field("time_taken_ms", 0).Info("this is also logged")
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","this_one":"11"}
|
|
||||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","this_too":"11"}
|
|
||||||
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
require.False(t, IsFile())
|
|
||||||
require.Equal(t, "", File())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
SetLevelOverride("tag", "manager", DebugLevel)
|
|
||||||
SetLevelOverride("tag", "publish", DebugLevel)
|
|
||||||
|
|
||||||
Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged")
|
|
||||||
Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged")
|
|
||||||
Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged")
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"}
|
|
||||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
require.False(t, IsFile())
|
|
||||||
require.Equal(t, "", File())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_FieldIf(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetLevel(DebugLevel)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
|
|
||||||
Time(time.Unix(11, 0).UTC()).
|
|
||||||
FieldIf("trace_field", "manager", TraceLevel). // This is not logged
|
|
||||||
Field("tag", "manager").
|
|
||||||
Debug("trace_field is not logged")
|
|
||||||
SetLevel(TraceLevel)
|
|
||||||
Time(time.Unix(12, 0).UTC()).
|
|
||||||
FieldIf("trace_field", "manager", TraceLevel). // Now it is logged
|
|
||||||
Field("tag", "manager").
|
|
||||||
Debug("trace_field is logged")
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"}
|
|
||||||
{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
|
|
||||||
log.Println("Some other library is using the standard Go logger")
|
|
||||||
require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_UsingStdLogger_Text(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
|
|
||||||
log.Println("Some other library is using the standard Go logger")
|
|
||||||
require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n")
|
|
||||||
require.NotContains(t, out.String(), `{`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_File(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
logfile := filepath.Join(t.TempDir(), "ntfy.log")
|
|
||||||
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)
|
|
||||||
require.Nil(t, err)
|
|
||||||
SetOutput(f)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
require.True(t, IsFile())
|
|
||||||
require.Equal(t, logfile, File())
|
|
||||||
|
|
||||||
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged")
|
|
||||||
require.Nil(t, f.Close())
|
|
||||||
|
|
||||||
f, err = os.Open(logfile)
|
|
||||||
require.Nil(t, err)
|
|
||||||
contents, err := io.ReadAll(f)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents))
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeError struct {
|
|
||||||
Code int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e fakeError) Error() string {
|
|
||||||
return e.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e fakeError) Context() Context {
|
|
||||||
return Context{
|
|
||||||
"error": e.Message,
|
|
||||||
"error_code": e.Code,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeVisitor struct {
|
|
||||||
UserID string
|
|
||||||
IP string
|
|
||||||
contextCalled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *fakeVisitor) Context() Context {
|
|
||||||
v.contextCalled = true
|
|
||||||
return Context{
|
|
||||||
"user_id": v.UserID,
|
|
||||||
"visitor_ip": v.IP,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetState() {
|
|
||||||
SetLevel(DefaultLevel)
|
|
||||||
SetFormat(DefaultFormat)
|
|
||||||
SetOutput(DefaultOutput)
|
|
||||||
ResetLevelOverrides()
|
|
||||||
}
|
}
|
||||||
|
|
24
log/types.go
|
@ -36,7 +36,6 @@ func (l Level) String() string {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON converts a level to a JSON string
|
|
||||||
func (l Level) MarshalJSON() ([]byte, error) {
|
func (l Level) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(l.String())
|
return json.Marshal(l.String())
|
||||||
}
|
}
|
||||||
|
@ -55,8 +54,6 @@ func ToLevel(s string) Level {
|
||||||
return WarnLevel
|
return WarnLevel
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
return ErrorLevel
|
return ErrorLevel
|
||||||
case "FATAL":
|
|
||||||
return FatalLevel
|
|
||||||
default:
|
default:
|
||||||
return InfoLevel
|
return InfoLevel
|
||||||
}
|
}
|
||||||
|
@ -94,22 +91,21 @@ func ToFormat(s string) Format {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contexter allows structs to export a key-value pairs in the form of a Context
|
type Ctx interface {
|
||||||
type Contexter interface {
|
Context() map[string]any
|
||||||
Context() Context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context represents an object's state in the form of key-value pairs
|
type fieldsCtx map[string]any
|
||||||
type Context map[string]any
|
|
||||||
|
|
||||||
// Merge merges other into this context
|
func (f fieldsCtx) Context() map[string]any {
|
||||||
func (c Context) Merge(other Context) {
|
return f
|
||||||
for k, v := range other {
|
}
|
||||||
c[k] = v
|
|
||||||
}
|
func NewCtx(fields map[string]any) Ctx {
|
||||||
|
return fieldsCtx(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
type levelOverride struct {
|
type levelOverride struct {
|
||||||
value string
|
value any
|
||||||
level Level
|
level Level
|
||||||
}
|
}
|
||||||
|
|
10
mkdocs.yml
|
@ -9,11 +9,9 @@ edit_uri: blob/main/docs/
|
||||||
|
|
||||||
theme:
|
theme:
|
||||||
name: material
|
name: material
|
||||||
font: false
|
|
||||||
language: en
|
language: en
|
||||||
custom_dir: docs/_overrides
|
|
||||||
logo: static/img/ntfy.png
|
logo: static/img/ntfy.png
|
||||||
favicon: static/img/favicon.ico
|
favicon: static/img/favicon.png
|
||||||
include_search_page: false
|
include_search_page: false
|
||||||
search_index_only: true
|
search_index_only: true
|
||||||
palette:
|
palette:
|
||||||
|
@ -71,9 +69,6 @@ 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
|
||||||
|
@ -81,7 +76,7 @@ nav:
|
||||||
- "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 UI": subscribe/web.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":
|
||||||
|
@ -93,7 +88,6 @@ nav:
|
||||||
- "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
|
|
||||||
- "Known issues": known-issues.md
|
- "Known issues": known-issues.md
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.md
|
||||||
- "Development": develop.md
|
- "Development": develop.md
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other
|
||||||
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/#tags-emojis).
|
||||||
|
|
||||||
<table class=\"remove-md-box emoji-table\"><tr>
|
<table class="remove-md-box"><tr>
|
||||||
" > "$1"
|
" > "$1"
|
||||||
|
|
||||||
count="$(cat "$SCRIPTDIR/emoji.json" | jq -r '.[] | .emoji' | wc -l)"
|
count="$(cat "$SCRIPTDIR/emoji.json" | jq -r '.[] | .emoji' | wc -l)"
|
||||||
|
@ -37,9 +37,9 @@ converted to emojis. This is a reference of all supported emojis. To learn more
|
||||||
for col in 0 1 2; do
|
for col in 0 1 2; do
|
||||||
from="$(($col * $percolumn + 1))"
|
from="$(($col * $percolumn + 1))"
|
||||||
to="$(($col * $percolumn + 1 + $percolumn))"
|
to="$(($col * $percolumn + 1 + $percolumn))"
|
||||||
echo "<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>" >> "$1"
|
echo "<td><table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody>" >> "$1"
|
||||||
cat "$SCRIPTDIR/emoji.json" \
|
cat "$SCRIPTDIR/emoji.json" \
|
||||||
| jq -r '.[] | "<tr><td class=c><code>" + .aliases[0] + "</code></td><td class=e>" + .emoji + "</td></tr>"' \
|
| jq -r '.[] | "<tr><td><code>" + .aliases[0] + "</code></td><td>" + .emoji + "</td></tr>"' \
|
||||||
| sed -n "${from},${to}p" >> "$1"
|
| sed -n "${from},${to}p" >> "$1"
|
||||||
echo "</tbody></table></td>" >> "$1"
|
echo "</tbody></table></td>" >> "$1"
|
||||||
done
|
done
|
||||||
|
|
|
@ -19,7 +19,7 @@ const (
|
||||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
DefaultStripePriceCacheDuration = time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all global and per-visitor limits
|
// Defines all global and per-visitor limits
|
||||||
|
@ -49,8 +49,6 @@ const (
|
||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorAccountCreationLimitBurst = 3
|
DefaultVisitorAccountCreationLimitBurst = 3
|
||||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||||
DefaultVisitorAuthFailureLimitBurst = 30
|
|
||||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
)
|
)
|
||||||
|
@ -58,15 +56,10 @@ const (
|
||||||
var (
|
var (
|
||||||
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
||||||
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
|
|
||||||
// extended using the server.yml config. If updated, also update in Android and web app.
|
|
||||||
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
File string // Config file, only used for testing
|
|
||||||
BaseURL string
|
BaseURL string
|
||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
ListenHTTPS string
|
ListenHTTPS string
|
||||||
|
@ -91,14 +84,12 @@ type Config struct {
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
DisallowedTopics []string
|
WebRootIsApp bool
|
||||||
WebRoot string // empty to disable
|
|
||||||
DelayedSenderInterval time.Duration
|
DelayedSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
FirebasePollInterval time.Duration
|
FirebasePollInterval time.Duration
|
||||||
FirebaseQuotaExceededPenaltyDuration time.Duration
|
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||||
UpstreamBaseURL string
|
UpstreamBaseURL string
|
||||||
UpstreamAccessToken string
|
|
||||||
SMTPSenderAddr string
|
SMTPSenderAddr string
|
||||||
SMTPSenderUser string
|
SMTPSenderUser string
|
||||||
SMTPSenderPass string
|
SMTPSenderPass string
|
||||||
|
@ -106,15 +97,6 @@ type Config struct {
|
||||||
SMTPServerListen string
|
SMTPServerListen string
|
||||||
SMTPServerDomain string
|
SMTPServerDomain string
|
||||||
SMTPServerAddrPrefix string
|
SMTPServerAddrPrefix string
|
||||||
TwilioAccount string
|
|
||||||
TwilioAuthToken string
|
|
||||||
TwilioPhoneNumber string
|
|
||||||
TwilioCallsBaseURL string
|
|
||||||
TwilioVerifyBaseURL string
|
|
||||||
TwilioVerifyService string
|
|
||||||
MetricsEnable bool
|
|
||||||
MetricsListenHTTP string
|
|
||||||
ProfileListenHTTP string
|
|
||||||
MessageLimit int
|
MessageLimit int
|
||||||
MinDelay time.Duration
|
MinDelay time.Duration
|
||||||
MaxDelay time.Duration
|
MaxDelay time.Duration
|
||||||
|
@ -131,19 +113,15 @@ type Config struct {
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
VisitorAccountCreationLimitBurst int
|
VisitorAccountCreationLimitBurst int
|
||||||
VisitorAccountCreationLimitReplenish time.Duration
|
VisitorAccountCreationLimitReplenish time.Duration
|
||||||
VisitorAuthFailureLimitBurst int
|
|
||||||
VisitorAuthFailureLimitReplenish time.Duration
|
|
||||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||||
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
|
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
BillingContact string
|
EnableWeb bool
|
||||||
EnableSignup bool // Enable creation of accounts via API and UI
|
EnableSignup bool // Enable creation of accounts via API and UI
|
||||||
EnableLogin bool
|
EnableLogin bool
|
||||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||||
EnableMetrics bool
|
|
||||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
Version string // injected by App
|
Version string // injected by App
|
||||||
}
|
}
|
||||||
|
@ -151,7 +129,6 @@ type Config struct {
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
File: "", // Only used for testing
|
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
|
@ -167,7 +144,7 @@ func NewConfig() *Config {
|
||||||
CacheBatchTimeout: 0,
|
CacheBatchTimeout: 0,
|
||||||
AuthFile: "",
|
AuthFile: "",
|
||||||
AuthStartupQueries: "",
|
AuthStartupQueries: "",
|
||||||
AuthDefault: user.PermissionReadWrite,
|
AuthDefault: user.NewPermission(true, true),
|
||||||
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||||
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||||
AttachmentCacheDir: "",
|
AttachmentCacheDir: "",
|
||||||
|
@ -176,14 +153,12 @@ func NewConfig() *Config {
|
||||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
DisallowedTopics: DefaultDisallowedTopics,
|
WebRootIsApp: false,
|
||||||
WebRoot: "/",
|
|
||||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||||
FirebasePollInterval: DefaultFirebasePollInterval,
|
FirebasePollInterval: DefaultFirebasePollInterval,
|
||||||
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
||||||
UpstreamBaseURL: "",
|
UpstreamBaseURL: "",
|
||||||
UpstreamAccessToken: "",
|
|
||||||
SMTPSenderAddr: "",
|
SMTPSenderAddr: "",
|
||||||
SMTPSenderUser: "",
|
SMTPSenderUser: "",
|
||||||
SMTPSenderPass: "",
|
SMTPSenderPass: "",
|
||||||
|
@ -191,12 +166,6 @@ func NewConfig() *Config {
|
||||||
SMTPServerListen: "",
|
SMTPServerListen: "",
|
||||||
SMTPServerDomain: "",
|
SMTPServerDomain: "",
|
||||||
SMTPServerAddrPrefix: "",
|
SMTPServerAddrPrefix: "",
|
||||||
TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
|
|
||||||
TwilioAccount: "",
|
|
||||||
TwilioAuthToken: "",
|
|
||||||
TwilioPhoneNumber: "",
|
|
||||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
|
||||||
TwilioVerifyService: "",
|
|
||||||
MessageLimit: DefaultMessageLengthLimit,
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
MinDelay: DefaultMinDelay,
|
MinDelay: DefaultMinDelay,
|
||||||
MaxDelay: DefaultMaxDelay,
|
MaxDelay: DefaultMaxDelay,
|
||||||
|
@ -213,15 +182,12 @@ func NewConfig() *Config {
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
||||||
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
||||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
|
||||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
VisitorSubscriberRateLimiting: false,
|
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
BillingContact: "",
|
EnableWeb: true,
|
||||||
EnableSignup: false,
|
EnableSignup: false,
|
||||||
EnableLogin: false,
|
EnableLogin: false,
|
||||||
EnableReservations: false,
|
EnableReservations: false,
|
||||||
|
|
166
server/errors.go
|
@ -3,7 +3,6 @@ package server
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -13,7 +12,6 @@ type errHTTP struct {
|
||||||
HTTPCode int `json:"http"`
|
HTTPCode int `json:"http"`
|
||||||
Message string `json:"error"`
|
Message string `json:"error"`
|
||||||
Link string `json:"link,omitempty"`
|
Link string `json:"link,omitempty"`
|
||||||
context log.Context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e errHTTP) Error() string {
|
func (e errHTTP) Error() string {
|
||||||
|
@ -25,118 +23,62 @@ func (e errHTTP) JSON() string {
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e errHTTP) Context() log.Context {
|
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
||||||
context := log.Context{
|
return &errHTTP{
|
||||||
"error": e.Message,
|
Code: err.Code,
|
||||||
"error_code": e.Code,
|
HTTPCode: err.HTTPCode,
|
||||||
"http_status": e.HTTPCode,
|
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
|
||||||
}
|
Link: err.Link,
|
||||||
for k, v := range e.context {
|
|
||||||
context[k] = v
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
|
|
||||||
clone := e.clone()
|
|
||||||
clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...))
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errHTTP) With(contexters ...log.Contexter) *errHTTP {
|
|
||||||
c := e.clone()
|
|
||||||
if c.context == nil {
|
|
||||||
c.context = make(log.Context)
|
|
||||||
}
|
|
||||||
for _, contexter := range contexters {
|
|
||||||
c.context.Merge(contexter.Context())
|
|
||||||
}
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errHTTP) Fields(context log.Context) *errHTTP {
|
|
||||||
c := e.clone()
|
|
||||||
if c.context == nil {
|
|
||||||
c.context = make(log.Context)
|
|
||||||
}
|
|
||||||
c.context.Merge(context)
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errHTTP) clone() errHTTP {
|
|
||||||
context := make(log.Context)
|
|
||||||
for k, v := range e.context {
|
|
||||||
context[k] = v
|
|
||||||
}
|
|
||||||
return errHTTP{
|
|
||||||
Code: e.Code,
|
|
||||||
HTTPCode: e.HTTPCode,
|
|
||||||
Message: e.Message,
|
|
||||||
Link: e.Link,
|
|
||||||
context: context,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""}
|
||||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
|
||||||
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil}
|
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||||
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil}
|
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
||||||
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil}
|
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
|
||||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
|
||||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is disallowed", ""}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil}
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil}
|
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil}
|
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
||||||
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil}
|
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil}
|
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil}
|
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
||||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil}
|
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil}
|
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
||||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
||||||
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil}
|
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
||||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
|
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
|
||||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
|
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||||
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||||
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
|
||||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
|
||||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
||||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
|
||||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
|
||||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
|
||||||
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
|
||||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
|
||||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
|
||||||
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -44,7 +44,6 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
||||||
if !fileIDRegex.MatchString(id) {
|
if !fileIDRegex.MatchString(id) {
|
||||||
return 0, errInvalidFileID
|
return 0, errInvalidFileID
|
||||||
}
|
}
|
||||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
|
|
||||||
file := filepath.Join(c.dir, id)
|
file := filepath.Join(c.dir, id)
|
||||||
if _, err := os.Stat(file); err == nil {
|
if _, err := os.Stat(file); err == nil {
|
||||||
return 0, errFileExists
|
return 0, errFileExists
|
||||||
|
@ -67,7 +66,6 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.totalSizeCurrent += size
|
c.totalSizeCurrent += size
|
||||||
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
@ -77,10 +75,10 @@ func (c *fileCache) Remove(ids ...string) error {
|
||||||
if !fileIDRegex.MatchString(id) {
|
if !fileIDRegex.MatchString(id) {
|
||||||
return errInvalidFileID
|
return errInvalidFileID
|
||||||
}
|
}
|
||||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment")
|
log.Debug("File Cache: Deleting attachment %s", id)
|
||||||
file := filepath.Join(c.dir, id)
|
file := filepath.Join(c.dir, id)
|
||||||
if err := os.Remove(file); err != nil {
|
if err := os.Remove(file); err != nil {
|
||||||
log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment")
|
log.Debug("File Cache: Error deleting attachment %s: %s", id, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
size, err := dirSize(c.dir)
|
size, err := dirSize(c.dir)
|
||||||
|
@ -90,7 +88,6 @@ func (c *fileCache) Remove(ids ...string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.totalSizeCurrent = size
|
c.totalSizeCurrent = size
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
mset(metricAttachmentsTotalSize, size)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
124
server/log.go
|
@ -1,124 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Log tags
|
|
||||||
const (
|
|
||||||
tagStartup = "startup"
|
|
||||||
tagHTTP = "http"
|
|
||||||
tagPublish = "publish"
|
|
||||||
tagSubscribe = "subscribe"
|
|
||||||
tagFirebase = "firebase"
|
|
||||||
tagSMTP = "smtp" // Receive email
|
|
||||||
tagEmail = "email" // Send email
|
|
||||||
tagTwilio = "twilio"
|
|
||||||
tagFileCache = "file_cache"
|
|
||||||
tagMessageCache = "message_cache"
|
|
||||||
tagStripe = "stripe"
|
|
||||||
tagAccount = "account"
|
|
||||||
tagManager = "manager"
|
|
||||||
tagResetter = "resetter"
|
|
||||||
tagWebsocket = "websocket"
|
|
||||||
tagMatrix = "matrix"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
|
|
||||||
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
|
|
||||||
)
|
|
||||||
|
|
||||||
// logr creates a new log event with HTTP request fields
|
|
||||||
func logr(r *http.Request) *log.Event {
|
|
||||||
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten
|
|
||||||
}
|
|
||||||
|
|
||||||
// logv creates a new log event with visitor fields
|
|
||||||
func logv(v *visitor) *log.Event {
|
|
||||||
return log.With(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logvr creates a new log event with HTTP request and visitor fields
|
|
||||||
func logvr(v *visitor, r *http.Request) *log.Event {
|
|
||||||
return logr(r).With(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logvrm creates a new log event with HTTP request, visitor fields and message fields
|
|
||||||
func logvrm(v *visitor, r *http.Request, m *message) *log.Event {
|
|
||||||
return logvr(v, r).With(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logvrm creates a new log event with visitor fields and message fields
|
|
||||||
func logvm(v *visitor, m *message) *log.Event {
|
|
||||||
return logv(v).With(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logem creates a new log event with email fields
|
|
||||||
func logem(smtpConn *smtp.Conn) *log.Event {
|
|
||||||
ev := log.Tag(tagSMTP).Field("smtp_hostname", smtpConn.Hostname())
|
|
||||||
if smtpConn.Conn() != nil {
|
|
||||||
ev.Field("smtp_remote_addr", smtpConn.Conn().RemoteAddr().String())
|
|
||||||
}
|
|
||||||
return ev
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpContext(r *http.Request) log.Context {
|
|
||||||
requestURI := r.RequestURI
|
|
||||||
if requestURI == "" {
|
|
||||||
requestURI = r.URL.Path
|
|
||||||
}
|
|
||||||
return log.Context{
|
|
||||||
"http_method": r.Method,
|
|
||||||
"http_path": requestURI,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func websocketErrorContext(err error) log.Context {
|
|
||||||
if c, ok := err.(*websocket.CloseError); ok {
|
|
||||||
return log.Context{
|
|
||||||
"error": c.Error(),
|
|
||||||
"error_code": c.Code,
|
|
||||||
"error_type": "websocket.CloseError",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return log.Context{
|
|
||||||
"error": err.Error(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderHTTPRequest(r *http.Request) string {
|
|
||||||
peekLimit := 4096
|
|
||||||
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
|
||||||
for key, values := range r.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
lines += fmt.Sprintf("%s: %s\n", key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines += "\n"
|
|
||||||
body, err := util.Peek(r.Body, peekLimit)
|
|
||||||
if err != nil {
|
|
||||||
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
|
|
||||||
} else if utf8.Valid(body.PeekedBytes) {
|
|
||||||
lines += string(body.PeekedBytes)
|
|
||||||
if body.LimitReached {
|
|
||||||
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
|
|
||||||
}
|
|
||||||
lines += "\n"
|
|
||||||
} else {
|
|
||||||
if body.LimitReached {
|
|
||||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
|
|
||||||
} else {
|
|
||||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Body = body // Important: Reset body, so it can be re-read
|
|
||||||
return strings.TrimSpace(lines)
|
|
||||||
}
|
|
1
server/mailer_emoji.json
Normal file
|
@ -17,7 +17,6 @@ import (
|
||||||
var (
|
var (
|
||||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||||
errMessageNotFound = errors.New("message not found")
|
errMessageNotFound = errors.New("message not found")
|
||||||
errNoRows = errors.New("no rows found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Messages cache
|
// Messages cache
|
||||||
|
@ -52,14 +51,7 @@ const (
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
CREATE TABLE IF NOT EXISTS stats (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value INT
|
|
||||||
);
|
|
||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
|
@ -114,14 +106,11 @@ const (
|
||||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
|
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
|
||||||
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
||||||
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||||
|
|
||||||
selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
|
|
||||||
updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 11
|
currentSchemaVersion = 10
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
|
@ -226,35 +215,23 @@ const (
|
||||||
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
||||||
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
`
|
`
|
||||||
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||||
|
|
||||||
// 10 -> 11
|
|
||||||
migrate10To11AlterMessagesTableQuery = `
|
|
||||||
CREATE TABLE IF NOT EXISTS stats (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value INT
|
|
||||||
);
|
|
||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
|
||||||
`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||||
0: migrateFrom0,
|
0: migrateFrom0,
|
||||||
1: migrateFrom1,
|
1: migrateFrom1,
|
||||||
2: migrateFrom2,
|
2: migrateFrom2,
|
||||||
3: migrateFrom3,
|
3: migrateFrom3,
|
||||||
4: migrateFrom4,
|
4: migrateFrom4,
|
||||||
5: migrateFrom5,
|
5: migrateFrom5,
|
||||||
6: migrateFrom6,
|
6: migrateFrom6,
|
||||||
7: migrateFrom7,
|
7: migrateFrom7,
|
||||||
8: migrateFrom8,
|
8: migrateFrom8,
|
||||||
9: migrateFrom9,
|
9: migrateFrom9,
|
||||||
10: migrateFrom10,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -392,10 +369,10 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
log.Error("Message Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start))
|
log.Debug("Message Cache: Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -555,7 +532,7 @@ func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
|
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -632,7 +609,7 @@ func (c *messageCache) processMessageBatches() {
|
||||||
}
|
}
|
||||||
for messages := range c.queue.Dequeue() {
|
for messages := range c.queue.Dequeue() {
|
||||||
if err := c.addMessages(messages); err != nil {
|
if err := c.addMessages(messages); err != nil {
|
||||||
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
|
log.Error("Message Cache: %s", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -725,26 +702,6 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) UpdateStats(messages int64) error {
|
|
||||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *messageCache) Stats() (messages int64, err error) {
|
|
||||||
rows, err := c.db.Query(selectStatsQuery)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
if !rows.Next() {
|
|
||||||
return 0, errNoRows
|
|
||||||
}
|
|
||||||
if err := rows.Scan(&messages); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *messageCache) Close() error {
|
func (c *messageCache) Close() error {
|
||||||
return c.db.Close()
|
return c.db.Close()
|
||||||
}
|
}
|
||||||
|
@ -809,7 +766,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
log.Info("Migrating cache database schema: from 0 to 1")
|
||||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -823,7 +780,7 @@ func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
log.Info("Migrating cache database schema: from 1 to 2")
|
||||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -834,7 +791,7 @@ func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
log.Info("Migrating cache database schema: from 2 to 3")
|
||||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -845,7 +802,7 @@ func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
log.Info("Migrating cache database schema: from 3 to 4")
|
||||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -856,7 +813,7 @@ func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
log.Info("Migrating cache database schema: from 4 to 5")
|
||||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -867,7 +824,7 @@ func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
log.Info("Migrating cache database schema: from 5 to 6")
|
||||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -878,7 +835,7 @@ func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
log.Info("Migrating cache database schema: from 6 to 7")
|
||||||
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -889,7 +846,7 @@ func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
log.Info("Migrating cache database schema: from 7 to 8")
|
||||||
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -900,7 +857,7 @@ func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
log.Info("Migrating cache database schema: from 8 to 9")
|
||||||
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -911,7 +868,7 @@ func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
log.Info("Migrating cache database schema: from 9 to 10")
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -926,21 +883,8 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
if err := tx.Commit(); err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
|
return nil // Update this when a new version is added
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
}
|
||||||
|
|
895
server/server.go
|
@ -117,19 +117,18 @@
|
||||||
# attachment-expiry-duration: "3h"
|
# attachment-expiry-duration: "3h"
|
||||||
|
|
||||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
|
||||||
#
|
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
|
||||||
#
|
#
|
||||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||||
|
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
|
||||||
# - smtp-sender-from is the e-mail address of the sender
|
# - smtp-sender-from is the e-mail address of the sender
|
||||||
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
|
|
||||||
#
|
#
|
||||||
# smtp-sender-addr:
|
# smtp-sender-addr:
|
||||||
# smtp-sender-from:
|
|
||||||
# smtp-sender-user:
|
# smtp-sender-user:
|
||||||
# smtp-sender-pass:
|
# smtp-sender-pass:
|
||||||
|
# smtp-sender-from:
|
||||||
|
|
||||||
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
||||||
# emails to a topic e-mail address to publish messages to a topic.
|
# emails to a topic e-mail address to publish messages to a topic.
|
||||||
|
@ -144,18 +143,6 @@
|
||||||
# smtp-server-domain:
|
# smtp-server-domain:
|
||||||
# smtp-server-addr-prefix:
|
# smtp-server-addr-prefix:
|
||||||
|
|
||||||
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
|
|
||||||
#
|
|
||||||
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
|
||||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
|
||||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
|
||||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
|
||||||
#
|
|
||||||
# twilio-account:
|
|
||||||
# twilio-auth-token:
|
|
||||||
# twilio-phone-number:
|
|
||||||
# twilio-verify-service:
|
|
||||||
|
|
||||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||||
# intermediaries closing the connection for inactivity.
|
# intermediaries closing the connection for inactivity.
|
||||||
#
|
#
|
||||||
|
@ -168,24 +155,11 @@
|
||||||
#
|
#
|
||||||
# manager-interval: "1m"
|
# manager-interval: "1m"
|
||||||
|
|
||||||
# Defines topic names that are not allowed, because they are otherwise used. There are a few default topics
|
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||||
# that cannot be used (e.g. app, account, settings, ...). To extend the default list, define them here.
|
# web app. If you self-host, you don't want to change this.
|
||||||
|
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
|
||||||
#
|
#
|
||||||
# Example:
|
# web-root: app
|
||||||
# disallowed-topics:
|
|
||||||
# - about
|
|
||||||
# - pricing
|
|
||||||
# - contact
|
|
||||||
#
|
|
||||||
# disallowed-topics:
|
|
||||||
|
|
||||||
# Defines the root path of the web app, or disables the web app entirely.
|
|
||||||
#
|
|
||||||
# Can be any simple path, e.g. "/", "/app", or "/ntfy". For backwards-compatibility reasons,
|
|
||||||
# the values "app" (maps to "/"), "home" (maps to "/app"), or "disable" (maps to "") to disable
|
|
||||||
# the web app entirely.
|
|
||||||
#
|
|
||||||
# web-root: /
|
|
||||||
|
|
||||||
# Various feature flags used to control the web app, and API access, mainly around user and
|
# Various feature flags used to control the web app, and API access, mainly around user and
|
||||||
# account management.
|
# account management.
|
||||||
|
@ -208,12 +182,7 @@
|
||||||
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
||||||
#
|
#
|
||||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
|
||||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
|
||||||
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
|
||||||
#
|
|
||||||
# upstream-base-url:
|
# upstream-base-url:
|
||||||
# upstream-access-token:
|
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
#
|
#
|
||||||
|
@ -254,85 +223,20 @@
|
||||||
# visitor-attachment-total-size-limit: "100M"
|
# visitor-attachment-total-size-limit: "100M"
|
||||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
|
||||||
# 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
|
|
||||||
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
|
|
||||||
# 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
|
|
||||||
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
|
|
||||||
# to use when publishing on this topic. Note: Setting the rate visitor 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
|
|
||||||
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
|
|
||||||
#
|
|
||||||
# visitor-subscriber-rate-limiting: false
|
|
||||||
|
|
||||||
# Payments integration via Stripe
|
# Payments integration via Stripe
|
||||||
#
|
#
|
||||||
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
||||||
# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.
|
# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.
|
||||||
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
|
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||||
# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.
|
# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.
|
||||||
# - billing-contact is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
|
||||||
# out with billing questions. If unset, nothing will be displayed.
|
|
||||||
#
|
#
|
||||||
# stripe-secret-key:
|
# stripe-secret-key:
|
||||||
# stripe-webhook-key:
|
# stripe-webhook-key:
|
||||||
# billing-contact:
|
|
||||||
|
|
||||||
# Metrics
|
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||||
|
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||||
#
|
#
|
||||||
# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port.
|
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
||||||
# Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
# debugging purposes, or your disk will fill up quickly.
|
||||||
# doing, and/or secure access to the endpoint in your reverse proxy.
|
|
||||||
#
|
#
|
||||||
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
# log-level: INFO
|
||||||
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
|
|
||||||
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
|
||||||
#
|
|
||||||
# enable-metrics: false
|
|
||||||
# metrics-listen-http:
|
|
||||||
|
|
||||||
# Profiling
|
|
||||||
#
|
|
||||||
# ntfy can expose Go's net/http/pprof endpoints to support profiling of the ntfy server. If enabled, ntfy will listen
|
|
||||||
# on a dedicated listen IP/port, which can be accessed via the web browser on http://<ip>:<port>/debug/pprof/.
|
|
||||||
# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details.
|
|
||||||
#
|
|
||||||
# profile-listen-http:
|
|
||||||
|
|
||||||
# Logging options
|
|
||||||
#
|
|
||||||
# By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format.
|
|
||||||
# ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
|
|
||||||
# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded
|
|
||||||
# by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
|
||||||
#
|
|
||||||
# - log-format defines the output format, can be "text" (default) or "json"
|
|
||||||
# - log-file is a filename to write logs to. If this is not set, ntfy logs to stderr.
|
|
||||||
# - log-level defines the default log level, can be one of "trace", "debug", "info" (default), "warn" or "error".
|
|
||||||
# Be aware that "debug" (and particularly "trace") can be VERY CHATTY. Only turn them on briefly for debugging purposes.
|
|
||||||
# - log-level-overrides lets you override the log level if certain fields match. This is incredibly powerful
|
|
||||||
# for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
|
|
||||||
# This is an array of strings in the format:
|
|
||||||
# - "field=value -> level" to match a value exactly, e.g. "tag=manager -> trace"
|
|
||||||
# - "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.
|
|
||||||
#
|
|
||||||
# Example (good for production):
|
|
||||||
# log-level: info
|
|
||||||
# log-format: json
|
|
||||||
# log-file: /var/log/ntfy.log
|
|
||||||
#
|
|
||||||
# Example level overrides (for debugging, only use temporarily):
|
|
||||||
# log-level-overrides:
|
|
||||||
# - "tag=manager -> trace"
|
|
||||||
# - "visitor_ip=1.2.3.4 -> debug"
|
|
||||||
# - "time_taken_ms -> debug"
|
|
||||||
#
|
|
||||||
# log-level: info
|
|
||||||
# log-level-overrides:
|
|
||||||
# log-format: text
|
|
||||||
# log-file:
|
|
||||||
|
|
|
@ -12,13 +12,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
subscriptionIDLength = 16
|
||||||
|
subscriptionIDPrefix = "su_"
|
||||||
syncTopicAccountSyncEvent = "sync"
|
syncTopicAccountSyncEvent = "sync"
|
||||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
u := v.User()
|
u := v.User()
|
||||||
if !u.IsAdmin() { // u may be nil, but that's fine
|
if !u.Admin() { // u may be nil, but that's fine
|
||||||
if !s.config.EnableSignup {
|
if !s.config.EnableSignup {
|
||||||
return errHTTPBadRequestSignupNotEnabled
|
return errHTTPBadRequestSignupNotEnabled
|
||||||
} else if u != nil {
|
} else if u != nil {
|
||||||
|
@ -35,20 +37,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil { // TODO this should return a User
|
||||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v.AccountCreated()
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
info, err := v.Info()
|
info, err := v.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats")
|
|
||||||
limits, stats := info.Limits, info.Stats
|
limits, stats := info.Limits, info.Stats
|
||||||
response := &apiAccountResponse{
|
response := &apiAccountResponse{
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
|
@ -56,7 +55,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
Messages: limits.MessageLimit,
|
Messages: limits.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
||||||
Emails: limits.EmailLimit,
|
Emails: limits.EmailLimit,
|
||||||
Calls: limits.CallLimit,
|
|
||||||
Reservations: limits.ReservationsLimit,
|
Reservations: limits.ReservationsLimit,
|
||||||
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
|
@ -68,8 +66,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
MessagesRemaining: stats.MessagesRemaining,
|
MessagesRemaining: stats.MessagesRemaining,
|
||||||
Emails: stats.Emails,
|
Emails: stats.Emails,
|
||||||
EmailsRemaining: stats.EmailsRemaining,
|
EmailsRemaining: stats.EmailsRemaining,
|
||||||
Calls: stats.Calls,
|
|
||||||
CallsRemaining: stats.CallsRemaining,
|
|
||||||
Reservations: stats.Reservations,
|
Reservations: stats.Reservations,
|
||||||
ReservationsRemaining: stats.ReservationsRemaining,
|
ReservationsRemaining: stats.ReservationsRemaining,
|
||||||
AttachmentTotalSize: stats.AttachmentTotalSize,
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||||
|
@ -103,24 +99,21 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
Customer: true,
|
Customer: true,
|
||||||
Subscription: u.Billing.StripeSubscriptionID != "",
|
Subscription: u.Billing.StripeSubscriptionID != "",
|
||||||
Status: string(u.Billing.StripeSubscriptionStatus),
|
Status: string(u.Billing.StripeSubscriptionStatus),
|
||||||
Interval: string(u.Billing.StripeSubscriptionInterval),
|
|
||||||
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||||
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.config.EnableReservations {
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
reservations, err := s.userManager.Reservations(u.Name)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if len(reservations) > 0 {
|
||||||
if len(reservations) > 0 {
|
response.Reservations = make([]*apiAccountReservation, 0)
|
||||||
response.Reservations = make([]*apiAccountReservation, 0)
|
for _, r := range reservations {
|
||||||
for _, r := range reservations {
|
response.Reservations = append(response.Reservations, &apiAccountReservation{
|
||||||
response.Reservations = append(response.Reservations, &apiAccountReservation{
|
Topic: r.Topic,
|
||||||
Topic: r.Topic,
|
Everyone: r.Everyone.String(),
|
||||||
Everyone: r.Everyone.String(),
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokens, err := s.userManager.Tokens(u.ID)
|
tokens, err := s.userManager.Tokens(u.ID)
|
||||||
|
@ -143,15 +136,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.config.TwilioAccount != "" {
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(phoneNumbers) > 0 {
|
|
||||||
response.PhoneNumbers = phoneNumbers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
|
@ -171,12 +155,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
|
||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
}
|
}
|
||||||
if u.Billing.StripeSubscriptionID != "" {
|
if u.Billing.StripeSubscriptionID != "" {
|
||||||
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
|
logvr(v, r).Tag(tagPay).Info("Canceling billing subscription for user %s", u.Name)
|
||||||
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
|
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, 0); err != nil {
|
if err := s.maybeRemoveMessagesAndExcessReservations(logHTTPPrefix(v, r), u, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Info("Marking user %s as deleted", u.Name)
|
logvr(v, r).Tag(tagAccount).Info("Marking user %s as deleted", u.Name)
|
||||||
|
@ -197,14 +181,15 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||||
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
|
|
||||||
if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil {
|
if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Debug("Changed password for user %s", u.Name)
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -218,17 +203,11 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
||||||
expires = time.Unix(*req.Expires, 0)
|
expires = time.Unix(*req.Expires, 0)
|
||||||
}
|
}
|
||||||
u := v.User()
|
u := v.User()
|
||||||
logvr(v, r).
|
|
||||||
Tag(tagAccount).
|
|
||||||
Fields(log.Context{
|
|
||||||
"token_label": label,
|
|
||||||
"token_expires": expires,
|
|
||||||
}).
|
|
||||||
Debug("Creating token for user %s", u.Name)
|
|
||||||
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Debug("Created token for user %s", u.Name)
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
Label: token.Label,
|
Label: token.Label,
|
||||||
|
@ -240,6 +219,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
u := v.User()
|
u := v.User()
|
||||||
req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -254,15 +234,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
|
||||||
if req.Expires != nil {
|
if req.Expires != nil {
|
||||||
expires = util.Time(time.Unix(*req.Expires, 0))
|
expires = util.Time(time.Unix(*req.Expires, 0))
|
||||||
} else if req.Label == nil {
|
} else if req.Label == nil {
|
||||||
expires = util.Time(time.Now().Add(tokenExpiryDuration)) // If label/expires not set, extend token by 72 hours
|
// If label and expires are not set, simply extend the token by 72 hours
|
||||||
|
expires = util.Time(time.Now().Add(tokenExpiryDuration))
|
||||||
}
|
}
|
||||||
logvr(v, r).
|
|
||||||
Tag(tagAccount).
|
|
||||||
Fields(log.Context{
|
|
||||||
"token_label": req.Label,
|
|
||||||
"token_expires": expires,
|
|
||||||
}).
|
|
||||||
Debug("Updating token for user %s as deleted", u.Name)
|
|
||||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -278,6 +252,7 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
u := v.User()
|
u := v.User()
|
||||||
token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
|
token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
@ -289,10 +264,6 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
|
||||||
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logvr(v, r).
|
|
||||||
Tag(tagAccount).
|
|
||||||
Field("token", token).
|
|
||||||
Debug("Deleted token for user %s", u.Name)
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -323,8 +294,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
|
||||||
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name)
|
if err := s.userManager.ChangeSettings(u); err != nil {
|
||||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
@ -336,36 +306,43 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u := v.User()
|
u := v.User()
|
||||||
prefs := u.Prefs
|
if u.Prefs == nil {
|
||||||
if prefs == nil {
|
u.Prefs = &user.Prefs{}
|
||||||
prefs = &user.Prefs{}
|
|
||||||
}
|
}
|
||||||
for _, subscription := range prefs.Subscriptions {
|
newSubscription.ID = "" // Client cannot set ID
|
||||||
|
for _, subscription := range u.Prefs.Subscriptions {
|
||||||
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||||
return errHTTPConflictSubscriptionExists
|
newSubscription = subscription
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.Subscriptions = append(prefs.Subscriptions, newSubscription)
|
if newSubscription.ID == "" {
|
||||||
logvr(v, r).Tag(tagAccount).With(newSubscription).Debug("Adding subscription for user %s", u.Name)
|
newSubscription.ID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
|
||||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
u.Prefs.Subscriptions = append(u.Prefs.Subscriptions, newSubscription)
|
||||||
return err
|
if err := s.userManager.ChangeSettings(u); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSubscription)
|
return s.writeJSON(w, newSubscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
subscriptionID := matches[1]
|
||||||
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u := v.User()
|
u := v.User()
|
||||||
prefs := u.Prefs
|
if u.Prefs == nil || u.Prefs.Subscriptions == nil {
|
||||||
if prefs == nil || prefs.Subscriptions == nil {
|
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
}
|
}
|
||||||
var subscription *user.Subscription
|
var subscription *user.Subscription
|
||||||
for _, sub := range prefs.Subscriptions {
|
for _, sub := range u.Prefs.Subscriptions {
|
||||||
if sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic {
|
if sub.ID == subscriptionID {
|
||||||
sub.DisplayName = updatedSubscription.DisplayName
|
sub.DisplayName = updatedSubscription.DisplayName
|
||||||
subscription = sub
|
subscription = sub
|
||||||
break
|
break
|
||||||
|
@ -374,33 +351,31 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
|
||||||
if subscription == nil {
|
if subscription == nil {
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).With(subscription).Debug("Changing subscription for user %s", u.Name)
|
if err := s.userManager.ChangeSettings(u); err != nil {
|
||||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, subscription)
|
return s.writeJSON(w, subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// DELETEs cannot have a body, and we don't want it in the path
|
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL")
|
if len(matches) != 2 {
|
||||||
deleteTopic := readParam(r, "X-Topic", "Topic")
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
subscriptionID := matches[1]
|
||||||
u := v.User()
|
u := v.User()
|
||||||
prefs := u.Prefs
|
if u.Prefs == nil || u.Prefs.Subscriptions == nil {
|
||||||
if prefs == nil || prefs.Subscriptions == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
newSubscriptions := make([]*user.Subscription, 0)
|
newSubscriptions := make([]*user.Subscription, 0)
|
||||||
for _, sub := range u.Prefs.Subscriptions {
|
for _, subscription := range u.Prefs.Subscriptions {
|
||||||
if sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic {
|
if subscription.ID != subscriptionID {
|
||||||
logvr(v, r).Tag(tagAccount).With(sub).Debug("Removing subscription for user %s", u.Name)
|
newSubscriptions = append(newSubscriptions, subscription)
|
||||||
} else {
|
|
||||||
newSubscriptions = append(newSubscriptions, sub)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(newSubscriptions) < len(prefs.Subscriptions) {
|
if len(newSubscriptions) < len(u.Prefs.Subscriptions) {
|
||||||
prefs.Subscriptions = newSubscriptions
|
u.Prefs.Subscriptions = newSubscriptions
|
||||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
if err := s.userManager.ChangeSettings(u); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -424,11 +399,11 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||||
return errHTTPBadRequestPermissionInvalid
|
return errHTTPBadRequestPermissionInvalid
|
||||||
}
|
}
|
||||||
// Check if we are allowed to reserve this topic
|
// Check if we are allowed to reserve this topic
|
||||||
if u.IsUser() && u.Tier == nil {
|
if u.User() && u.Tier == nil {
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
|
} else if err := s.userManager.CheckAllowAccess(u.Name, req.Topic); err != nil {
|
||||||
return errHTTPConflictTopicReserved
|
return errHTTPConflictTopicReserved
|
||||||
} else if u.IsUser() {
|
} else if u.User() {
|
||||||
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
|
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -443,13 +418,6 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Actually add the reservation
|
// Actually add the reservation
|
||||||
logvr(v, r).
|
|
||||||
Tag(tagAccount).
|
|
||||||
Fields(log.Context{
|
|
||||||
"topic": req.Topic,
|
|
||||||
"everyone": everyone.String(),
|
|
||||||
}).
|
|
||||||
Debug("Adding topic reservation")
|
|
||||||
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
|
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -458,7 +426,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.CancelSubscribersExceptUser(u.ID)
|
t.CancelSubscribers(u.ID)
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -479,22 +447,14 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||||
} else if !authorized {
|
} else if !authorized {
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
}
|
}
|
||||||
deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages")
|
|
||||||
logvr(v, r).
|
|
||||||
Tag(tagAccount).
|
|
||||||
Fields(log.Context{
|
|
||||||
"topic": topic,
|
|
||||||
"delete_messages": deleteMessages,
|
|
||||||
}).
|
|
||||||
Debug("Removing topic reservation")
|
|
||||||
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
|
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages")
|
||||||
if deleteMessages {
|
if deleteMessages {
|
||||||
if err := s.messageCache.ExpireMessages(topic); err != nil {
|
if err := s.messageCache.ExpireMessages(topic); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.pruneMessages()
|
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
@ -502,100 +462,32 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||||
// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
|
// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
|
||||||
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
||||||
// The process relies on the manager to perform the actual deletions (see runManager).
|
// The process relies on the manager to perform the actual deletions (see runManager).
|
||||||
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
|
func (s *Server) maybeRemoveMessagesAndExcessReservations(logPrefix string, u *user.User, reservationsLimit int64) error {
|
||||||
reservations, err := s.userManager.Reservations(u.Name)
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if int64(len(reservations)) <= reservationsLimit {
|
} else if int64(len(reservations)) <= reservationsLimit {
|
||||||
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
topics := make([]string, 0)
|
topics := make([]string, 0)
|
||||||
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||||
topics = append(topics, reservations[i].Topic)
|
topics = append(topics, reservations[i].Topic)
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
|
log.Info("%s Removing excess reservations for topics %s", logPrefix, strings.Join(topics, ", "))
|
||||||
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go s.pruneMessages()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
} else if req.Channel != "sms" && req.Channel != "call" {
|
|
||||||
return errHTTPBadRequestPhoneNumberVerifyChannelInvalid
|
|
||||||
}
|
|
||||||
// Check user is allowed to add phone numbers
|
|
||||||
if u == nil || (u.IsUser() && u.Tier == nil) {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
} else if u.IsUser() && u.Tier.CallLimit == 0 {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
// Check if phone number exists
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if util.Contains(phoneNumbers, req.Number) {
|
|
||||||
return errHTTPConflictPhoneNumberExists
|
|
||||||
}
|
|
||||||
// Actually add the unverified number, and send verification
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
|
|
||||||
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
}
|
|
||||||
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
|
|
||||||
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
}
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
|
|
||||||
if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
||||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.publishSyncEvent(v); err != nil {
|
if err := s.publishSyncEvent(v); err != nil {
|
||||||
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
log.Trace("%s Error publishing to user's sync topic: %s", v.String(), err.Error())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
@ -606,7 +498,7 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
||||||
if u == nil || u.SyncTopic == "" {
|
if u == nil || u.SyncTopic == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
|
log.Trace("Publishing sync event to user %s's sync topic %s", u.Name, u.SyncTopic)
|
||||||
syncTopic, err := s.topicFromID(u.SyncTopic)
|
syncTopic, err := s.topicFromID(u.SyncTopic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -32,8 +31,8 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
||||||
require.True(t, strings.HasPrefix(token.Token, "tk_"))
|
require.True(t, strings.HasPrefix(token.Token, "tk_"))
|
||||||
require.Equal(t, "9.9.9.9", token.LastOrigin)
|
require.Equal(t, "9.9.9.9", token.LastOrigin)
|
||||||
require.True(t, token.LastAccess > time.Now().Unix()-2)
|
require.True(t, token.LastAccess > time.Now().Unix()-1)
|
||||||
require.True(t, token.LastAccess < time.Now().Unix()+2)
|
require.True(t, token.LastAccess < time.Now().Unix()+1)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
"Authorization": util.BearerAuth(token.Token),
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
@ -151,8 +150,6 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
||||||
require.Equal(t, int64(0), account.Stats.Emails)
|
require.Equal(t, int64(0), account.Stats.Emails)
|
||||||
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
||||||
require.Equal(t, int64(0), account.Stats.Calls)
|
|
||||||
require.Equal(t, int64(0), account.Stats.CallsRemaining)
|
|
||||||
|
|
||||||
rr = request(t, s, "POST", "/mytopic", "", nil)
|
rr = request(t, s, "POST", "/mytopic", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -216,11 +213,13 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, 1, len(account.Subscriptions))
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
|
require.NotEmpty(t, account.Subscriptions[0].ID)
|
||||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
require.Nil(t, account.Subscriptions[0].DisplayName)
|
require.Nil(t, account.Subscriptions[0].DisplayName)
|
||||||
|
|
||||||
rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{
|
subscriptionID := account.Subscriptions[0].ID
|
||||||
|
rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -231,14 +230,13 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, 1, len(account.Subscriptions))
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
|
require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
|
||||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName)
|
require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName)
|
||||||
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{
|
rr = request(t, s, "DELETE", "/v1/account/subscription/"+subscriptionID, "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
"X-BaseURL": "http://abc.com",
|
|
||||||
"X-Topic": "def",
|
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
@ -292,7 +290,6 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ExtendToken(t *testing.T) {
|
func TestAccount_ExtendToken(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
@ -315,17 +312,6 @@ func TestAccount_ExtendToken(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, token.Token, extendedToken.Token)
|
require.Equal(t, token.Token, extendedToken.Token)
|
||||||
require.True(t, token.Expires < extendedToken.Expires)
|
require.True(t, token.Expires < extendedToken.Expires)
|
||||||
|
|
||||||
expires := time.Now().Add(999 * time.Hour)
|
|
||||||
body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix())
|
|
||||||
rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{
|
|
||||||
"Authorization": util.BearerAuth(token.Token),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "some label", token.Label)
|
|
||||||
require.Equal(t, expires.Unix(), token.Expires)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
||||||
|
@ -451,7 +437,7 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
// A user, an admin, and a reservation walk into a bar
|
// A user, an admin, and a reservation walk into a bar
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
}))
|
}))
|
||||||
|
@ -500,8 +486,6 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||||
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
conf.EnableReservations = true
|
|
||||||
conf.TwilioAccount = "dummy"
|
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
|
@ -509,12 +493,11 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Create a tier
|
// Create a tier
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessageLimit: 123,
|
MessageLimit: 123,
|
||||||
MessageExpiryDuration: 86400 * time.Second,
|
MessageExpiryDuration: 86400 * time.Second,
|
||||||
EmailLimit: 32,
|
EmailLimit: 32,
|
||||||
CallLimit: 10,
|
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
AttachmentFileSizeLimit: 1231231,
|
AttachmentFileSizeLimit: 1231231,
|
||||||
AttachmentTotalSizeLimit: 123123,
|
AttachmentTotalSizeLimit: 123123,
|
||||||
|
@ -556,7 +539,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
require.Equal(t, int64(123), account.Limits.Messages)
|
require.Equal(t, int64(123), account.Limits.Messages)
|
||||||
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
||||||
require.Equal(t, int64(32), account.Limits.Emails)
|
require.Equal(t, int64(32), account.Limits.Emails)
|
||||||
require.Equal(t, int64(10), account.Limits.Calls)
|
|
||||||
require.Equal(t, int64(2), account.Limits.Reservations)
|
require.Equal(t, int64(2), account.Limits.Reservations)
|
||||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||||
|
@ -593,7 +575,7 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessageLimit: 20,
|
MessageLimit: 20,
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
|
@ -617,110 +599,101 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||||
require.Equal(t, 403, rr.Code)
|
require.Equal(t, 403, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.AuthDefault = user.PermissionReadWrite
|
conf.AuthDefault = user.PermissionReadWrite
|
||||||
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// Create user with tier
|
// Create user with tier
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser))
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Equal(t, 200, rr.Code)
|
||||||
Code: "pro",
|
|
||||||
MessageLimit: 20,
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
MessageExpiryDuration: time.Hour,
|
Code: "pro",
|
||||||
ReservationLimit: 2,
|
MessageLimit: 20,
|
||||||
AttachmentTotalSizeLimit: 10000,
|
ReservationLimit: 2,
|
||||||
AttachmentFileSizeLimit: 10000,
|
|
||||||
AttachmentExpiryDuration: time.Hour,
|
|
||||||
AttachmentBandwidthLimit: 10000,
|
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
// Reserve two topics "mytopic1" and "mytopic2"
|
// Subscribe anonymously
|
||||||
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{
|
anonCh, userCh := make(chan bool), make(chan bool)
|
||||||
|
go func() {
|
||||||
|
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
messages := toMessages(t, rr.Body.String())
|
||||||
|
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
|
||||||
|
require.Equal(t, "open", messages[0].Event)
|
||||||
|
require.Equal(t, "message before reservation", messages[1].Message)
|
||||||
|
anonCh <- true
|
||||||
|
log.Info("Anonymous subscription ended")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Subscribe with user
|
||||||
|
go func() {
|
||||||
|
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
messages := toMessages(t, rr.Body.String())
|
||||||
|
require.Equal(t, 3, len(messages))
|
||||||
|
require.Equal(t, "open", messages[0].Event)
|
||||||
|
require.Equal(t, "message before reservation", messages[1].Message)
|
||||||
|
require.Equal(t, "message after reservation", messages[2].Message)
|
||||||
|
userCh <- true
|
||||||
|
log.Info("User subscription ended")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Publish message (before reservation)
|
||||||
|
time.Sleep(2 * time.Second) // Wait for subscribers
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
|
||||||
|
|
||||||
|
// Reserve a topic
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{
|
// Everyone but phil should be killed
|
||||||
|
select {
|
||||||
|
case <-anonCh:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Waiting for anonymous subscription to be killed failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a message
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Publish a message with attachment to each topic
|
// Kill user Go routine
|
||||||
rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{
|
s.topics["mytopic"].CancelSubscribers("<invalid>")
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
m1 := toMessage(t, rr.Body.String())
|
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
|
||||||
|
|
||||||
rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{
|
select {
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
case <-userCh:
|
||||||
})
|
case <-time.After(5 * time.Second):
|
||||||
require.Equal(t, 200, rr.Code)
|
t.Fatal("Waiting for user subscription to be killed failed")
|
||||||
m2 := toMessage(t, rr.Body.String())
|
}
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
|
||||||
|
|
||||||
// Pre-verify message count and file
|
|
||||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(ms))
|
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
|
||||||
|
|
||||||
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(ms))
|
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
|
||||||
|
|
||||||
// Delete reservation
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
|
||||||
"X-Delete-Messages": "true",
|
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{
|
|
||||||
"X-Delete-Messages": "false",
|
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Verify that messages and attachments were deleted
|
|
||||||
// This does not explicitly call the manager!
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 0, len(ms))
|
|
||||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
|
||||||
|
|
||||||
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(ms))
|
|
||||||
require.Equal(t, m2.ID, ms[0].ID)
|
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.AuthDefault = user.PermissionReadWrite
|
conf.AuthDefault = user.PermissionReadWrite
|
||||||
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
|
conf.AuthStatsQueueWriterInterval = 100 * time.Millisecond
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// Create user with tier
|
// Create user with tier
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "starter",
|
Code: "starter",
|
||||||
MessageLimit: 10,
|
MessageLimit: 10,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessageLimit: 20,
|
MessageLimit: 20,
|
||||||
}))
|
}))
|
||||||
|
@ -732,12 +705,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Wait for stats queue writer, verify that message stats were persisted
|
// Wait for stats queue writer
|
||||||
waitFor(t, func() bool {
|
time.Sleep(200 * time.Millisecond)
|
||||||
u, err := s.userManager.User("phil")
|
|
||||||
require.Nil(t, err)
|
// Verify that message stats were persisted
|
||||||
return int64(1) == u.Stats.Messages
|
u, err := s.userManager.User("phil")
|
||||||
})
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(1), u.Stats.Messages)
|
||||||
|
|
||||||
// Change tier, make a request (to reset limiters)
|
// Change tier, make a request (to reset limiters)
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
@ -745,27 +719,10 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
|
||||||
require.Equal(t, int64(1), account.Stats.Messages) // Is not reset!
|
|
||||||
|
|
||||||
// Publish another message
|
|
||||||
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Verify that message stats were persisted
|
// Verify that message stats were persisted
|
||||||
waitFor(t, func() bool {
|
time.Sleep(300 * time.Millisecond)
|
||||||
u, err := s.userManager.User("phil")
|
u, err = s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
|
require.Equal(t, int64(0), u.Stats.Messages) // v.EnqueueStats had run!
|
||||||
})
|
}
|
||||||
|
|
||||||
// Stats keep counting
|
|
||||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
|
||||||
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
|
|
||||||
}*/
|
|
||||||
|
|
|
@ -1,143 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
users, err := s.userManager.Users()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grants, err := s.userManager.AllGrants()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
usersResponse := make([]*apiUserResponse, len(users))
|
|
||||||
for i, u := range users {
|
|
||||||
tier := ""
|
|
||||||
if u.Tier != nil {
|
|
||||||
tier = u.Tier.Code
|
|
||||||
}
|
|
||||||
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
|
|
||||||
for i, g := range grants[u.ID] {
|
|
||||||
userGrants[i] = &apiUserGrantResponse{
|
|
||||||
Topic: g.TopicPattern,
|
|
||||||
Permission: g.Allow.String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usersResponse[i] = &apiUserResponse{
|
|
||||||
Username: u.Name,
|
|
||||||
Role: string(u.Role),
|
|
||||||
Tier: tier,
|
|
||||||
Grants: userGrants,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, usersResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !user.AllowedUsername(req.Username) || req.Password == "" {
|
|
||||||
return errHTTPBadRequest.Wrap("username invalid, or password missing")
|
|
||||||
}
|
|
||||||
u, err := s.userManager.User(req.Username)
|
|
||||||
if err != nil && err != user.ErrUserNotFound {
|
|
||||||
return err
|
|
||||||
} else if u != nil {
|
|
||||||
return errHTTPConflictUserExists
|
|
||||||
}
|
|
||||||
var tier *user.Tier
|
|
||||||
if req.Tier != "" {
|
|
||||||
tier, err = s.userManager.Tier(req.Tier)
|
|
||||||
if err == user.ErrTierNotFound {
|
|
||||||
return errHTTPBadRequestTierInvalid
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tier != nil {
|
|
||||||
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u, err := s.userManager.User(req.Username)
|
|
||||||
if err == user.ErrUserNotFound {
|
|
||||||
return errHTTPBadRequestUserNotFound
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !u.IsUser() {
|
|
||||||
return errHTTPUnauthorized.Wrap("can only remove regular users from API")
|
|
||||||
}
|
|
||||||
if err := s.userManager.RemoveUser(req.Username); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.killUserSubscriber(u, "*"); err != nil { // FIXME super inefficient
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.userManager.User(req.Username)
|
|
||||||
if err == user.ErrUserNotFound {
|
|
||||||
return errHTTPBadRequestUserNotFound
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
permission, err := user.ParsePermission(req.Permission)
|
|
||||||
if err != nil {
|
|
||||||
return errHTTPBadRequestPermissionInvalid
|
|
||||||
}
|
|
||||||
if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u, err := s.userManager.User(req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error {
|
|
||||||
topics, err := s.topicsFromPattern(topicPattern)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, t := range topics {
|
|
||||||
t.CancelSubscriberUser(u.ID)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,181 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUser_AddRemove(t *testing.T) {
|
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
|
||||||
defer s.closeDatabases()
|
|
||||||
|
|
||||||
// Create admin, tier
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
|
||||||
Code: "tier1",
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Create user via API
|
|
||||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Create user with tier via API
|
|
||||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Check users
|
|
||||||
users, err := s.userManager.Users()
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 4, len(users))
|
|
||||||
require.Equal(t, "phil", users[0].Name)
|
|
||||||
require.Equal(t, "ben", users[1].Name)
|
|
||||||
require.Equal(t, user.RoleUser, users[1].Role)
|
|
||||||
require.Nil(t, users[1].Tier)
|
|
||||||
require.Equal(t, "emma", users[2].Name)
|
|
||||||
require.Equal(t, user.RoleUser, users[2].Role)
|
|
||||||
require.Equal(t, "tier1", users[2].Tier.Code)
|
|
||||||
require.Equal(t, user.Everyone, users[3].Name)
|
|
||||||
|
|
||||||
// Delete user via API
|
|
||||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
|
||||||
defer s.closeDatabases()
|
|
||||||
|
|
||||||
// Create admin
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
|
||||||
|
|
||||||
// Cannot create user with invalid username
|
|
||||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 400, rr.Code)
|
|
||||||
|
|
||||||
// Cannot create user if user already exists
|
|
||||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
|
||||||
|
|
||||||
// Cannot create user with invalid tier
|
|
||||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
|
||||||
|
|
||||||
// Cannot delete user as non-admin
|
|
||||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 401, rr.Code)
|
|
||||||
|
|
||||||
// Delete user via API
|
|
||||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccess_AllowReset(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
defer s.closeDatabases()
|
|
||||||
|
|
||||||
// User and admin
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
|
||||||
|
|
||||||
// Subscribing not allowed
|
|
||||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 403, rr.Code)
|
|
||||||
|
|
||||||
// Grant access
|
|
||||||
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Now subscribing is allowed
|
|
||||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Reset access
|
|
||||||
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Subscribing not allowed (again)
|
|
||||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 403, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
defer s.closeDatabases()
|
|
||||||
|
|
||||||
// User
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
|
||||||
|
|
||||||
// Grant access fails, because non-admin
|
|
||||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 401, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
defer s.closeDatabases()
|
|
||||||
|
|
||||||
// User and admin, grant access to "gol*" topics
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
|
||||||
|
|
||||||
start, timeTaken := time.Now(), atomic.Int64{}
|
|
||||||
go func() {
|
|
||||||
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
timeTaken.Store(time.Since(start).Milliseconds())
|
|
||||||
}()
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
// Reset access
|
|
||||||
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
|
|
||||||
// Wait for connection to be killed; this will fail if the connection is never killed
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return timeTaken.Load() >= 500
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -8,6 +8,7 @@ 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/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -38,22 +39,23 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||||
if !v.FirebaseAllowed() {
|
if err := v.FirebaseAllowed(); err != nil {
|
||||||
return errFirebaseTemporarilyBanned
|
return errFirebaseTemporarilyBanned
|
||||||
}
|
}
|
||||||
fbm, err := toFirebaseMessage(m, c.auther)
|
fbm, err := toFirebaseMessage(m, c.auther)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ev := logvm(v, m).Tag(tagFirebase)
|
if log.IsTrace() {
|
||||||
if ev.IsTrace() {
|
logvm(v, m).
|
||||||
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
|
Tag(tagFirebase).
|
||||||
|
Field("firebase_message", util.MaybeMarshalJSON(fbm)).
|
||||||
|
Trace("Firebase message")
|
||||||
}
|
}
|
||||||
err = c.sender.Send(fbm)
|
err = c.sender.Send(fbm)
|
||||||
if err == errFirebaseQuotaExceeded {
|
if err == errFirebaseQuotaExceeded {
|
||||||
logvm(v, m).
|
logvm(v, m).
|
||||||
Tag(tagFirebase).
|
Tag(tagFirebase).
|
||||||
Err(err).
|
|
||||||
Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor")
|
Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor")
|
||||||
v.FirebaseTemporarilyDeny()
|
v.FirebaseTemporarilyDeny()
|
||||||
}
|
}
|
||||||
|
|